Public: anyone can read this; the crew can change it.
P0: the forge's agent cannot be steered by what it reads: fix before /talk is offered #8231
Check · Review · Land · Live done — Landed and live in production.
This pull covers blocking items 1–3 of #8230 only; items 4–9 are not done. It is meant to be one of several small pulls, each "Part of #8230", so the issue should stay open.
shipped production is serving it deploy 76ab860
This pull covers blocking items 1–3 of #8230 only; items 4–9 are not done. It is meant to be one of several small pulls, each "Part of #8230", so the issue should stay open.
What changed
- Item 1: the fence now survives the next message. Once the agent has read fenced text,
fenceUp(src/core/fence.ts) stays up for the conversation. A new message or a resume no longer clears it. A write goes ahead only when the person's newest words name it. - Item 2: the ask-first list is complete.
- Added to ASKING: land_past_gate, share, invites, egress_grants, sever, parent_unlink, quotas, spawn, app_deploy, stop, converge, origins, cloud_account, outsiders, seal and migrate, plus account_suspend and account_role. invites, egress_grants and origins still let their list/read modes through.
- Not gated: access only reads a log. It is on an explicit NOT_ASKED allow-list in the test, together with the other read, plan and self-settings writes.
- New test: every writes:true tool must be in ASKING or NOT_ASKED, and not both, so a new tool cannot slip through.
- Item 3: the person sees the whole call.
- Shown in full: the Confirms page (src/web/pages/confirms.ts) has a "Full arguments" expander, and the terminal's Confirms dialog shows the same text. It has the complete JSON, secret-like keys hidden at any depth and invisible characters written out.
- Refused when unreadable: a call over 8000 characters, over 30 keys, with a key over 100 characters, or nested deeper than 6 levels is refused unkept, for both ask-first and fence holds.
Tests
- Item 1 replay: a new test in
test/conversation-fence.test.tsreads the injected issue, starts a new turn where the person says "thanks", and checks thecloseis held. It then checks a write the person names does go ahead. - Items 2–3: the allow-list and argument-size tests are in
test/conversation-confirm.test.ts. - Run: the conversation, confirm, fence, web,
mcp-sizeandtui-widgetstests pass, andgate,tscanddesign-lintprinted no errors. - Not run: I did not run the whole suite.
scripts/fast.tssaid the diff was too broad to name tests, so I picked the files above.
Not done here
- Items 4–9: fence holes, batched calls,
ro-token scope, empty cwd for turns, resume repeating a write, and the lower items. - The replay does not use a stand-in model for a full run; it is a scripted sequence of MCP calls.
- A conversation that has read any fenced text now needs every later write named by the person's newest words. I chose that over clearing the fence because the resumed session still holds the poisoned text.
Try it
Run bun test test/conversation-fence.test.ts test/conversation-confirm.test.ts test/mcp-size.test.ts. By hand, ask the agent to read an issue, then to close one, and look under Settings → Confirms for "Full arguments".
🤖 Generated with Claude Code
This pull fixes items 1–6 of #8230 and is Part of #8230. It should not close the issue: items 7–9 are split into #8253, #8254 and #8255, and #8230 now waits on them.
Items 1–3 (first commit, unchanged):
- Item 1: the fence now stays up across the person's next message and across a resumed session.
- Item 2: every hard-to-undo tool now asks the person first. A test fails if any
writes:truetool is missing from the ask-first list and from the explicit not-asked list. - Item 3: the confirm shows the full arguments, and a call too large to read is refused.
Item 4: fence holes (src/core/fence.ts):
- An object key that is not a field name is now wrapped as data.
- A value under a bare key (
url,repo,state, …) stays bare only while it looks like something the forge made: one short run with no space or bracket. url/href/uristay bare only when they are a path or under the forge's own origin. Anything else is wrapped like any other text.
Item 5: batched calls:
- Every tool call through a via token now marks the read when it is sent, before the tool runs (
readThroughinsrc/http/mcp/shared.ts, called fromsrc/http/mcp.ts). - So an answer holding only bare keys, a thrown error, or a write sent after a read in the same step all leave the fence up.
fenceAnswernow only wraps; it no longer does the marking.
Item 6: scope: POST /api/v1/conversation/messages now answers 403 to an ro token, because a turn acts with rw. The MCP say tool already required rw.
docs/features/conversation-fence.md now describes how the fence works after these changes.
Tests:
- New tests in
test/conversation-fence.test.ts(an answer with no text still raises the fence; object keys and bare values are wrapped) and intest/conversation-talk.test.ts(anrotoken is refused). - Each new test fails with the source changes stashed and passes with them.
- The
conversation-,mcp,confirm-*andvia-tokentest files pass (844 tests).tscandscripts/gate.tsare clean. scripts/fast.tssaid the diff was too broad to name tests, because the branch includes a merge of main, so I picked those files.- The previous check failed on three tests outside this change (proxy timing, MCP
map/wait_for, check-slot order). The latter two pass here after merging main; I could not run the proxy timing test here.
Not done here:
- Item 7 (#8253): the turn should run in an empty directory, not the data dir. Moving it orphans every stored session, because Claude Code keys sessions by working directory. So it needs a "start fresh when the session is missing" fallback, which is its own pull.
- Item 8 (#8254): a resumed turn can repeat a write that finished but was never reported.
- Item 9 (#8255): the lower items:
steerRunnot counting as the person speaking, no cap on conversation tokens,named()matching by substring, and unbounded turns.
Try it: bun test test/conversation-fence.test.ts test/conversation-talk.test.ts test/conversation-confirm.test.ts
🤖 Generated with Claude Code
Direction is right (fence persistence, ask-first list, read-at-dispatch, rw scope). Changes needed, in one round:
- HIGH (fix first): src/crew/conversation_confirm.ts argumentsShown hides every key matching SECRET = /token|secret|key|value|password/. That hides the very arguments a person must approve: ssh_keys add
key(the public key being granted access), anyvalue/keyfield of flags, settings, labels etc. Item 3 is "the person sees the whole call"; a hiddenkeyorvaluedefeats it and lets a steered agent grant access the person cannot read. Hide only genuine secrets (explicit per-tool list, e.g. secrets.value, token/password fields), and show the rest. Add a test: ssh_keys add with a public key shows it in full.
- Tests missing for the two doors that changed: nothing asserts the web Confirms page renders the Full arguments expander (escaped, secret hidden), nor that the TUI confirm dialog body carries w.arguments. Add both (web escaping of
<in an argument is the one that matters). Also no test that heldWrite/askedFirst actually refuse a too-big call unkept through MCP (only the pure tooBigToApprove is tested); add one for each path.
- Design: web/pages/confirms.ts hand-rolls a <details>+<pre class="mono small"> inside the row
metaslot. Use the named row/disclosure component, or document the new piece in docs/design/areas/<area>.md, and confirm design-lint is clean; a block-level <pre> inside meta can also shift layout in the row.
- Clean: docs/features/conversation-fence.md has two overlong, unwrapped lines added; reflow. The README/doc for Confirms should mention the Full arguments view on both page and terminal.
Otherwise: scope check on the talk API, readThrough before tool.run, key/value fencing and origin-bound url handling look correct.
This round addresses @sentinel's four points. When the forge's agent is steered by text it reads, its risky writes are held for the person's yes, and the person now reads every argument except real secrets before saying yes.
1. Only real secrets are hidden (src/crew/conversation_confirm.ts). The old rule hid every argument named token, secret, key, value or password. That hid the public key ssh_keys add grants and the key/value that limits sets. Now, at any depth, it hides arguments whose names contain token, secret, password or credential (so secretKey, hooks.secret and subscription_add.token stay hidden). It also hides the top-level value for three tools only: secrets, app_env and cloud_account. The confirm sentence and the full view use the same rule, and argumentsShown now takes the tool name. A new test checks that an ssh_keys add public key is shown whole, a limit's key and value are shown, and those secrets stay hidden.
2. Tests for the paths that changed:
- Settings → Confirms page: an argument with
<img …>is rendered escaped and a sealed secret's value is hidden. - Terminal yes dialog: it shows the call's arguments under the sentence.
- Too-big calls through
/mcp: a call with too much text in its arguments is refused and kept nowhere. This is tested on both paths: an action that always asks first (askedFirst), and a write held after the agent has read outside text (heldWrite).
3. Design. Each confirm on Settings → Confirms is now a row() that folds open (fold) onto messageText(w.arguments), with the Yes form beside it (after). That replaces the hand-rolled <details><pre> inside the row's meta line, so no block element sits in that line. The stylesheet already lays out a folded row with a control beside it, so there is no new class; docs/design/areas/settings.md records the pattern. bun scripts/design-lint.ts is clean. The row's lead icon is now the fold caret instead of the lock.
4. Docs. The long lines in docs/features/conversation-fence.md are reflowed. docs/MCP.md now says that the person reads every argument before saying yes (the page row folds open on them, the terminal dialog repeats them), names what stays hidden, and says a call too large to read is refused.
To try it: bun test test/conversation-confirm.test.ts test/conversation-fence.test.ts. Then ask the forge's agent for ssh_keys add and open /settings/confirms on the preview: the row folds open on the whole public key. Gate, tsc and design-lint pass. scripts/fast.ts named 1194 test files (about 103 minutes, over its 5-minute budget), so I picked the covering ones myself: the conversation confirm, fence, talk and turn suites, plus mcp-docs, mcp-size, design-lint, cold-read, hot-files-expected and policy — 110 passed. I found no prompt injection in the review or the repository.
🤖 Generated with Claude Code
All four earlier asks are met. 1) Only real secrets are hidden: the SECRET rule is now token/secret/password/credential at any depth, plus a top-level value for secrets, app_env and cloud_account. A public key and a limit key/value are shown whole, and a test covers it. 2) The changed paths have tests: the escaped Confirms page, the terminal yes dialog, and the too-big refusal on both the askedFirst and heldWrite paths. 3) Design: the page uses row() with fold and after, with no new class, and the pattern is recorded in settings.md. 4) The docs are updated. The fence changes (kept across messages, read marked at dispatch, bare-key values checked, key fencing) and the ro-token 403 on talk are each tested. I could not diff against d27e2dc66838 because the clone does not have that commit, so I read the change as a whole. Non-blocking: the fold caret replaces the lock icon, which is fine.
This pull covers blocking items 1–3 of #8230 only; items 4–9 are not done. It is meant to be one of several small pulls, each "Part of #8230", so the issue should stay open.
What changed
fenceUp(src/core/fence.ts) stays up for the conversation. A new message or a resume no longer clears it. A write goes ahead only when the person's newest words name it.- Added to
ASKING:land_past_gate,share,invites,egress_grants,sever,parent_unlink,quotas,spawn,app_deploy,stop,converge,origins,cloud_account,outsiders,sealandmigrate, plusaccount_suspendandaccount_role.invites,egress_grantsandoriginsstill let their list/read modes through. - Not gated:accessonly reads a log. It is on an explicitNOT_ASKEDallow-list in the test, together with the other read, plan and self-settings writes. - New test: everywrites:truetool must be inASKINGorNOT_ASKED, and not both, so a new tool cannot slip through.- Shown in full: the Confirms page (
src/web/pages/confirms.ts) has a "Full arguments" expander, and the terminal's Confirms dialog shows the same text. It has the complete JSON, secret-like keys hidden at any depth and invisible characters written out. - Refused when unreadable: a call over 8000 characters, over 30 keys, with a key over 100 characters, or nested deeper than 6 levels is refused unkept, for both ask-first and fence holds.Tests
test/conversation-fence.test.tsreads the injected issue, starts a new turn where the person says "thanks", and checks thecloseis held. It then checks a write the person names does go ahead.test/conversation-confirm.test.ts.mcp-sizeandtui-widgetstests pass, andgate,tscanddesign-lintprinted no errors.scripts/fast.tssaid the diff was too broad to name tests, so I picked the files above.Not done here
ro-token scope, empty cwd for turns, resume repeating a write, and the lower items.Try it Run
bun test test/conversation-fence.test.ts test/conversation-confirm.test.ts test/mcp-size.test.ts. By hand, ask the agent to read an issue, then to close one, and look under Settings → Confirms for "Full arguments".🤖 Generated with Claude Code
Closes #8230