Skip to content

symfonic.agent.subagents.lockdown

lockdown

No delegated child holds a block-edit tool, on any construction path.

A prompt block has exactly one writer. That is the whole guarantee the block layer offers an operator: the rules the agent runs under are written by a human (authored tiers) or by the consolidation timeline (learned tiers), and the agent reads them. A delegated child that could write a block breaks it twice over -- it is a second writer, and it is a writer the parent never sees, because the child runs its own loop with its own tool palette.

Why this exists before the tools do

This release registers no block-edit tool anywhere, so there is nothing here to strip yet. That is the argument for installing the lock now rather than alongside the tools: shipping the write surface and its child guard in one change means every construction path has to be found under the pressure of the feature landing, and a missed path is not a missing feature, it is a child that can rewrite its parent's rules. The paths are enumerated here while they are still cheap to enumerate, and :func:assert_no_block_edit_surface starts failing the day a tool with one of the reserved names can be built.

Construction-time, not run-time

Both entry points run at construction. Tool registration happens in SymfonicAgent.__init__ and the registry freezes with the graph, while agent_depth -- the only signal that says "you are a child" -- is known per run. A depth check inside the tool would therefore fire after the palette it was meant to restrict had already been advertised to the model. So the parent sanitises what it builds, and the registry rejects what it is handed.

The two paths, and why one is a refusal

:func:deny_child_self_edit covers the configs the parent builds: it returns a config with the self-edit request cleared. It is applied to both branches of the parent's child construction, including the caller-supplied SubAgentSpec.config -- a documented first-class parameter that otherwise passes straight through, which would make the lock decorative on exactly the path an adopter reaches for when they want something non-default.

:func:assert_no_block_edit_surface covers the children the parent is handed pre-built via SubAgent(agent=...). Those cannot be sanitised: the child is already constructed, its registry may already be frozen, and silently continuing would leave the caller believing in a lock that did not hold. It raises instead, following the duplicate-name precedent in :class:~symfonic.agent.subagents.registry.SubAgentRegistry. It inspects the child's registered tools and not only its config flag, because a hand-registered tool never consulted the flag.

SELF_EDIT_FIELD module-attribute

SELF_EDIT_FIELD = 'prompt_block_self_edit'

The FrameworkConfig field naming the (denied) child write surface.

assert_no_block_edit_surface

assert_no_block_edit_surface(name: str, agent: Any) -> None

Raise if pre-built child agent can write a prompt block.

Parameters:

Name Type Description Default
name str

The child's routing key, for the error message.

required
agent Any

The child agent being registered.

required

Raises:

Type Description
ValueError

If the child carries a block-edit tool, or a config requesting one.

Source code in src/symfonic/agent/subagents/lockdown.py
def assert_no_block_edit_surface(name: str, agent: Any) -> None:
    """Raise if pre-built child ``agent`` can write a prompt block.

    Args:
        name: The child's routing key, for the error message.
        agent: The child agent being registered.

    Raises:
        ValueError: If the child carries a block-edit tool, or a config
            requesting one.
    """
    tools = registered_block_edit_tools(agent)
    if tools:
        raise ValueError(
            f"sub-agent {name!r} was built with the block-edit tool(s) "
            f"{list(tools)!r} registered. A delegated child holds no block-edit "
            "tool on any construction path: a prompt block has one writer, and a "
            "child that can write one is a second writer the parent never sees. "
            "Remove the tool from the child, or declare the child as a "
            "SubAgentSpec -- specs are built by the parent, which clears the "
            "write surface for you."
        )
    if wants_block_self_edit(getattr(agent, "_config", None)):
        raise ValueError(
            f"sub-agent {name!r} was built with a config setting "
            f"{SELF_EDIT_FIELD}=True. A delegated child never receives the block "
            "self-edit surface, and a pre-built child cannot be sanitised after "
            "the fact -- its tool registry is already built. Set "
            f"{SELF_EDIT_FIELD}=False on the child's config, or declare the child "
            "as a SubAgentSpec so the parent builds it with the flag cleared."
        )

deny_child_self_edit

deny_child_self_edit(config: Any) -> Any

Return config with the block self-edit request cleared.

The parent's answer on every path where it builds the child's config itself. Returns the object unchanged when the flag is already off -- which is the default, so the common path allocates nothing and keeps config identity intact for callers that compare it.

A config that asks for self-edit but cannot be copied (a stub, a plain namespace) is refused rather than passed through: a lock that silently gives up on an input shape it did not expect is not a lock.

Parameters:

Name Type Description Default
config Any

The child FrameworkConfig (or any object carrying the flag).

required

Returns:

Type Description
Any

A config whose self-edit flag is False.

Raises:

Type Description
TypeError

If the flag is set and config exposes no pydantic model_copy to clear it through.

Source code in src/symfonic/agent/subagents/lockdown.py
def deny_child_self_edit(config: Any) -> Any:
    """Return ``config`` with the block self-edit request cleared.

    The parent's answer on every path where it builds the child's config
    itself. Returns the object unchanged when the flag is already off --
    which is the default, so the common path allocates nothing and keeps
    config identity intact for callers that compare it.

    A config that asks for self-edit but cannot be copied (a stub, a
    plain namespace) is refused rather than passed through: a lock that
    silently gives up on an input shape it did not expect is not a lock.

    Args:
        config: The child ``FrameworkConfig`` (or any object carrying the
            flag).

    Returns:
        A config whose self-edit flag is ``False``.

    Raises:
        TypeError: If the flag is set and ``config`` exposes no
            pydantic ``model_copy`` to clear it through.
    """
    if not wants_block_self_edit(config):
        return config
    copier = getattr(config, "model_copy", None)
    if not callable(copier):
        raise TypeError(
            f"a delegated child was given a config with {SELF_EDIT_FIELD}=True "
            f"of type {type(config).__name__!r}, which exposes no model_copy to "
            "clear it through. A child never holds a block-edit tool, and a "
            "config that cannot be sanitised cannot be used for one -- pass a "
            "FrameworkConfig, or set the flag False yourself."
        )
    return copier(update={SELF_EDIT_FIELD: False})

