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
¶
The FrameworkConfig field naming the (denied) child write surface.
assert_no_block_edit_surface ¶
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
deny_child_self_edit ¶
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 |
required |
Returns:
| Type | Description |
|---|---|
Any
|
A config whose self-edit flag is |
Raises:
| Type | Description |
|---|---|
TypeError
|
If the flag is set and |
Source code in src/symfonic/agent/subagents/lockdown.py
registered_block_edit_tools ¶
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
wants_block_self_edit ¶
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.