Repository navigation
fix(external-cli): record where safety values come from - #3199
Merged
nicobailon merged 10 commits intoNov 5, 2026
Merged
Conversation
…ers declare theirs
The safety block on a code-owned external CLI run is the adapter's argv
profile, but status and workflow receipts presented it as fact. When
runner.command is a wrapper that rewrites argv, the record was wrong in
both directions: a claude-code-writer run whose wrapper dropped the
editors and added Bash, WebFetch, WebSearch and an MCP server still
recorded tools=Read,Write,Edit,Glob,Grep and mcp=empty-strict, and the
record did not name the command that launched it. The receipt parser
required those literals, so an accurate record could not be written.
- Runner status and receipt metadata carry
safetySource: { measured: false, command, declaredFields }.
- runner.declaredSafety (agent frontmatter and runtime definitions)
overrides profile fields for that run. It needs a code-owned adapter,
accepts only fields that adapter already records, and checks each value
kind against the profile.
- Receipts accept declared values but still require every undeclared
field to equal the profile. Receipts without safetySource are read as
the profile, as before.
- Status labels declared values and prints a Safety source line naming
the launch command.
- The per-adapter profiles move to one EXTERNAL_CLI_ADAPTER_SAFETY table
that both the resolver and the receipt parser use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aredSafety Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
[Medium impact] No new code issues were found; required CI on the exact head must be confirmed before merging.
|
A run placed on a Herdr saved machine launches through the internal pane adapter, which builds the adapter's own argv and never runs runner.command. Recording a declared wrapper grant and that command for such a run made two false statements. Launch gating now refuses an agent with runner.declaredSafety when a saved machine is requested, and the pane path throws if one reaches it. Status now says "current <adapter> adapter profile", because it rebuilds undeclared fields from today's profile, not from the persisted record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o fix/external-cli-declared-safety # Conflicts: # CHANGELOG.md
Keep safetySource { measured: false, command } beside the adapter profile
safety block and the shared EXTERNAL_CLI_ADAPTER_SAFETY table. Drop the
runner.declaredSafety field: it replaced code-owned profile values with
unverified author text that nothing checks or acts on.
A run on a Herdr saved machine records no command, because the remote pane
runs the adapter's own binary, never runner.command. Status labels the block
as the adapter profile instead of adding a second line repeating the command.
Owner
|
Thanks for this. The safety record really did present the adapter profile as fact, with no sign that a wrapper was in play. I pushed two commits that keep that fix and drop
16 files (+305 −72) → 9 files (+127 −62). Your commits and the changelog credit stay; it will be squash-merged. |
… status The status-step runner of a Herdr saved-machine run was built without the machine, so status.json (and receipts that fall back to it) named the local runner.command, which the remote pane never runs. Pass the step's machine through. Also name safetySource in the generic-adapter receipt error and keep the status safety label in one constant.
# Conflicts: # CHANGELOG.md
…ing ssh The test ran a real Herdr launch against an unreachable host. The launch failure rejects runSubagent before the runner closes its control inbox, and on Linux that inbox is a native fs.watch handle, so the test file never exited and the CI shard hit its time limit. A stop requested before the runner starts ends the run before the placed launch instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Every code-owned external CLI run (
codex-exec*,claude-code*,cursor-agent*) records asafetyblock.resolveExternalCliRunnerStatusfills it in from the adapter id alone. It is the access the adapter's own argv asks for, not something read from the launched process. Status (subagent({ action: "status" })) and workflow receipts still present it as fact.runner.commandcan point at a wrapper script that changes argv before it starts the CLI. Profiles cannot add argv, so a wrapper is how people grant a child something different. When one is used, the record is wrong and says nothing about the wrapper:claude-code-writeragent whose wrapper removes the editors and addsBash,WebFetch,WebSearchand an MCP server still recordstools=Read,Write,Edit,Glob,Grepandmcp=empty-strict.Bashand keepsacceptEditsalso records noBash.workflow-receipt.tsrequires the profile literals (tools !== "Read,Write,Edit,Glob,Grep"throws), so an accurate record would be rejected.How to reproduce
runner: { type: external-cli, adapter: claude-code-writer, command: <script> }, where the script runsclaudewith--tools Read,Glob,Grep,Bashin place of the adapter's--toolsvalue.subagent({ action: "status", id }).tools=Read,Write,Edit,Glob,Grep, mcp=empty-strict, and the child could runBash. The workflow receipt entry has the same values.What this changes
safetySource: { measured: false, command, declaredFields }next tosafety.commandis the launch command as configured, so a wrapper shows up in the record.declaredFieldsnames the fields that did not come from the adapter profile.runner.declaredSafetyfield, accepted in agent frontmatter and runtime agent definitions, lets the agent author state what the wrapper grants. Its values replace the profile values for those fields only. Validation is strict: it needs a code-ownedadapterand at least one field. Keys must be fields that adapter already records (network: openis rejected with the list of allowed keys). String fields need a non-empty trimmed string of up to 512 characters, and boolean fields need a boolean. Fields left out keep the profile values.declaredFieldsmust equal the adapter profile, so a receipt that changestoolswithout listing it indeclaredFieldsis rejected, as is one withmeasured: trueor an unknown declared field. Unwrapped runs get the same checks as before. The check confirms that a record is consistent and says where its values came from. It cannot reject a record that is consistent but wrong, such as a wrapper run with no declaration, because it never sees the launched process.runner.command. An agent withdeclaredSafetyis therefore refused before launch when a saved machine is requested, and the pane path throws if one reaches it.Safety (declared tools, mcp): ...instead ofSafety: ..., and adds aSafety source:line naming the current adapter profile (status rebuilds undeclared fields from today's profile), the command and "not measured from the launched process". The existingSafety:line format is unchanged for runs with no declared fields.EXTERNAL_CLI_ADAPTER_SAFETYtable used by both the resolver and the receipt parser. That replaces five near-identical branches inworkflow-receipt.ts. The Claude Code legacy receipt branch and the legacygrok-buildbranch are kept as they were, and neither acceptssafetySource.Compatibility
New runs always write
safetySource. On read, a missingsafetySourceis treated as "all fields from the profile", which is what those records meant. There is no legacy branch for this: it is one optional field, and requiring it would make existing receipts unreadable for resume and inspection. Status and receipt consumers that readsafetyget the same keys as before. Only a declared run has different values, and that run is flagged bysafetySource.declaredFields.Out of scope
declaredSafetyis the agent author's statement, and the docs say so. Detecting a wrapper automatically, for example by comparing the command to the expected binary, would be a guess.declaredSafetydoes not change the launch argv. User profiles still cannot add argv.agent-managementcreate/update does not acceptdeclaredSafety, just as it does not acceptcapabilitiestoday. Declared agents are written as frontmatter files or runtime definitions.external-clirunner (noadapter) records no safety and cannot declare one.Docs
docs/agents.mdhad no description of the safety record. This adds a new "Safety record and wrapper commands" section covering whatsafetyis,safetySource,declaredSafetywith an example and its validation rules, how status labels it, and the receipt rule.docs/tool-reference.mdgets a short paragraph in "External CLI agent profiles" that links to it. OneCHANGELOG.mdentry under Fixed.Tests
npm run typecheck: clean.npm test: 4327 tests, 4310 pass, 0 fail, 17 skipped by the suite.test/unit/host-peer-runtime-imports.test.tshas been reported as failing on a cleanmain. It passed in my runs on this branch and I made no changes for it.test/unit/claude-code-adapter.test.ts:measured: false.declaredFieldsemptied while values still differ from the profile, an unknown declared field, an empty declared value,measured: true, and missingsafety. An emptydeclaredFieldswith profile values stays valid, since every unwrapped run writes it.test/unit/herdr-machine.test.ts: saved-machine launch gating rejects an agent withdeclaredSafety. It fails with the gating change reverted.test/unit/run-status.test.ts: the status output labels a profile run as an unmeasured profile with its command, and labels a declared run's fields without printing a bareSafety:line.claude-code-adapter.test.tsthat builds a legacy Claude receipt now also removessafetySource, because real legacy receipts never had it.🤖 Generated with Claude Code