Skip to content

proposals — AgentTeamsModule

Orchestrator-only-writes pilot, phase P1. The orchestrator applies typed change and deletion proposals and runs typed command requests for the agents it dispatched, under a policy registered in the brief.

Source: agentteams/proposals.py · CLI: agentteams/cli/proposal_commands.py (both integrity-pinned)


Under the opt-in write_policy: "orchestrator-only" pilot, non-orchestrator agents don't edit files or run commands. They return one of three artifacts:

Artifact Schema
Change proposal: {kind, dispatch, path, base_sha256, content, rationale, gates?} schemas/change-proposal.schema.json
Deletion proposal: {kind, dispatch, path, base_sha256, rationale} schemas/delete-proposal.schema.json
Command request: {kind, dispatch, argv, purpose, cwd?, expected_writes?, stdin_from_content?} schemas/command-request.schema.json

Flow

  1. Dispatch. agentteams --issue-dispatch --agent SLUG --project ROOT prints a nonce for the agent. The orchestrator includes it in the agent's task. Only HMAC(key, nonce) is stored, so reading .agentteams/ reveals no usable nonce. A nonce allows 25 artifacts within 24 hours, and dry runs don't count against it. Expired dispatches are compacted away.
  2. The agent returns an artifact carrying that nonce. If the artifact also has an agent field, it must match the dispatch record.
  3. The orchestrator runs agentteams --apply-proposal FILE or --run-request FILE, with --description BRIEF --project ROOT [--dry-run].
  4. The agent is read from the signed dispatch record, so prose in a proposal cannot steer the orchestrator into acting as another agent.
  5. --dry-run runs every check, including the gates, which execute, but changes nothing.
  6. Audit. agentteams --verify-proposal-ledger --project ROOT checks the ledger.

Every operation needs an operator key. Under the switch only the out-of-session runner holds it, as a 0600 file in ~/.config/agentteams/keys (see Out-of-session runner). A direct, non-switch call uses AGENTTEAMS_PROPOSAL_LEDGER_KEY, else AGENTTEAMS_DECISION_SIGNING_KEY. Without a key, everything is refused.

Checks

