@1agh/maude 0.56.0 → 0.58.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.
- package/README.md +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -175,7 +175,11 @@ pnpm version # = bash scripts/changesets-version.sh
|
|
|
175
175
|
# → re-runs scripts/check-version-parity.sh
|
|
176
176
|
|
|
177
177
|
git commit -am "chore: release v$(node -p "require('./package.json').version")"
|
|
178
|
-
|
|
178
|
+
# ANNOTATED (-a -m) is required — `git push --follow-tags` only pushes annotated
|
|
179
|
+
# tags. A lightweight `git tag vX.Y.Z` will silently stay local and no release
|
|
180
|
+
# workflow ever fires.
|
|
181
|
+
VER="v$(node -p "require('./package.json').version")"
|
|
182
|
+
git tag -a "$VER" -m "$VER"
|
|
179
183
|
git push --follow-tags
|
|
180
184
|
```
|
|
181
185
|
|
|
@@ -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
|
-
/**
|
|
52
|
-
|
|
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?: (
|
|
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
|
-
// •
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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`);
|