registered_block_edit_tools

registered_block_edit_tools(agent: Any) -> tuple[str, ...]

Return the names of block-edit tools registered on agent.

The authoritative check, because it reads what the model will actually be offered. A tool handed straight to SymfonicAgent(tools=[...]) never passed through :attr:SELF_EDIT_FIELD, so a config-only guard would wave it through.

Duck-typed on purpose: anything without a tool registry -- the async run(query, ...) adapters SubAgent explicitly accepts -- has no palette to inspect and returns (). It is not a SymfonicAgent, so it holds no tool this framework registered, and whatever it does hold is the caller's own surface to govern.

v9.2.x review fix (t11-child-lockdown): the probe used to call all_tools() and iterate the result with no guard, so a child that synthesises attributes on access (unittest.mock.Mock, a lazy/remote proxy) passed the callable() check, returned a non-iterable object, and crashed parent construction with a raw TypeError/whatever the proxy itself raises -- for a palette the child does not actually have. Inspection now degrades to "nothing found" instead of failing construction: this is a probe, and a child this framework did not build is not obligated to answer it in any particular shape.

v9.2.x review fix (t11-child-lockdown, cross-shard LOW): an adapter that DELEGATES to a real SymfonicAgent -- holds it on an attribute and forwards run -- rather than inheriting from it has no _graph of its own, so the direct lookup above found nothing and the wrapped agent's tools were invisible to this guard. When the direct lookup finds no registry, one level of delegation is now probed: the agent's own attributes are searched for a genuine SymfonicAgent instance and the check retried against it. A duck child that holds no SymfonicAgent (the documented adapter shape, a plain callable, a Mock) never matches, so it is unaffected.

Source code in src/symfonic/agent/subagents/lockdown.py
def registered_block_edit_tools(agent: Any) -> tuple[str, ...]:
    """Return the names of block-edit tools registered on ``agent``.

    The authoritative check, because it reads what the model will
    actually be offered. A tool handed straight to
    ``SymfonicAgent(tools=[...])`` never passed through
    :attr:`SELF_EDIT_FIELD`, so a config-only guard would wave it
    through.

    Duck-typed on purpose: anything without a tool registry -- the
    ``async run(query, ...)`` adapters ``SubAgent`` explicitly accepts --
    has no palette to inspect and returns ``()``. It is not a
    ``SymfonicAgent``, so it holds no tool this framework registered, and
    whatever it does hold is the caller's own surface to govern.

    v9.2.x review fix (t11-child-lockdown): the probe used to call
    ``all_tools()`` and iterate the result with no guard, so a child
    that synthesises attributes on access (``unittest.mock.Mock``, a
    lazy/remote proxy) passed the ``callable()`` check, returned a
    non-iterable object, and crashed parent construction with a raw
    ``TypeError``/whatever the proxy itself raises -- for a palette the
    child does not actually have. Inspection now degrades to "nothing
    found" instead of failing construction: this is a *probe*, and a
    child this framework did not build is not obligated to answer it in
    any particular shape.

    v9.2.x review fix (t11-child-lockdown, cross-shard LOW): an adapter
    that DELEGATES to a real ``SymfonicAgent`` -- holds it on an
    attribute and forwards ``run`` -- rather than inheriting from it has
    no ``_graph`` of its own, so the direct lookup above found nothing
    and the wrapped agent's tools were invisible to this guard. When the
    direct lookup finds no registry, one level of delegation is now
    probed: the agent's own attributes are searched for a genuine
    ``SymfonicAgent`` instance and the check retried against it. A duck
    child that holds no ``SymfonicAgent`` (the documented adapter shape,
    a plain callable, a ``Mock``) never matches, so it is unaffected.
    """
    tools = _direct_registered_tools(agent)
    if tools is not None:
        return tools
    return _delegated_registered_tools(agent)

wants_block_self_edit

wants_block_self_edit(config: Any) -> bool

Return True if config requests the block self-edit surface.

Read by getattr so an object that has never heard of the field -- a test double, a foreign config -- answers False instead of raising.

v9.2.x review fix (t11-child-lockdown): compares with is True rather than bool(...). A duck-typed child that auto-generates attributes on access -- unittest.mock.MagicMock, a lazy/remote proxy implementing __getattr__ -- answers any attribute read with a truthy object, so bool(getattr(config, SELF_EDIT_FIELD, False)) reported such a child as requesting self-edit even though it never declared the field. Only a real, explicitly-set True counts.

Source code in src/symfonic/agent/subagents/lockdown.py
def wants_block_self_edit(config: Any) -> bool:
    """Return ``True`` if ``config`` requests the block self-edit surface.

    Read by ``getattr`` so an object that has never heard of the field
    -- a test double, a foreign config -- answers ``False`` instead of
    raising.

    v9.2.x review fix (t11-child-lockdown): compares with ``is True``
    rather than ``bool(...)``. A duck-typed child that auto-generates
    attributes on access -- ``unittest.mock.MagicMock``, a lazy/remote
    proxy implementing ``__getattr__`` -- answers any attribute read with
    a truthy object, so ``bool(getattr(config, SELF_EDIT_FIELD, False))``
    reported such a child as requesting self-edit even though it never
    declared the field. Only a real, explicitly-set ``True`` counts.
    """
    return getattr(config, SELF_EDIT_FIELD, False) is True