Structured tool arguments (RFC 0090)
When to read this
Section titled “When to read this”Before touching how the planner/router pass arguments to a tool, or how those
arguments are risk-classified / DLP-scanned / governed. This is the “args” axis
that sits beside the existing (tool, action) selection axis.
The one-line model
Section titled “The one-line model”A tool plan is (tool, action, **args**). The current native main-brain picker
selects from server-built closed function schemas, then the one shared tier/gate
tail validates (tool, action) before a read executes or a write becomes an
approval proposal. Model-supplied argument values never control identity,
authorization, or gate choice. Off-schema or DLP-flagged values are dropped,
clamped, denied, or escalated — never silently executed. The older deterministic
planner/router descriptions below remain useful for their carrier and governance
details, but they are not the current source of routing authority (ADR 0220 /
INV-029).
Native free-text query carry
Section titled “Native free-text query carry”Many native actions use the one closed optional/required query property rather
than widening the global decoder with tool-specific keys. The selector must make
that query self-contained from the current request plus prior scanned turns
framed as DATA; executors receive runtime._effective_tool_query, with raw user
text only as the documented fallback or per-tool exception. A downstream
integration may parse that string into a smaller frozen request model before
network I/O.
github.list_repos is the bounded example: its optional query preserves wording
such as “top 5”; planner.external_sources.parse_explicit_result_count supplies
the shared count grammar with a caller-specific ceiling; the GitHub integration
turns that into a frozen list request and sends only the resolved per_page
value. This does not add a max_results schema key, alter the native decoder, or
give the model control over account scope.
The pieces
Section titled “The pieces”- Schema (
planner/tool_args_schema.py): each carrier is a frozen, closed dataclass —WeatherArgs,GmailReadArgs,CalendarListArgs,TodoListArgs,DriveListArgs. Closed by construction: unexpected keys are rejected, bounded value sets are independent constants (e.g.WEATHER_WHEN_VALUES,GMAIL_MAILBOX_VALUES) pinned a second time intests/invariants/test_planner_router_fitness.pyso a widening hits CODEOWNERS. Identity/scope (owner_id/account_ref/calendar_id/file_id) is never a field — it stays server-derived (INV-007 tenant isolation).ToolArgsis the closed union of all carriers;ToolPlan.tool_args(planner/schemas.py) is the optional typed side-channel, mirroringpublic_source. - Populate. The deterministic planner attaches a carrier only when the user
expressed something meaningful, else
None(byte-identical):deterministic.py::_weather_plan/_gmail_plan/_drive_plan, usingrule_helpers.parse_gmail_read_args(which mirrorsgmail_filter_queryexactly). The LLM router attaches args after the read-only gates viagpt_adapter._typed_router_args, so args can never widen the allowlist. RFC 0096 / ADR 0149 Phase 1 (engine_llm_args_enabled, default-off): the registry engine (decider_engine.route_via_engine) may emit additional typed args beyond weatherwhen— today a calendar-cancelevent_idintoCalendarCancelArgs, attached after the proposal gate (keyed off the post-gate(tool, action)). Inert: the cancel emitter still reads theevent_id=query carrier, so nothing consumes the emitted mutation arg yet; emitter consumption is a later phase. Flag-off is byte-identical (the engine prompt, the acceptedargskeys, and the mapper all behave as today). - Validate ids — THE injection boundary (RFC 0096 / ADR 0149 Phase 2,
engine_id_validation_enabled, default-off; INV-022 / INV-023). Before the approval token binds any LLM-emitted concrete id, the proposal emitter checks it is a member of the server-controlled recent-list — the ids the server returned from the most recent gmail/calendar list, never attacker-influenced subject/sender/summary text. The shared predicateruntime._proposal_ids_in_recent_list(candidate_ids, recent_ids)is fail-closed (empty recent-list / empty-or-blank candidate / any candidate absent → reject, exactstr.stripcompare)._emit_calendar_cancel_proposal_chipvalidates theevent_idagainst_recent_calendar_list_event_ids;_emit_gmail_bulk_proposal_chipvalidates everymessage_idagainst_recent_gmail_list_message_ids(one non-member degrades the whole set). A non-member id degrades to a clarify and binds nothing. The SAME boundary covers the chipless instant-execute lane (ADR 0153 —google_gmail.mark_read, which the engine emits asdecision=="tool"to execute directly): before the broker call,_execute_gmail_lowrisk_writere-checks every parsedmessage_idagainst_recent_gmail_list_message_ids(chat_key)with the same fail-closed predicate (sameengine_id_validation_enabledgate), so a hallucinated/injected id is never marked-read on the tool-path. Today theengine_arg_backfillindex-resolution supplies this guarantee implicitly, so the flag must be ON before Phase 4 retires that backfill (FAILURE_MODESmode 400). The ordering-awaretest_llm_router_guardian.pypin fails the build if the check is removed, un-gated, or moved after the bind. - Consume. The weather executor renders a date-matched tomorrow brief behind
STEELMOTH_RUNTIME_TOOL_ARGS_WEATHER_ENABLED(live on the operator). Other carriers are not yet consumed by executors. - Govern (shadow).
governance_wiring.record_tool_governance_shadowis called at the top ofruntime._execute_tool_action(WIRE-B). It derives the required capability server-side from(tool, action)(required_capability_for— the confused-deputy defense), runsauthz.govern()(capability → DISK risk → policy → DLP → audit), and records what it would decide without ever blocking (is_enforcing()is False by default). Governed scope: email act- browser execute + tier-2 reads (gmail/calendar/tasks/todo list/read).
Typed arg values are folded into the content
govern()risk/DLP-scans, so a value sourced from the router/carryover is governed too.
- browser execute + tier-2 reads (gmail/calendar/tasks/todo list/read).
Typed arg values are folded into the content
- Egress firewall (
authz/egress.py).check_egress(url)is the pure, deny-by-default SSRF gate the fetch paths consult (browser_egress.guard_navigation,agent_tools/pdf_from_url.py): it refuses bad schemes, internal hostnames, and literal private/loopback/link-local/metadata IPs. RFC 0095 / ADR 0148 (Phase 6a) adds an optional positive per-hostallowlistarm — passedNone(the default) it is byte-identical, passed a host set it additionally refuses a public host that is not a member (REASON_HOST_NOT_ALLOWLISTED), consulted after the SSRF refusals (fail-closed ordering).EGRESS_ALLOWLISTSis empty on merge and pinned a second time intests/invariants/test_planner_router_fitness.py(_EGRESS_ALLOWLIST_GROUND_TRUTH), like the read-only / arg allowlists. TheSTEELMOTH_RUNTIME_EGRESS_ALLOWLIST_MODEconfig knob (off|shadow|enforce, defaultoff) is substrate: 6a ships the mechanism inert — no fetch caller handscheck_egressan allowlist yet, so behaviour is unchanged. Wiring the call sites and the allowlist enforce flip — part of the F12 gate that precedes un-shadowing the registry decider — are later, operator-gated steps. - DNS-rebind recheck (
browser_egress.py, Phase 6b).check_egressis DNS-blind by design (it blocks a literal private IP, not a hostname that resolves to one). 6b adds the pureis_blocked_address(ip)predicate +REASON_RESOLVED_TO_BLOCKEDtoegress.py, and the DNS-resolvingresolved_host_is_blocked(host, *, resolver=socket.getaddrinfo)helper tobrowser_egress.py. When the existingSTEELMOTH_RUNTIME_BROWSER_SSRF_GUARD_ENABLEDflag is on,guard_navigation(the single chokepoint for the Steel / screenshot-subprocess / Playwright-pdf nav paths) andpdf_from_url’s post-redirect recheck resolve an allowed hostname and refuse it if any resolved IP (v4/v6) is blocked. Inert / default-off: thegetaddrinforuns only inside the flag-on branch (no DNS I/O, byte-identical when off), and it is fail-open (a resolution error allows + logs). Known residual: the runtime’s resolution and the browser’s own (separate-process) resolution are independent lookups — a best-effort pre-nav defense, not in-band interception; the true boundary stays the browser container’s network isolation. The enforce flip (turning the flag on) is a later, operator-gated step.
Risk tiers and status
Section titled “Risk tiers and status”Tier-1 read-only (weather, web_search, todo/browser_task list) → validator only.
Tier-2 client-data reads → validator + server-derived tenant scope + egress DLP
(schema + populate + shadow observation shipped). Tier-3 mutating/external →
validator + Bifrost ESCALATE→HITL + args_hash + DLP (the gmail mark/star,
calendar/todo, and browser-nav gaps; P4, not yet built, gated on the
safety-eval harness gate). The steelmoth-native adversarial safety-eval
harness that gates tier-3 is landing under tests/safety_eval/ (RFC 0090 §6;
ported from the read-only PronterLabs reference and asserted against
authz.govern / authz.dlp).
Invariants to respect
Section titled “Invariants to respect”INV-007 (identity/scope never arg-fillable), INV-012 (closed typed boundaries, parse-don’t-validate), INV-023 (no hard-delete on client-data adapters), INV-024 (no broker relocation when wiring governance). Every enforce-flip or deploy is operator-gated (HR6); the governance seam is shadow-only until then.
RFC 0118 §3.7 argument-extensions (PR-C)
Section titled “RFC 0118 §3.7 argument-extensions (PR-C)”Two write carriers gained bounded optional fields; every pre-existing canonical hash is unchanged because the new keys enter the canonical dict only when set:
GmailSendArgs(planner/tool_args_schema.py) —forward_message_id(+include_forwarded_attachments) marks the send as a forward of one server-resolved prior-list message, andattachment_idscarries comma-joined OWNER attachment-store ids (each re-verified against the redeem scope before any byte is read). A forward WITHOUT file parts folds the quoted original intobodyand rides the untouched broker send; file-bearing sends dispatch through the sharedgmail_sendexecutor with the gsuite functions bound (the ADR 0181 client-OAuth precedent), keeping the outbound injection scan and the redeem DLP gate.CalendarCreateArgs/CalendarUpdateArgs—recurrenceholds ONE bounded RRULE string (FREQ=…, <= 200 chars, validated in__post_init__), threaded canonical-args → chip → redeem →events().insert/patchrecurrence=[...].

