Skip to content

Structured tool arguments (RFC 0090)

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.

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).

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.

  • 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 in tests/invariants/test_planner_router_fitness.py so 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). ToolArgs is the closed union of all carriers; ToolPlan.tool_args (planner/schemas.py) is the optional typed side-channel, mirroring public_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, using rule_helpers.parse_gmail_read_args (which mirrors gmail_filter_query exactly). The LLM router attaches args after the read-only gates via gpt_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 weather when — today a calendar-cancel event_id into CalendarCancelArgs, attached after the proposal gate (keyed off the post-gate (tool, action)). Inert: the cancel emitter still reads the event_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 accepted args keys, 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 predicate runtime._proposal_ids_in_recent_list(candidate_ids, recent_ids) is fail-closed (empty recent-list / empty-or-blank candidate / any candidate absent → reject, exact str.strip compare). _emit_calendar_cancel_proposal_chip validates the event_id against _recent_calendar_list_event_ids; _emit_gmail_bulk_proposal_chip validates every message_id against _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 as decision=="tool" to execute directly): before the broker call, _execute_gmail_lowrisk_write re-checks every parsed message_id against _recent_gmail_list_message_ids(chat_key) with the same fail-closed predicate (same engine_id_validation_enabled gate), so a hallucinated/injected id is never marked-read on the tool-path. Today the engine_arg_backfill index-resolution supplies this guarantee implicitly, so the flag must be ON before Phase 4 retires that backfill (FAILURE_MODES mode 400). The ordering-aware test_llm_router_guardian.py pin 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_shadow is called at the top of runtime._execute_tool_action (WIRE-B). It derives the required capability server-side from (tool, action) (required_capability_for — the confused-deputy defense), runs authz.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.
  • 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-host allowlist arm — passed None (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_ALLOWLISTS is empty on merge and pinned a second time in tests/invariants/test_planner_router_fitness.py (_EGRESS_ALLOWLIST_GROUND_TRUTH), like the read-only / arg allowlists. The STEELMOTH_RUNTIME_EGRESS_ALLOWLIST_MODE config knob (off|shadow|enforce, default off) is substrate: 6a ships the mechanism inert — no fetch caller hands check_egress an 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_egress is DNS-blind by design (it blocks a literal private IP, not a hostname that resolves to one). 6b adds the pure is_blocked_address(ip) predicate + REASON_RESOLVED_TO_BLOCKED to egress.py, and the DNS-resolving resolved_host_is_blocked(host, *, resolver=socket.getaddrinfo) helper to browser_egress.py. When the existing STEELMOTH_RUNTIME_BROWSER_SSRF_GUARD_ENABLED flag is on, guard_navigation (the single chokepoint for the Steel / screenshot-subprocess / Playwright-pdf nav paths) and pdf_from_url’s post-redirect recheck resolve an allowed hostname and refuse it if any resolved IP (v4/v6) is blocked. Inert / default-off: the getaddrinfo runs 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.

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).

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.

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, and attachment_ids carries 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 into body and rides the untouched broker send; file-bearing sends dispatch through the shared gmail_send executor with the gsuite functions bound (the ADR 0181 client-OAuth precedent), keeping the outbound injection scan and the redeem DLP gate.
  • CalendarCreateArgs / CalendarUpdateArgsrecurrence holds ONE bounded RRULE string (FREQ=…, <= 200 chars, validated in __post_init__), threaded canonical-args → chip → redeem → events().insert/patch recurrence=[...].