@1agh/maude 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/build.ts +28 -1
  7. package/apps/studio/client/app.jsx +47 -30
  8. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  9. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  10. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  11. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  12. package/apps/studio/client/panels/acp-runtime.js +96 -5
  13. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  14. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  15. package/apps/studio/dist/client.bundle.js +1278 -1278
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/http.ts +51 -2
  18. package/apps/studio/server.ts +11 -0
  19. package/apps/studio/sync/connection-state.ts +116 -6
  20. package/apps/studio/sync/index.ts +50 -1
  21. package/apps/studio/sync/presentation.ts +273 -0
  22. package/apps/studio/sync/remote-docs.ts +191 -0
  23. package/apps/studio/sync/status.ts +4 -1
  24. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  25. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  26. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  27. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  28. package/apps/studio/test/acp-commands.test.ts +5 -0
  29. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  30. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  31. package/apps/studio/test/acp-permission.test.ts +18 -2
  32. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  33. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  35. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  36. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  37. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  38. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  40. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  41. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  42. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  43. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  44. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  45. package/apps/studio/test/sync-presentation.test.ts +185 -0
  46. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  47. package/apps/studio/whats-new.json +18 -0
  48. package/package.json +8 -8
@@ -8,6 +8,7 @@
8
8
  // (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
9
9
  // auth precedence falls through to the user's Pro/Max subscription.
10
10
 
11
+ import { readFileSync } from 'node:fs';
11
12
  import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
12
13
  import { dirname } from 'node:path';
13
14
 