Proposals. - The path must not resolve outside the project. - These paths are refused: - the control plane, case-folded: .git, .claude, .goose, .codex, .github/agents, .github/hooks, sandbox, .agentteams; - the brief itself; - anything matched by protected_paths. A pattern without wildcards also covers everything below it. - The path must be inside the agent's write_scopes. Each scope is a file, a directory ending in /, or (P5b) a pattern such as reports/dossiers/*/strategy.md. In a pattern, *, ? and [...] match within one path segment and never match a dot-segment, and a trailing / covers everything below the pattern, dot-files and dot-directories included (reports/*/ covers reports/p2/.envrc). The first segment must be literal, with no **, .., absolute or ~ paths, and no control-plane segment. Protected paths and the control plane still win over a matching scope. - One *, two meanings in one brief. In write_scopes, * matches one path segment. In proposal_gates globs (and protected_paths, and command writes globs) it is fnmatch's *, which crosses /: lean/*.lean gates lean/MathAgents/X/CX.lean too. A gate glob therefore over-gates, which fails safe, while a scope pattern never widens past its segment. - Content is UTF-8 text within the size cap (256 KiB). A target that isn't UTF-8 is refused. - The base must not be stale. - Every requested gate, and every gate whose glob matches the path (case-insensitive), runs on a temporary copy of the proposed content. - The destination is re-resolved and the base re-checked after the gates and immediately before an atomic write, which keeps the file's mode.

Requests. - argv must start with one of the agent's prefixes, and each remaining argument must fullmatch its pattern. A variadic entry's last pattern covers one or more trailing arguments. - An argument containing a .. path segment is refused at run time, whatever the pattern admits. - argv[0] is resolved on a sanitized PATH and must not live inside the project. - cwd must be the entry's pinned cwd. - expected_writes must lie in the entry's writes globs or in the agent's scopes, and never in the control plane. - stdin_from_content is accepted only by entries that register stdin_gates, which run on it first.

Environment. Gates and commands get only locale and HOME-type variables plus a PATH of absolute entries. Signing keys and tokens are never passed.

Undeclared writes fail the run (exit 3; a timeout exits 3 too). - What is compared: tracked and untracked files by content hash, and the watched control plane by size, mtime, ctime and inode, before and after the command. - The control plane covers .agentteams, .claude, .goose, .codex, .github/agents, .github/hooks and sandbox. - It also covers git's hooks, config, config.worktree and info, resolved with git rev-parse --git-path, so linked worktrees and core.hooksPath are watched too. If git can't resolve them (e.g. a git older than 2.31), the command is refused. - ctime cannot be set back by the user, so a same-size rewrite with its mtime restored still shows. - Timeouts: a command runs in its own process group. On timeout the whole group is killed, then the command is still checked: its writes are compared and the ledger is tamper-checked before the failure is recorded. - No git worktree: the command is refused (fail-closed). - Ledger tampering: if the command changed the ledger, its head or the dispatch records, nothing more is recorded (LedgerTamperedError) and the operator must inspect. The write-ahead run-request-start row then has no matching completion row. That gap is the signal to look for. - Write-ahead: a ledger row is written before every write, deletion and command run, so a crash leaves a recorded intent. - Blind spot: files ignored by git (build directories) are invisible to this check.

Policy lint (load_policy) refuses: - argument patterns that would admit an option (-x), a parent path (../x) or an absolute path; - shells as commands or gates; - {file}/{path} used inside a larger argument; - write scopes that cover the brief.

The switch and the AR_WRITE_POLICY check (P2)

"write_policy": "orchestrator-only" in the brief reaches the team manifest, and only that value does: a brief without it, or with "default", produces a byte-identical team. Under the switch, the post-generation audit checks every agent file except the orchestrator.

This is a static check of what agent files declare, not runtime enforcement: it stops nothing at run time. The runtime boundary is the out-of-session runner and its OS sandbox (confinement).

Framework Error Warning
claude Any tool that isn't known to be read-only (see below); a tools: key that is absent, null, empty ([]), duplicated, or of any shape other than one line or a clean - item block list, since the agent may then inherit every tool; any other capability key (mcpServers, hooks, skills, memory, allowed-tools, capabilities, or a later key in CAPABILITY_FRONT_MATTER_KEYS), since none of them shows in tools:; a permissionMode other than default or plan; a front-matter key the check can't read (explicit ?, merge <<, anchored, tagged or escaped), since it could hide one of those keys Bash
goose A recipe whose real extensions or marker grant edit/write, summon or sub_recipes; any extension other than developer (judged by the tools it grants), analyze (allowed only when listed off) and agentteams_readfs. The coordination server writes request and log files, so it is refused, as the generator already withholds it; an agentteams_readfs entry that doesn't launch the shipped server (type, cmd, args) or lists tools it lacks; analyze left on wherever developer or analyze appears (it reads outside the workspace, and Goose adds it beside developer), unless listed available_tools: [__none__]; missing extensions A granted shell

Generation refuses the switch on copilot-vscode, copilot-cli and agents-md (P4a key custody), and on codex off Linux and macOS, where no launcher runner is emitted; on codex the launcher keeps the key out of reach and a generated role gate limits spawned agents to read-only tools (see below). This table lists only the frameworks a team under the switch can use. The check itself also knows the other shapes: - Copilot: any non-read-only tool, plus execute. - Codex: sandbox_mode other than "read-only", and any mcp_servers. - agents-md: always flagged. | any other | A shell too | — |

  • Read-only tokens: read, search, grep, glob, ls, todo, todowrite, web, fetch, webfetch, websearch, retrieval, codebase, usages and problems. Everything else is refused, including dispatch (Task/agent, which can start a subagent that writes), editFiles, runCommands, MCP tools (mcp__…) and *.

  • Shell warnings can't be silenced. Agents hold no shell under the switch (operator decision B): commands go through the runner's confined --run-request, so no brief field quiets a shell warning.

  • The orchestrator exemption covers only the shallowest orchestrator file (on Goose, also bridge-orchestrator). A deeper copy, or two equally shallow copies, are checked like any agent. On Codex, the exempt file's name must also be orchestrator.
  • Coverage: a disk audit (--post-audit on an existing team) sees every agent file in the team directory, adopted agents included, but skips symlinks and files that aren't UTF-8. The in-memory audit during generation sees only the rendered files.
  • Unreadable files are errors: under the switch, a disk audit doesn't follow symlinks. It reports each one (file or directory), and each agent file it can't read or decode, since the host may still load it. Without the switch, the audit follows links as before.
  • Accepted residue: under the switch, a Goose recipe can't carry any operator MCP server, even a read-only one, because the check can't tell what it does. The Goose extensions it does allow are trusted by name.
  • MCP servers: what a non-orchestrator agent may use under the switch, what is planned and what is held is summarized in references/non-orchestrator-mcp.reference.md in the repository.
  • Not covered: project-level skill directories (an agent's own skills: key is an error, above), and a project-level .codex/config.toml that overrides sandbox_mode. Codex ignores a spawned agent's sandbox_mode; the session's sandbox (or agentteams' launcher, through .codex/confined-run.example.sh) governs every agent.

Generated teams under the switch (P3)

Under "write_policy": "orchestrator-only", generation makes the team pass the check above. agentteams.write_policy.apply runs before every framework adapter.

  • Non-orchestrator agents, the team builder included:
  • Their canonical tools: line is narrowed to read, search and todo, and read is always kept. edit, execute, agent and retrieval are dropped.
  • Each framework derives its grants from that one line. Claude gets Read, Grep, Glob (plus TodoWrite where todo was declared). Codex gets sandbox_mode = "read-only". Goose, which is forced to grant mode (an explicit "legacy" is refused), gets agentteams_readfs. Copilot keeps the line as it is.
  • A "Write Policy: Return Proposals, Never Write" section is appended. It overrides any write wording in the body.
  • The orchestrator (and Goose bridge-orchestrator) keeps its tools. It gains a "Write Policy: Applying Proposals" workflow:
  • issue a dispatch nonce, apply or run artifacts, never retry by hand, verify the ledger at closeout;
  • Workflows 0A and 0B members return proposals;
  • Workflow 13 (child orchestrators) is disabled.
  • Capability gaps. Non-orchestrator agents attach a gap note to their handoff instead of opening a plan. The orchestrator opens every capability-gap plan, the skill protocol's included. It records gap notes, as untrusted data, in references/mcp-needs.csv, and decides MCP needs per the agent MCP-need protocol (mcp_need).
  • The team ships:
  • references/write-policy.reference.md, with the three artifact shapes and the exit codes;
  • references/mcp-need.reference.md, the MCP-need procedure;
  • the references/mcp-needs.csv register stub.
  • Fences: an unfenced body is wrapped whole, section included, so --update --merge refreshes both. A body that already has fences gets the section in its own write_policy fence.
  • Shell (execute) is dropped everywhere (operator decisions of 2026-10-06). Commands go through command-request, which the runner runs confined.

  • Goose non-orchestrator recipes carry no operator MCP server and no coordination server (it writes request and log files) under the switch.

  • A tools: key the generator can't narrow cleanly is refused rather than shadowed by a second key. That means any shape other than a one-line flow list.

Switching on an existing team. --update --merge adds the sections, but it never merges front matter or recipe/TOML grants, so agents keep their old tools. --update prints a notice saying so, and the audit flags every one. - Markdown agents (claude, copilot): run --reconcile-front-matter --reconcile-apply to take the narrowed tools:. - Goose recipes and Codex TOML: reconcile doesn't read these. Regenerate them, either with a fresh build or with --overwrite. --overwrite is a destructive action, so it needs a recorded @security clearance, and it backs files up first.

Not converted: - Adopted (bespoke) agents are never re-rendered. The audit flags them, and converting them is the consumer's job. - Copilot handoff buttons stay. They're clicked by the user, not run by the agent. - Codex project-level MCP servers (codex:mcp) are inherited by subagents, and sandbox_mode doesn't confine them. Keep them read-only, or off, for a team under the switch.

  • Adopted agents are not narrowed. An adopted (bespoke) agent file belongs to the project, so the generator leaves it as it is. After each render under the switch, agentteams runs the AR_WRITE_POLICY check on the agent files it doesn't generate (adopted ones, not files it skipped), and prints [WRITE-POLICY] naming any that fail. It changes no file and no exit code. Narrow them yourself, for example with agentteams.write_policy.apply, or the audit fails.

Residual risk (stated, not hidden)

  • An allowed command that runs agent-written code is arbitrary execution, whatever the allowlist says. lake build compiles Lean that can do IO at compile time, and a test runner executes test files. The runner contains them in the OS sandbox (P4b): writes go only to the agent's declared roots, and on macOS only the declared programs can run. Inside those bounds, the code can still do anything.
  • The undeclared-write check doesn't see paths ignored by git. Under the runner the sandbox bounds them to the declared roots.
  • Direct mode (no runner) keeps the P1 risks. A command run directly by the CLI runs as the same user, unconfined, so it can read its parent's exec-time environment, and with it an environment key. Under the switch the CLI never runs directly. A process that leaves its process group (setsid) can still outlive the after-snapshot on macOS; on Linux, bwrap's PID namespace and --die-with-parent stop it.
  • Gates can run without spending a use. Dry runs execute gates, and since R1 a use is counted only when an artifact acts: after every check passes, right before the write, the deletion or the command. So refused artifacts don't count either, and one nonce can trigger unlimited gate runs. This is deliberate, so an agent iterating on rejected attempts isn't locked out. A separate attempt budget bounds it: DISPATCH_MAX_ATTEMPTS, four times the use cap, counts every validated arrival, dry runs and refusals included. Gates are registered by the operator, but they still run code on agent-supplied content. Refusals are logged in the ledger.
  • The Read deny rules bind sandboxed commands too. Under the switch, Claude is denied .agentteams/** and .agentteams-queue/**, which hold raw nonces in transit. Claude Code 2.1.246 applies those rules to sandboxed Bash commands as well, the orchestrator's CLI included (measured 2026-10-11). So the runner publishes liveness and the orchestrator's results, sealed, in the session-readable .agentteams-status/ (proposal-runner). Under the switch only the orchestrator keeps Bash.
  • Writes outside the project (e.g. ~/.gitconfig, shell rc files) are not detected by this check. Under the runner, the sandbox refuses them.
  • The policy lint for argument patterns is a probe-based sample. It catches option-, parent-, absolute- and home-shaped arguments around common stems (.*, [a-z].*, .+\\.lean), but it is not a proof.

Pilot scope and timing (P5a)

  • write_policy_frameworks: ["claude", "goose"]. When one brief emits several frameworks, the switch applies only to the listed ones. Every other framework renders exactly as without the switch, and is outside the pilot's guarantee.
  • Explicit profile. The switch needs an explicit privilege_profile of "confined" or "exclusive". The default leaves the Claude gate hook fail-open, by the 2026-W39 design.
  • Command timeouts. A command entry may set timeout (seconds, 1–7200; default 600), for example for a kernel audit that takes minutes. The runner serves one request at a time, so a long command delays the queue.
  • Measurement. Run and gate ledger rows record duration_ms, and runner results record queue_wait_ms and serve_ms, so P5 can measure per-attempt latency.
  • The orchestrator's duties. AR_WRITE_POLICY also flags an orchestrator that lacks its "Write Policy: Applying Proposals" section.

Out-of-session runner (P4a)

Under the switch the orchestrator never holds the ledger key, and its agentteams commands don't act themselves. They queue requests for the runner, which the operator starts outside every agent session:

agentteams --serve-requests --project <root> --description <brief>
  • Key custody. The runner reads the key from ~/.config/agentteams/keys/proposal-ledger.key (0600), or from AGENTTEAMS_PROPOSAL_LEDGER_KEY_FILE. Under file custody, environment keys are ignored.
  • Session sandboxes. They read-deny that directory, and under the switch they write-deny .agentteams/, so only the runner writes the ledger.
  • Frameworks. The switch is allowed only where such a session sandbox exists: claude and goose, with privilege_profile not "cooperative". On codex it is allowed when codex:sandbox is in effect (an explicit confined/exclusive profile) and holds only when Codex is launched through .codex/confined-run.example.sh, whose launcher masks the key directory. Generation can't verify that launch. On Codex the launcher keeps the ledger key and .agentteams out of reach, and the runner turns on a generated PreToolUse role gate (.agentteams/bin/codex-role-gate.py, wired by .codex/hooks.json): any call Codex tags with a spawned agent's agent_type may use only the read-only agentteams_readfs tools. That is harness-level, like Claude's tool grants. It is refused on codex off Linux/macOS (no runner is emitted there). Generation refuses it elsewhere.
  • Confined execution (P4b). The runner runs every command and gate in an OS sandbox from confined_programs: the operator-owned file (P5b) when present, otherwise the brief's block. See confinement.

Details, queue layout and residual risks: proposal_runner.

Ledger

.agentteams/proposal-ledger.jsonl gets one row per dispatch, apply, deletion, run and refusal. Dispatch issue, compaction and use counting serialize on .agentteams/dispatches.lock, a file that is never replaced, so the use cap cannot be raced. Each row is HMAC-signed and carries the hash of the previous row. Appends take a file lock. The head file .agentteams/proposal-ledger.head, also signed, anchors the row count and last hash. --verify-proposal-ledger reports an edited, removed, reordered or truncated ledger, and one rewritten without the key.

Public surface

  • issue_dispatch(root, agent) → nonce; agent_for(root, nonce) → agent
  • load_policy(brief, *, brief_rel=None) → Policy
  • apply_proposal(artifact, *, root, policy, dry_run=False) → {agent, path, base_sha256, new_sha256, gates, written}
  • run_request(artifact, *, root, policy, dry_run=False, confine=False, timeout_cap=None, expect_agent=None, refuse_agents=frozenset()) (implemented in agentteams/proposal_run.py, carved out under CH-07) → {agent, argv, exit, stdout, stderr, truncated, undeclared_writes, ran}. Each output stream is capped at MAX_OUTPUT_BYTES (256 KiB), and truncated says whether any was dropped. timeout_cap bounds the entry's timeout; the runner sets it for MCP-channel requests.
  • record(root, entry), verify_ledger(root)
  • ProposalError (every refusal) and UndeclaredWritesError (the command ran; .result holds its output)

P1 does not change generation. The brief switch and the AR_WRITE_POLICY contract check (P2), template and emitter changes (P3) and confined programs in the sandbox profiles (P4) follow.