@@ -31,6 +32,14 @@ import {
31
32
  import { scrubAgentEnv } from './env.ts';
32
33
  import type { SdkPluginConfig } from './plugin-bootstrap.ts';
33
34
  import { resolveAdapterEntry, resolveAgentRuntime, resolveClaudePath } from './probe.ts';
35
+ import {
36
+ isWriteToolName,
37
+ looksLikeWriteToolCall,
38
+ pinScopeRoot,
39
+ resolveWriteTargets,
40
+ type WriteScopeVerdict,
41
+ writeTargetsInsideProject,
42
+ } from './write-scope.ts';
34
43
 
35
44
  export interface AcpBridgeOptions {
36
45
  /** Absolute repo root the ACP session runs in (where `.design/` + the CLI operate). */
@@ -48,8 +57,17 @@ export interface AcpBridgeOptions {
48
57
  * serve). Carried on the readonly options so it survives an adapter re-spawn.
49
58
  */
50
59
  plugins?: SdkPluginConfig[];
51
- /** Streamed `session/update` notifications relayed to the browser. */
52
- onUpdate: (update: SessionUpdate) => void;
60
+ /**
61
+ * Streamed `session/update` notifications relayed to the browser.
62
+ *
63
+ * `seq` is the transcript line this update occupies — the re-attach seam
64
+ * (Addendum Task 8). A bridge outlives its socket now, so the client can
65
+ * hydrate history over HTTP and attach mid-stream; stamping every update with
66
+ * its transcript line is what lets the two sources be joined exactly instead
67
+ * of overlapping (duplicate output) or falling short (a hole mid-stream).
68
+ * See `acp/transcript.ts`'s "re-attach seam" section.
69
+ */
70
+ onUpdate: (update: SessionUpdate, seq: number) => void;
53
71
  /**
54
72
  * Informational transparency callback: fires whenever the agent asks for a
55
73
  * tool permission, REGARDLESS of how it's ultimately resolved. Kept
@@ -63,8 +81,23 @@ export interface AcpBridgeOptions {
63
81
  * (index.ts) forwards it to the browser as a `permission-request` frame.
64
82
  * The bridge awaits `resolvePermission(id, …)` before returning to the
65
83
  * adapter — nothing is pre-decided here.
84
+ *
85
+ * `req.options` is the bridge's own, possibly FILTERED copy — not the
86
+ * adapter's array verbatim. For an out-of-project write every `allow_always`
87
+ * option is stripped (feature-acp-write-path-scope Decision D: one click must
88
+ * not be able to make an out-of-project write permanent), and
89
+ * `resolvePermission` validates against the same filtered set, so a
90
+ * hand-crafted frame can't pin an option that was never offered.
91
+ *
92
+ * `scope` is present ONLY for a write tool the path gate refused to
93
+ * auto-approve — it is what lets the client say plainly that the target is
94
+ * outside the project and render the RESOLVED absolute path.
66
95
  */
67
- onPermissionRequest?: (id: string, req: RequestPermissionRequest) => void;
96
+ onPermissionRequest?: (
97
+ id: string,
98
+ req: RequestPermissionRequest,
99
+ scope?: PermissionScopeInfo
100
+ ) => void;
68
101
  /**
69
102
  * The elicitation-form UI hook (feature-acp-ask-user-question) — fires once
70
103
  * per `unstable_createElicitation` call with a fresh nonce `id`, mirroring
@@ -116,6 +149,27 @@ export interface AcpBridgeOptions {
116
149
  permissionTimeoutMs?: number;
117
150
  }
118
151
 
152
+ /**
153
+ * What the client needs to render an out-of-project write honestly
154
+ * (feature-acp-write-path-scope Task 4). Attached to a `permission-request`
155
+ * ONLY when the tool is a known write tool AND the path gate declined to
156
+ * auto-approve it — an ordinary prompt (Bash, an unknown MCP tool, …) carries
157
+ * no `scope` at all, so the client's "outside the project" copy can never fire
158
+ * on a request the gate never judged.
159
+ */
160
+ export interface PermissionScopeInfo {
161
+ /** Always `true` when present — a discriminator the client can test directly. */
162
+ outOfProjectWrite: true;
163
+ /** The RESOLVED absolute path(s). Never the model's own string: `docs/../../../.zshenv`
164
+ * reads as harmless in a prompt and its resolution does not (same lesson as
165
+ * the deep-link modal's truncated project name). */
166
+ resolvedPaths: string[];
167
+ /** The pinned project root the paths were judged against — so the prompt can
168
+ * say what "outside" means instead of asserting it. */
169
+ scopeRoot: string;
170
+ reason: WriteScopeVerdict['reason'];
171
+ }
172
+
119
173
  /** The bridge's normalized shape of a `usage_update` notification. `rateLimit`
120
174
  * is the RAW `_meta["_claude/rateLimit"]` payload (an `SDKRateLimitInfo`) —
121
175
  * passed through opaque; `client/panels/acp-usage.js`'s `parseUsage` is
@@ -150,6 +204,21 @@ type Spawned = ReturnType<typeof Bun.spawn>;
150
204
  // forwarded into the privileged `loadSession` ACP call.
151
205
  const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
152
206
 
207
+ /** Raw non-empty line count of a transcript file — the re-attach seam's seed.
208
+ * Deliberately duplicated from `transcript.ts`'s `chatTranscriptSeq` rather
209
+ * than imported: importing would pull the transcript READER (and its
210
+ * designRoot/chatId path convention) into the bridge, which knows only an
211
+ * absolute file path. The two MUST count identically — raw non-empty lines,
212
+ * never parsed lines, since a corrupt line would otherwise shift every later
213
+ * seq and permanently desync the seam. */
214
+ function countTranscriptLines(path: string): number {
215
+ try {
216
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean).length;
217
+ } catch {
218
+ return 0; // no transcript yet — first turn of this chat
219
+ }
220
+ }
221
+
153
222
  // `loadSession`'s replay can, in principle, never settle if the underlying
154
223
  // transport dies mid-call (adapter crash, a concurrent `stop()` from another
155
224
  // chat sharing this bridge). Bound it so `replaying` always resets and a
@@ -211,16 +280,59 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
211
280
  // this list still routes through the real approve/deny gate (requestPermission →
212
281
  // PermissionPrompt) — arbitrary `Bash(curl …)`/`rm`, WebFetch, unknown MCP tools.
213
282
  //
214
- // • File tools (Read/Edit/Write/Glob/Grep/NotebookEdit) — the canvas-editing
215
- // surface. Auto-approving Edit/Write is the accepted residual: edits land in
216
- // the served project (already the edit target) and are reversible via the
217
- // `_history/` snapshot stack.
283
+ // • Read-only file tools (Read/Glob/Grep) — the canvas-READING surface.
284
+ // Deliberately unscoped; this list closes WRITE egress, not read (see the
285
+ // "Explicitly NOT in scope" note in feature-acp-write-path-scope).
286
+ // • The WRITE tools (Edit/Write/NotebookEdit) are NOT on this list — and their
287
+ // absence is load-bearing, not an oversight. A bare name here means the CLI
288
+ // approves the call ITSELF and `requestPermission` is never invoked, so a
289
+ // path condition cannot be expressed "next to" an allow-list entry; it can
290
+ // only be expressed by moving the decision. They are auto-approved instead by
291
+ // the PATH GATE in `requestPermission` below (`acp/write-scope.ts`), which
292
+ // grants exactly DDR-184's no-prompt-per-edit outcome for every write landing
293
+ // inside the session's pinned project root, and routes every other write to
294
+ // the real prompt.
295
+ //
296
+ // IN-PROJECT IS NECESSARY, NOT SUFFICIENT. A resolved target under `.git/` or
297
+ // `.claude/` is genuinely inside the root and still prompts — see
298
+ // `EXECUTION_SENSITIVE_DIRS` in write-scope.ts. Writing `.git/hooks/pre-commit`
299
+ // is code execution at the next git operation (and this app runs git for the
300
+ // user), which needs no second tool call at all.
301
+ //
302
+ // This corrects the justification this comment used to carry. It read: "Auto-
303
+ // approving Edit/Write is the accepted residual: edits land in the served
304
+ // project (already the edit target) and are reversible via the `_history/`
305
+ // snapshot stack." That was not merely incomplete, it was the WRONG CLAIM —
306
+ // nothing whatsoever constrained the target path, so neither half held. Edits
307
+ // did not have to land in the served project, and `_history/` snapshots only
308
+ // exist for canvases inside `<designRoot>`, so the rollback argument covers a
309
+ // subset of the project and nothing at all outside it — and `.git/hooks/` is
310
+ // the counterexample INSIDE the project, where the file is neither the edit
311
+ // target nor snapshotted. A write to `~/.zshenv`,
312
+ // `~/Library/LaunchAgents/*.plist` or another repo entirely was auto-approved
313
+ // silently, with no prompt and no rollback. That is the delivery primitive
314
+ // behind the A2 finding of the 2026-08-04 attacker pass; the gate closes the
315
+ // class, not the one env var A2 happened to name.
218
316
  // • `Bash(maude:*)` — the SINGLE rule that covers the entire design-helper
219
317
  // surface, because DDR-062 routes every helper through `maude design <verb>`
220
318
  // (screenshot / draw-* / canvas-rects / probe-footage / …) and their own deps
221
319
  // (agent-browser, playwright, svgo) run as CHILDREN of that one bash call, so
222
320
  // they need no separate entry. Bash NOT starting with `maude` still prompts.
223
321
  //
322
+ // RESIDUAL, NAMED EXPLICITLY (do not let this read as exhaustive — that is
323
+ // the mistake the ⚠️ below exists to correct): this rule has the SAME
324
+ // redirection property that got the read-only fs group cut. `maude design
325
+ // slug foo > ~/.zshenv` matches the prefix, so a helper's stdout can be
326
+ // redirected anywhere. It is NOT cut, because `Bash(maude:*)` IS the design
327
+ // workflow (DDR-062) and removing it would put a prompt on every step of the
328
+ // thing DDR-184 exists to unblock — a materially different, larger decision
329
+ // than dropping nine convenience verbs. What redirection buys here is
330
+ // helper-CHOSEN stdout to an attacker-chosen path, which is weaker than the
331
+ // read-only group's `cat > file <<'EOF'` (model-authored arbitrary CONTENT),
332
+ // but it is not nothing, and it sits alongside the unscoped `--out` of
333
+ // BYPASS-2 and the `exec bun run` of BYPASS-1. Tracked as an open item in
334
+ // feature-acp-write-path-scope's Task 5 findings.
335
+ //
224
336
  // DDR-185 widens this list with further, independently-justified groups
225
337
  // (never collapsed into "widen Bash generally" — see the DDR for the full
226
338
  // record, including why a `PreToolUse` hook was investigated and ruled out:
@@ -265,7 +377,43 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
265
377
  // reads those for the user's OWN manual terminal use — this wrapper's
266
378
  // explicit env deletion is what stops that from leaking into the
267
379
  // auto-approving ACP session specifically).
268
- // • Read-only filesystem inspection (ls/cat/pwd/head/tail/wc/tree/file/stat)
380
+ // • Read-only filesystem inspection (ls/cat/pwd/head/tail/wc/tree/file/stat).
381
+ //
382
+ // ⚠️ CORRECTION (security-auditor F1, 2026-08-07 — feature-acp-write-path-
383
+ // scope). The paragraph below calls this group "read-only" and argues it
384
+ // adds no incremental READ capability. The read argument is correct and
385
+ // BESIDE THE POINT: Claude Code's `Bash(<cmd>:*)` prefix rule does NOT
386
+ // reject shell redirection, and every verb in this group accepts `>`. So
387
+ // `cat > ~/.zshenv <<'EOF' … EOF` matches `Bash(cat:*)`, is self-approved by
388
+ // the CLI, never reaches `requestPermission`, and writes model-authored
389
+ // arbitrary content to an arbitrary path in ONE command — with no write tool
390
+ // involved at all. PoC'd live against claude 2.1.220 under
391
+ // `--permission-mode default`.
392
+ //
393
+ // This group was therefore an UNRESTRICTED ARBITRARY WRITE surface, not a
394
+ // read surface, and CHEAPER than the write tools it sat beside — the path
395
+ // gate does not and cannot see it. **All nine entries are CUT**, mirroring
396
+ // how `find` and `agent-browser` were cut rather than patched in DDR-185's
397
+ // own security addendum, and for the identical reason: the residual is NOT
398
+ // fixable via prefix-matching, because the rule cannot inspect what follows
399
+ // the command name. `pwd` goes too — `pwd > file` redirects exactly like the
400
+ // rest, so "but pwd is harmless" is the same beside-the-point argument.
401
+ //
402
+ // Cost, accepted deliberately: these verbs prompt again, which gives back
403
+ // part of DDR-185's friction win. That is the right trade — a write gate
404
+ // whose headline claim is defeated by `cat >` is worse than a prompt on
405
+ // `ls`. Read/Grep/Glob remain auto-approved, so the actual READ workflow the
406
+ // group existed to smooth is untouched; what returns is a prompt on the
407
+ // *convenience interface* to power already granted.
408
+ //
409
+ // If someone wants them back: route them through a hardened `maude design`
410
+ // wrapper verb the way `agent-browser-safe` and `curl-local` already are
411
+ // (covered for free by `Bash(maude:*)`, zero Bash-surface widening) — a
412
+ // wrapper CAN reject redirection, a prefix rule cannot.
413
+ //
414
+ // The original justification follows. Its reasoning about the READ surface
415
+ // was accurate and is why the group was added at all; it is kept so the next
416
+ // reader sees both what was argued and what it missed:
269
417
  // — adds ~NO incremental read capability: Read/Grep/Glob above are
270
418
  // ALREADY auto-approved with no path scoping at all (a pre-existing fact,
271
419
  // not something this list changes), so these commands are a more
@@ -325,21 +473,13 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
325
473
  // mid-workflow.
326
474
  export const MAUDE_DEFAULT_ALLOWED_TOOLS: readonly string[] = [
327
475
  'Read',
328
- 'Edit',
329
- 'Write',
330
476
  'Glob',
331
477
  'Grep',
332
- 'NotebookEdit',
478
+ // NO 'Edit' / 'Write' / 'NotebookEdit' — see the comment block above. They are
479
+ // scope-gated in `requestPermission`, not name-allowed here.
333
480
  'Bash(maude:*)',
334
- 'Bash(ls:*)',
335
- 'Bash(cat:*)',
336
- 'Bash(pwd:*)',
337
- 'Bash(head:*)',
338
- 'Bash(tail:*)',
339
- 'Bash(wc:*)',
340
- 'Bash(tree:*)',
341
- 'Bash(file:*)',
342
- 'Bash(stat:*)',
481
+ // The read-only fs verb group (ls/cat/pwd/head/tail/wc/tree/file/stat) was
482
+ // CUT — see the ⚠️ block above. Every one of them accepts `>`.
343
483
  'WebSearch',
344
484
  'WebFetch',
345
485
  ];
@@ -435,6 +575,11 @@ const PERMISSION_TIMEOUT_MS = 120_000;
435
575
  // never to a silent allow.
436
576
  export const MAX_PENDING_PERMISSIONS = 10;
437
577
 
578
+ // feature-acp-write-path-scope — ceiling on the `toolCallId → toolName` cache
579
+ // the write gate reads. See `rememberToolName` for the eviction policy and why
580
+ // an eviction degrades safely.
581
+ export const MAX_TRACKED_TOOL_NAMES = 256;
582
+
438
583
  // feature-acp-ask-user-question, SECURITY (ethical-hacker finding) — unlike a
439
584
  // permission request (one per tool call, rate-limited by how fast a model can
440
585
  // call tools), an elicitation can be issued directly by any connected MCP
@@ -484,6 +629,10 @@ export class AcpBridge {
484
629
  private starting: Promise<void> | null = null;
485
630
  /** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
486
631
  private transcriptPath: string | null = null;
632
+ /** How many lines this chat's transcript holds — the re-attach seam's
633
+ * counter. Seeded from disk in `setTranscriptPath`, incremented by every
634
+ * append. See `acp/transcript.ts`'s "re-attach seam" section. */
635
+ private transcriptLines = 0;
487
636
  /** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
488
637
  private sessionStorePath: string | null = null;
489
638
  /** True while `conn.loadSession()` is replaying a resumed session's history
@@ -534,8 +683,41 @@ export class AcpBridge {
534
683
  // Milestone D — the last-seen usage snapshot, cached the same way lastModes/
535
684
  // lastConfigOptions are (mirrors the manager's latestCommands replay pattern).
536
685
  private lastUsage: BridgeUsage | null = null;
686
+ // feature-acp-write-path-scope Task 3 — `toolCallId → toolName`, harvested
687
+ // from the streamed `tool_call`/`tool_call_update` notifications'
688
+ // `_meta.claudeCode.toolName`. This is the ONLY channel the tool name arrives
689
+ // on: the adapter builds a permission request's `toolCall` inline as
690
+ // `{ toolCallId, rawInput, ...toolInfoFromToolUse(…) }` (acp-agent.js:2270-2286),
691
+ // and `toolInfoFromToolUse` returns title/kind/content/locations — no name.
692
+ // The adapter guarantees the ordering the gate depends on:
693
+ // `requestPermissionFromClient` awaits `ensureToolCallEmitted` BEFORE issuing
694
+ // the request, so the notification is always on the wire first. A miss simply
695
+ // fails closed to the prompt (see `classifyWrite`), so a future adapter that
696
+ // reorders these degrades to "the user is asked" — never to a silent allow.
697
+ private toolNames = new Map<string, string>();
698
+ /**
699
+ * SECURITY / Task 11 (Solution E) — the project root this session's writes are
700
+ * scoped to, realpath-resolved ONCE here and never recomputed.
701
+ *
702
+ * DO NOT replace reads of this with `this.opts.repoRoot`, and do not make it
703
+ * settable. Today a bridge's lifetime IS one project's lifetime, so the two
704
+ * are the same value and the distinction looks like ceremony. The moment a
705
+ * session outlives a project switch (the plan's Addendum — Tasks 8/10 make
706
+ * exactly that possible), "the project" becomes two different things, and a
707
+ * gate that re-reads the current one silently hands project A's session write
708
+ * access to project B. That is the one failure mode this whole feature exists
709
+ * to prevent, so the pin ships WITH the lifetime change, not after it.
710
+ */
711
+ private readonly scopeRoot: string;
537
712
 
538
- constructor(private readonly opts: AcpBridgeOptions) {}
713
+ constructor(private readonly opts: AcpBridgeOptions) {
714
+ this.scopeRoot = pinScopeRoot(opts.repoRoot);
715
+ }
716
+
717
+ /** The pinned write-scope root (read-only) — exposed for tests + diagnostics. */
718
+ get writeScopeRoot(): string {
719
+ return this.scopeRoot;
720
+ }
539
721
 
540
722
  /** The last-advertised mode roster + current mode (read-only snapshot). */
541
723
  get modes(): SessionModeState | null {
@@ -552,6 +734,17 @@ export class AcpBridge {
552
734
  return this.lastUsage;
553
735
  }
554
736
 
737
+ /**
738
+ * feature-acp-turn-notifications Task 2 — count of permission + elicitation
739
+ * requests currently awaiting a human decision. `> 0` is the `awaiting-input`
740
+ * signal: the turn is technically still in-flight, but blocked on the user,
741
+ * not on the model — the case `PERMISSION_TIMEOUT_MS` exists to fail closed
742
+ * on if nobody is told in time.
743
+ */
744
+ get awaitingInputCount(): number {
745
+ return this.pendingPermissions.size + this.pendingElicitations.size;
746
+ }
747
+
555
748
  /** The session id of the most recent prompt (for the `connected` frame). */
556
749
  get sessionId(): string | null {
557
750
  return this.currentSession;
@@ -562,7 +755,14 @@ export class AcpBridge {
562
755
  }
563
756
 
564
757
  setTranscriptPath(path: string | null): void {
758
+ if (path === this.transcriptPath) return;
565
759
  this.transcriptPath = path;
760
+ // Re-seed the seam's counter from what is already on disk, so a bridge that
761
+ // resumes a chat from a PRIOR process lifetime continues that transcript's
762
+ // numbering instead of restarting at 1 and colliding with lines the client
763
+ // already hydrated. Counted the same way `chatTranscriptSeq` counts (raw
764
+ // non-empty lines) — the two must not disagree or the seam desyncs.
765
+ this.transcriptLines = path ? countTranscriptLines(path) : 0;
566
766
  }
567
767
 
568
768
  setSessionStorePath(path: string | null): void {
@@ -868,6 +1068,11 @@ export class AcpBridge {
868
1068
  const client: Client = {
869
1069
  sessionUpdate: (params: SessionNotification) => {
870
1070
  const u = params.update;
1071
+ // feature-acp-write-path-scope — harvest `toolCallId → toolName` BEFORE
1072
+ // any early return (in particular before the `replaying` guard below):
1073
+ // this is the write gate's only source for the tool name, and it must
1074
+ // never be skipped for a reason unrelated to permissions.
1075
+ this.rememberToolName(u);
871
1076
  // The command catalogue is chrome, not chat — surface it to the UI but
872
1077
  // keep it out of the rendered turn + the persisted transcript.
873
1078
  if (u.sessionUpdate === 'available_commands_update') {
@@ -926,8 +1131,12 @@ export class AcpBridge {
926
1131
  // transcript and already rendered client-side, so forwarding/re-appending
927
1132
  // it here would duplicate every message in the panel and the jsonl file.
928
1133
  if (this.replaying) return;
929
- this.opts.onUpdate(u);
930
- void this.appendTranscript({ role: 'agent', update: u });
1134
+ // Claim the transcript line FIRST, then hand the same number to both
1135
+ // consumers. Deriving it separately in each would let them disagree,
1136
+ // which is exactly the desync the seam exists to prevent.
1137
+ const seq = ++this.transcriptLines;
1138
+ this.opts.onUpdate(u, seq);
1139
+ void this.appendTranscript({ role: 'agent', update: u }, seq);
931
1140
  },
932
1141
  requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
933
1142
  // Milestone B (retires DDR-125 F2's blanket auto-approve) — the
@@ -938,6 +1147,18 @@ export class AcpBridge {
938
1147
  // transparency callback (every request, however it resolves);
939
1148
  // `onPermissionRequest` is the actual UI hook the client answers.
940
1149
  this.opts.onPermission?.(params);
1150
+ // feature-acp-write-path-scope Task 3 — THE WRITE-PATH GATE. This is the
1151
+ // branch that replaces `Edit`/`Write`/`NotebookEdit`'s former presence on
1152
+ // MAUDE_DEFAULT_ALLOWED_TOOLS. An in-project write short-circuits here
1153
+ // with no pending entry, no client frame and no prompt — byte-for-byte
1154
+ // the DDR-184 experience. Everything else falls through to the real gate
1155
+ // that already exists; this deliberately does NOT build a parallel path.
1156
+ const scope = this.classifyWrite(params);
1157
+ if (scope.autoApprove) {
1158
+ return Promise.resolve({
1159
+ outcome: { outcome: 'selected', optionId: scope.autoApprove },
1160
+ });
1161
+ }
941
1162
  // SECURITY (ethical-hacker finding) — bound queue depth before
942
1163
  // registering a pending entry, mirroring the elicitation channel's
943
1164
  // MAX_PENDING_ELICITATIONS cap. Deny immediately past the cap — safe
@@ -946,14 +1167,29 @@ export class AcpBridge {
946
1167
  return Promise.resolve({ outcome: { outcome: 'cancelled' } });
947
1168
  }
948
1169
  const id = crypto.randomUUID();
949
- const optionIds = new Set((params.options ?? []).map((o) => o.optionId));
1170
+ // Decision D — an out-of-project write cannot be made permanent by one
1171
+ // click. Strip every `allow_always` option BEFORE it is offered, so
1172
+ // consent is per-call. Filtering here rather than client-side is what
1173
+ // makes it a gate instead of a speed bump: `optionIds` below is built
1174
+ // from the SAME filtered array, so a hand-crafted `permission-response`
1175
+ // naming `allow_always` fails closed to `cancelled` (resolvePermission's
1176
+ // existing DDR-125 F1 posture) rather than being honored.
1177
+ // `reject_always` is deliberately left in place — it is the safe
1178
+ // direction, and stripping it could remove the only reject option.
1179
+ const offered = scope.stripAlways
1180
+ ? (params.options ?? []).filter((o) => o.kind !== 'allow_always')
1181
+ : (params.options ?? []);
1182
+ const outgoing: RequestPermissionRequest = scope.stripAlways
1183
+ ? { ...params, options: offered }
1184
+ : params;
1185
+ const optionIds = new Set(offered.map((o) => o.optionId));
950
1186
  return new Promise<RequestPermissionResponse>((resolve) => {
951
1187
  const timer = setTimeout(
952
1188
  () => this.resolvePermission(id, 'cancelled'),
953
1189
  this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
954
1190
  );
955
1191
  this.pendingPermissions.set(id, { resolve, timer, optionIds });
956
- this.opts.onPermissionRequest?.(id, params);
1192
+ this.opts.onPermissionRequest?.(id, outgoing, scope.info);
957
1193
  });
958
1194
  },
959
1195
  unstable_createElicitation: (
@@ -1101,6 +1337,121 @@ export class AcpBridge {
1101
1337
  await this.sessionFor(chatId);
1102
1338
  }
1103
1339
 
1340
+ /**
1341
+ * Record `toolCallId → toolName` from a streamed tool-call notification.
1342
+ * See the `toolNames` field comment for why this is the only available source.
1343
+ *
1344
+ * Bounded FIFO: a long turn can issue many tool calls, and this map has no
1345
+ * natural reaper (a tool call's permission request may never arrive at all).
1346
+ * `Map` preserves insertion order, so evicting the first key drops the oldest.
1347
+ * The cap is far above any realistic single turn's tool-call count, so an
1348
+ * eviction in practice means a pathological turn — in which case the affected
1349
+ * request fails closed to the prompt, which is the correct direction.
1350
+ */
1351
+ private rememberToolName(u: SessionUpdate): void {
1352
+ if (u.sessionUpdate !== 'tool_call' && u.sessionUpdate !== 'tool_call_update') return;
1353
+ // Read through a structural view rather than the SDK union: `_meta` is
1354
+ // declared as an open `unknown`-valued record, and `claudeCode.toolName` is
1355
+ // an adapter-INTERNAL convention (like the `_meta` payloads newSessionParams
1356
+ // sends the other way), not part of the ACP schema.
1357
+ const view = u as { toolCallId?: unknown; _meta?: { claudeCode?: { toolName?: unknown } } };
1358
+ const id = view.toolCallId;
1359
+ const name = view._meta?.claudeCode?.toolName;
1360
+ if (typeof id !== 'string' || !id || typeof name !== 'string' || !name) return;
1361
+ if (!this.toolNames.has(id) && this.toolNames.size >= MAX_TRACKED_TOOL_NAMES) {
1362
+ const oldest = this.toolNames.keys().next().value;
1363
+ if (oldest !== undefined) this.toolNames.delete(oldest);
1364
+ }
1365
+ this.toolNames.set(id, name);
1366
+ }
1367
+
1368
+ /**
1369
+ * The write-path decision for one permission request.
1370
+ *
1371
+ * Returns `{ autoApprove: optionId }` ONLY for a known write tool whose every
1372
+ * resolved target lands inside the pinned scope root. Returns `{ info }` for a
1373
+ * known write tool that did NOT pass (so the prompt can be honest about it),
1374
+ * and `{}` for everything else — which is every non-write tool, and therefore
1375
+ * the overwhelmingly common case. Nothing here can make a NON-write tool
1376
+ * easier to approve; the only outcomes are "auto-approve this write" or
1377
+ * "carry on to the prompt that already existed".
1378
+ *
1379
+ * Fail-closed points, all of which land on the prompt rather than on a grant:
1380
+ * • the tool name is unknown (notification missed / evicted / reordered);
1381
+ * • the name isn't a write tool;
1382
+ * • no target path could be extracted;
1383
+ * • `locations[]` and `rawInput` name different files;
1384
+ * • any target resolves outside the root;
1385
+ * • the agent offered no `allow_once`-shaped option.
1386
+ *
1387
+ * That last one is worth stating plainly: auto-approval deliberately uses the
1388
+ * ONCE-only option and never falls back to `allow_always`. Selecting
1389
+ * `allow_always` would make the adapter install a session-wide standing rule
1390
+ * for the tool NAME (`{type:'addRules', rules:[{toolName}]}`, acp-agent.js) —
1391
+ * i.e. it would silently restore the unscoped `Write` grant this whole change
1392
+ * removes, from inside the code that removed it.
1393
+ */
1394
+ private classifyWrite(params: RequestPermissionRequest): {
1395
+ autoApprove?: string;
1396
+ info?: PermissionScopeInfo;
1397
+ stripAlways?: boolean;
1398
+ } {
1399
+ const toolCallId = params.toolCall?.toolCallId;
1400
+ const toolName = typeof toolCallId === 'string' ? this.toolNames.get(toolCallId) : undefined;
1401
+ // SECURITY (security-auditor F2) — TWO different bars, deliberately.
1402
+ //
1403
+ // `named` — a confirmed write tool. The ONLY thing that can be granted.
1404
+ // `shaped` — it merely LOOKS like a write (kind:'edit' / a notebook_path)
1405
+ // because the name is unknown: a missed/evicted/reordered
1406
+ // `tool_call` notification. Never granted, but still warned
1407
+ // about honestly and still stripped of `allow_always`.
1408
+ //
1409
+ // Coupling BOTH to the strict name check (the first cut) meant an unknown
1410
+ // name failed closed for the grant while failing OPEN for the hardening —
1411
+ // Decision D silently defeated, and the card falling back to the model's own
1412
+ // `Write docs/../../../.zshenv` headline. Granting is strict; warning is
1413
+ // generous.
1414
+ const named = isWriteToolName(toolName);
1415
+ if (!named && !looksLikeWriteToolCall(params.toolCall)) return {};
1416
+
1417
+ const verdict = named
1418
+ ? writeTargetsInsideProject(params.toolCall, this.scopeRoot, toolName)
1419
+ : resolveWriteTargets(params.toolCall, this.scopeRoot);
1420
+ if (!verdict.inside) {
1421
+ return {
1422
+ stripAlways: true,
1423
+ info: {
1424
+ outOfProjectWrite: true,
1425
+ resolvedPaths: verdict.resolved,
1426
+ scopeRoot: this.scopeRoot,
1427
+ reason: verdict.reason,
1428
+ },
1429
+ };
1430
+ }
1431
+ // In-project but the name was never confirmed: no grant (that bar needs the
1432
+ // name), and no `scope` either — the target IS inside, so "outside this
1433
+ // project" would be a lie. It gets the ordinary card…
1434
+ //
1435
+ // …but STILL without `allow_always` (security-auditor F6). The two are
1436
+ // separate concerns and the first cut wrongly tied them together: `info` is
1437
+ // COPY (only truthful when the target is outside), `stripAlways` is a
1438
+ // CONTROL (needed whenever the call is write-shaped, wherever it lands).
1439
+ // Selecting `allow_always` makes the adapter install a `{type:'addRules',
1440
+ // rules:[{toolName}]}` standing rule keyed by the tool NAME, which carries
1441
+ // no path scope at all — so one click on an INSIDE-the-project card
1442
+ // permanently permits every subsequent write by that tool, including
1443
+ // out-of-project ones. The in-project-ness of the click is irrelevant to
1444
+ // what the rule then allows; that is the whole hole.
1445
+ if (!named) return { stripAlways: true };
1446
+ const allowOnce = (params.options ?? []).find((o) => o.kind === 'allow_once');
1447
+ // No once-only option on the table → fall through to the prompt. Not an
1448
+ // `info` case (the write IS in-project, so that copy would be a lie), but
1449
+ // still `stripAlways` — see F6 above: a name-keyed standing rule is unscoped
1450
+ // no matter which card it was clicked from.
1451
+ if (!allowOnce) return { stripAlways: true };
1452
+ return { autoApprove: allowOnce.optionId };
1453
+ }
1454
+
1104
1455
  /**
1105
1456
  * Settle a pending permission request (Milestone B). `decision` is either a
1106
1457
  * `PermissionOption.optionId` the agent offered, or the literal `'cancelled'`
@@ -1231,6 +1582,7 @@ export class AcpBridge {
1231
1582
  // otherwise get back a result tied to the connection we just tore down).
1232
1583
  this.sessionPromises.clear();
1233
1584
  this.briefLogged.clear();
1585
+ this.toolNames.clear();
1234
1586
  this.currentSession = null;
1235
1587
  }
1236
1588
 
@@ -1246,9 +1598,16 @@ export class AcpBridge {
1246
1598
  }
1247
1599
  }
1248
1600
 
1249
- private async appendTranscript(entry: Record<string, unknown>): Promise<void> {
1601
+ /** Append one transcript line. `claimedSeq` is passed by the update path,
1602
+ * which already claimed its line number so it could hand the SAME number to
1603
+ * the client (see the seam note there); every other caller claims here. */
1604
+ private async appendTranscript(
1605
+ entry: Record<string, unknown>,
1606
+ claimedSeq?: number
1607
+ ): Promise<void> {
1250
1608
  const path = this.transcriptPath;
1251
1609
  if (!path) return;
1610
+ if (claimedSeq === undefined) this.transcriptLines += 1;
1252
1611
  try {
1253
1612
  await mkdir(dirname(path), { recursive: true });
1254
1613
  await appendFile(path, `${JSON.stringify({ ts: Date.now(), ...entry })}\n`);