@1agh/maude 0.55.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 (85) 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/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,97 @@
1
+ // A one-line registry so the HTTP layer can ask "is a chat mid-turn right now?"
2
+ // without `createHttp` growing an `Acp` parameter (feature-acp-write-path-scope
3
+ // Addendum, Task 9).
4
+ //
5
+ // WHY A REGISTRY AND NOT A PARAMETER: `createHttp` is called with six
6
+ // collaborators already and is constructed in `server.ts` alongside — not
7
+ // after — the ACP manager. Threading the manager in would churn the signature
8
+ // and every test that builds an Http, to expose ONE boolean-ish query. The
9
+ // alternative that was rejected outright is asking the CLIENT (ChatPanel
10
+ // already tracks per-chat busy state): a bridge can now be running DETACHED,
11
+ // with no socket and therefore no client that knows about it — which is
12
+ // precisely the case the branch-switch warning exists for. Client state would
13
+ // answer "no chat is running" at the exact moment the answer matters most.
14
+ //
15
+ // One dev-server process serves one project and calls `createAcp` once, so the
16
+ // single-slot shape is correct here rather than merely convenient. It is
17
+ // deliberately a PULL (a getter the manager registers), not a pushed snapshot:
18
+ // a snapshot would be stale by the time anyone read it, and "was a turn running
19
+ // a moment ago" is the wrong question to gate a `git checkout` on.
20
+
21
+ type RunningChatsProbe = () => string[];
22
+
23
+ let probe: RunningChatsProbe | null = null;
24
+
25
+ /** Registered once by `createAcp`. A later call replaces the earlier one — the
26
+ * last manager constructed is the live one. */
27
+ export function registerRunningChatsProbe(fn: RunningChatsProbe): void {
28
+ probe = fn;
29
+ }
30
+
31
+ /** Chat ids with a turn in flight. Empty when no ACP manager exists at all
32
+ * (web serve without the panel, tests) — never throws, because a failure to
33
+ * answer must not block a branch switch. */
34
+ export function runningChats(): string[] {
35
+ try {
36
+ return probe?.() ?? [];
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ // feature-acp-turn-notifications Task 2 — a richer PULL registry alongside the
43
+ // one above, not a replacement: `runningChats()` stays exactly as it is for
44
+ // the branch-switch warning + the reaper's `has_running_chat` probe (Task 3
45
+ // keeps `/_api/acp/running` unchanged for that reason). This one answers a
46
+ // different question — "what SHOULD the shell tell the user" — which needs a
47
+ // per-chat state, not just a busy/idle boolean, because a chat blocked on a
48
+ // permission prompt is technically still `turnActive` in the ACP sense but is
49
+ // the MORE actionable of the two (see bridge.ts's `awaitingInputCount`).
50
+ export type ChatActivityState = 'running' | 'awaiting-input' | 'idle';
51
+
52
+ export interface ChatActivity {
53
+ chatId: string;
54
+ state: ChatActivityState;
55
+ }
56
+
57
+ export interface ActivitySnapshot {
58
+ /** Bumped only when the snapshot's (chatId, state) set actually changes —
59
+ * lets a poller skip re-deriving transitions on an unchanged read, without
60
+ * needing wall-clock time (Workflow-script-style determinism isn't a
61
+ * concern here, but a plain counter is simpler than a clock either way). */
62
+ seq: number;
63
+ chats: ChatActivity[];
64
+ }
65
+
66
+ type ActivityProbe = () => ChatActivity[];
67
+
68
+ let activityProbe: ActivityProbe | null = null;
69
+ let activitySeq = 0;
70
+ let lastSnapshotKey = '';
71
+
72
+ /** Registered once by `createAcp`, alongside `registerRunningChatsProbe`. */
73
+ export function registerActivityProbe(fn: ActivityProbe): void {
74
+ activityProbe = fn;
75
+ }
76
+
77
+ /** Per-chat activity snapshot + a monotonic `seq`. Empty/unchanged-seq when no
78
+ * ACP manager exists — never throws, same failure posture as `runningChats`. */
79
+ export function activitySnapshot(): ActivitySnapshot {
80
+ let chats: ChatActivity[];
81
+ try {
82
+ chats = activityProbe?.() ?? [];
83
+ } catch {
84
+ chats = [];
85
+ }
86
+ // Order-independent key — chat enumeration order can shuffle between calls
87
+ // (Map iteration) without the underlying state having changed.
88
+ const key = [...chats]
89
+ .sort((a, b) => a.chatId.localeCompare(b.chatId))
90
+ .map((c) => `${c.chatId}:${c.state}`)
91
+ .join(',');
92
+ if (key !== lastSnapshotKey) {
93
+ lastSnapshotKey = key;
94
+ activitySeq++;
95
+ }
96
+ return { seq: activitySeq, chats };
97
+ }
@@ -116,6 +116,70 @@ function readLines(file: string): Array<Record<string, unknown>> {
116
116
  }
117
117
  }
118
118
 
119
+ // ── The re-attach seam (feature-acp-write-path-scope Addendum, Task 8) ───────
120
+ //
121
+ // A bridge now outlives its WebSocket, so a page reload (or a branch switch)
122
+ // re-attaches to a chat that may have kept streaming while nobody was listening.
123
+ // That gives the client TWO sources for the same bytes — the transcript it
124
+ // hydrates over HTTP, and the live stream — and the seam between them needs a
125
+ // marker, not a guess, or the user sees the last few seconds twice (or loses
126
+ // them, if we guess the other way).
127
+ //
128
+ // The marker is the transcript's RAW line count. It works because the bridge
129
+ // appends exactly one line per emitted update, so "line N" and "the Nth thing
130
+ // the client should have seen" are the same number by construction.
131
+ //
132
+ // CRITICAL: these two helpers count/index RAW non-empty lines, deliberately NOT
133
+ // `readLines()`, which silently drops unparseable lines. A single corrupt line
134
+ // would otherwise shift every subsequent seq by one and permanently desync the
135
+ // seam — replaying content the client already has, forever. A malformed line is
136
+ // skipped from the RESULT here but still consumes its index.
137
+
138
+ /** The chat's current sequence marker: how many transcript lines exist. `0` for
139
+ * a chat with no transcript yet. Handed to the client as the `X-Maude-Chat-Seq`
140
+ * header on the history fetch, and echoed back on `attach`. */
141
+ export function chatTranscriptSeq(designRoot: string, chatId: string): number {
142
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
143
+ if (!existsSync(file)) return 0;
144
+ try {
145
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean).length;
146
+ } catch {
147
+ return 0;
148
+ }
149
+ }
150
+
151
+ /** Transcript lines strictly after `afterSeq`, each paired with its own 1-based
152
+ * seq — what a re-attaching client missed while it had no socket. Bounded by
153
+ * `limit` so a client that attaches with `seq: 0` against a long transcript
154
+ * can't make the server serialize the whole history into WS frames (it already
155
+ * hydrated that over HTTP; the replay exists for the gap, not for the archive). */
156
+ export function readChatLinesAfter(
157
+ designRoot: string,
158
+ chatId: string,
159
+ afterSeq: number,
160
+ limit = 500
161
+ ): Array<{ seq: number; entry: Record<string, unknown> }> {
162
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
163
+ if (!existsSync(file)) return [];
164
+ let raw: string[];
165
+ try {
166
+ raw = readFileSync(file, 'utf8').split('\n').filter(Boolean);
167
+ } catch {
168
+ return [];
169
+ }
170
+ const out: Array<{ seq: number; entry: Record<string, unknown> }> = [];
171
+ // Walk from the END so `limit` keeps the MOST RECENT lines — truncating the
172
+ // tail would drop precisely the streaming updates the re-attach is for.
173
+ for (let i = raw.length - 1; i >= afterSeq && out.length < limit; i--) {
174
+ try {
175
+ out.push({ seq: i + 1, entry: JSON.parse(raw[i]) as Record<string, unknown> });
176
+ } catch {
177
+ /* malformed line — skipped from the result, but its index is still spent */
178
+ }
179
+ }
180
+ return out.reverse();
181
+ }
182
+
119
183
  /** First user line, truncated — the chat's display title. Context-attachment
120
184
  * lines are stripped first, so a chat never titles itself `[maude-context …`
121
185
  * (the 2026-07-03 dogfood finding). */
@@ -0,0 +1,459 @@
1
+ // The ACP write-path scope gate (feature-acp-write-path-scope).
2
+ //
3
+ // WHY THIS FILE EXISTS
4
+ // -------------------
5
+ // DDR-184 put `Edit` / `Write` / `NotebookEdit` on `MAUDE_DEFAULT_ALLOWED_TOOLS`
6
+ // as bare tool names, which means the CLI approves them itself and the bridge's
7
+ // `requestPermission` gate is never called at all. The justification recorded in
8
+ // bridge.ts was that "edits land in the served project (already the edit target)
9
+ // and are reversible via the `_history/` snapshot stack" — BOTH halves of which
10
+ // are true only INSIDE the project, and nothing enforced that. A write to
11
+ // `~/.zshenv`, `~/Library/LaunchAgents/*.plist` or `~/.config/environment.d/*.conf`
12
+ // was auto-approved silently, with no prompt and no rollback. That is the
13
+ // delivery primitive behind the A2 finding of the 2026-08-04 attacker pass
14
+ // (untrusted DDR-054 project content steering the auto-approving session into one
15
+ // file write that moves `MAUDE_CLOUD_URL`, which governs both the sidecar's
16
+ // bearer-token destination and a native OS opener).
17
+ //
18
+ // The fix moves the decision rather than widening the name list: the three write
19
+ // tools come OFF the allow-list, and `requestPermission` auto-approves them —
20
+ // with no prompt, exactly preserving DDR-184's goal — when and only when every
21
+ // resolved target lands inside the session's pinned project root. Everything else
22
+ // goes to the prompt that already exists.
23
+ //
24
+ // WHAT THE ADAPTER ACTUALLY SENDS (measured 2026-08-07 against
25
+ // @agentclientprotocol/claude-agent-acp@0.57.0 — plan Task 1; do NOT re-derive
26
+ // this from the ACP type declarations, they disagree with the implementation)
27
+ // -------------------------------------------------------------------------
28
+ // 1. `toolCall.locations[].path` for `Write`/`Edit` is `input.file_path`
29
+ // VERBATIM (`dist/tools.js` — `locations: input?.file_path ? [{ path:
30
+ // input.file_path }] : []`). The adapter does NOT normalize, absolutize, or
31
+ // validate it, despite the SDK's own `types.gen.d.ts:568-572` describing the
32
+ // field as "The absolute file path being accessed or modified". So:
33
+ // • a relative path is possible and MUST be resolved against the session
34
+ // `cwd` (which `newSessionParams` pins to the repo root), and
35
+ // • the plan's "cross-check `locations[].path` against `rawInput.file_path`"
36
+ // is TAUTOLOGICAL for these two tools — they read the same field. The
37
+ // cross-check is kept anyway (a non-compliant/hostile adapter is exactly
38
+ // the case a fail-closed gate is for), but nobody should mistake it for
39
+ // load-bearing corroboration between two independent sources.
40
+ // 2. `NotebookEdit` has NO case in the adapter's tool mapper at all — it falls
41
+ // through to `case "Other"`, which emits **no `locations` whatsoever**. The
42
+ // `rawInput` fallback is therefore MANDATORY, not a defensive nicety, and
43
+ // the relevant field is `notebook_path`, not `file_path`.
44
+ // 3. The permission request does NOT carry the tool NAME. `requestPermission`'s
45
+ // `toolCall` is built inline as `{ toolCallId, rawInput, ...toolInfoFromToolUse(…) }`
46
+ // (`dist/acp-agent.js:2270-2286`) and `toolInfoFromToolUse` returns only
47
+ // title/kind/content/locations. The name rides ONLY on the streamed
48
+ // `tool_call` / `tool_call_update` notification, as
49
+ // `_meta.claudeCode.toolName` (`dist/acp-agent.js:3808-3829`) — which the
50
+ // adapter guarantees is emitted BEFORE the permission request (
51
+ // `requestPermissionFromClient` awaits `ensureToolCallEmitted` first). That
52
+ // is why the caller passes `toolName` in rather than sniffing `kind`/`title`:
53
+ // `kind: 'edit'` is shared with any future edit-shaped tool, and `title` is
54
+ // a display string. An unknown name simply fails closed to the prompt.
55
+ //
56
+ // Pure + dependency-free on purpose: unit-testable without a session, a
57
+ // subprocess, or a bridge (test/acp-write-scope.test.ts).
58
+
59
+ import { realpathSync } from 'node:fs';
60
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
61
+
62
+ /**
63
+ * The tools this gate scopes. Bare names as they arrive on
64
+ * `_meta.claudeCode.toolName`.
65
+ *
66
+ * `MultiEdit` is included defensively — it is not a tool the current Claude Code
67
+ * ships, but it has existed, its input shape is `file_path`-keyed like `Edit`,
68
+ * and the cost of listing a tool that never fires is zero while the cost of
69
+ * missing one is an unscoped write. It is NOT on `MAUDE_DEFAULT_ALLOWED_TOOLS`
70
+ * either way, so listing it here can only ever ADD a scope check, never a grant.
71
+ */
72
+ export const WRITE_TOOL_NAMES: ReadonlySet<string> = new Set([
73
+ 'Write',
74
+ 'Edit',
75
+ 'MultiEdit',
76
+ 'NotebookEdit',
77
+ ]);
78
+
79
+ export function isWriteToolName(name: unknown): boolean {
80
+ return typeof name === 'string' && WRITE_TOOL_NAMES.has(name);
81
+ }
82
+
83
+ /** Why a write was (or wasn't) judged in-project — surfaced in the prompt + tests. */
84
+ export type WriteScopeReason =
85
+ /** Every resolved target is inside the pinned root — auto-approve. */
86
+ | 'inside'
87
+ /** At least one resolved target is outside the pinned root. */
88
+ | 'outside'
89
+ /** No path could be extracted at all — fail closed. */
90
+ | 'no-target'
91
+ /** `locations[]` and `rawInput` named different files — fail closed. */
92
+ | 'disagreement'
93
+ /** Inside the root, but under a path that reaches EXECUTION — see
94
+ * `PROTECTED_IN_PROJECT`. In-project is a necessary condition for
95
+ * auto-approval, not a sufficient one. */
96
+ | 'in-project-denied';
97
+
98
+ export interface WriteScopeVerdict {
99
+ /** True ONLY when every resolvable target is inside the pinned project root. */
100
+ inside: boolean;
101
+ reason: WriteScopeReason;
102
+ /** The RESOLVED absolute paths, deduped, in discovery order. What the prompt
103
+ * must render — never the model's own string (`docs/../../../.zshenv` reads
104
+ * as harmless in a prompt; its resolution does not). Empty for `no-target`. */
105
+ resolved: string[];
106
+ }
107
+
108
+ /**
109
+ * Paths that are INSIDE the project and must still never be auto-approved.
110
+ *
111
+ * SECURITY (ethical-hacker A1, CRITICAL) — the feature's premise is that an
112
+ * in-project write is safe because it "lands in the served project (already the
113
+ * edit target) and is reversible via the `_history/` snapshot stack". These are
114
+ * the named exception class where BOTH halves are false: not the edit target,
115
+ * and `_history/` snapshots canvases under `<designRoot>` — nothing here. The
116
+ * corrected rule: **in-project is NECESSARY for auto-approval, not SUFFICIENT.**
117
+ *
118
+ * The concrete chain, which is why this is a blocker and not a residual — it is
119
+ * TWO ordinary auto-approved writes and needs no second tool and no user action:
120
+ * 1. `Write <root>/.git/config` → `[core] fsmonitor = "sh /path/payload"`.
121
+ * 2. `Write <any canvas>.tsx` — the thing the session does all day. `git/watch.ts`
122
+ * `isVersionable()` matches `.tsx` → debounce → `gitStatus(repoRoot)` →
123
+ * `service.ts` `statusSystem` (the DDR-133 default whenever a `git` binary
124
+ * is on PATH, not an opt-in) → `spawn('git', ['status', …])` in the repo
125
+ * whose config was just rewritten. Git refreshes the index, which invokes
126
+ * `core.fsmonitor` THROUGH A SHELL.
127
+ * Shell invocation is what makes `.git/config` the vector rather than
128
+ * `.git/hooks/*`: `Write` creates 0644 and the session has no `chmod`, so a hook
129
+ * is inert — but `fsmonitor` needs no executable bit. Sibling keys reach the same
130
+ * place on other triggers (`credential.helper`/`core.askPass` on fetch,
131
+ * `diff.external`/`core.pager` on the Changes panel, `filter.*.smudge` paired
132
+ * with an in-project `.gitattributes` on checkout), so this is a class of
133
+ * triggers over ONE write, not a single key to patch.
134
+ *
135
+ * Each entry, and why it is not merely tidiness:
136
+ * • `.git` — the above. Matched as a path SEGMENT AT ANY DEPTH, not just the
137
+ * first: a nested checkout, a submodule, or `sub/.git/config` is the same
138
+ * primitive one directory down.
139
+ * • `.gitattributes` / `.gitmodules` — the other half of `filter.*.smudge`, and
140
+ * submodule URLs; both are read by git without being under `.git/`.
141
+ * • `.claude` — settings, hooks, skills, commands. DDR-144's
142
+ * `settingSources:['user']` stops the ACP session reading the PROJECT copy
143
+ * (verified), but a plain `claude` the user opens in this repo does read it,
144
+ * so the write steers a FUTURE session with wider permissions.
145
+ * • `CLAUDE.md` — read via a separate path from `settingSources`, i.e. the
146
+ * narrowing does NOT cover it. One write loads an injection into every future
147
+ * session in this repo, including the user's own terminal.
148
+ * • `.mcp.json` — project MCP server definitions; a new server is new tools.
149
+ * • `node_modules` — a write executes at the next import, and this process
150
+ * imports from there constantly.
151
+ * • `package.json` + lockfiles — `scripts` run on install/build/test.
152
+ *
153
+ * • `.envrc` — direnv executes it on `cd` into the repo. The lowest bar of
154
+ * anything here: no app action, no git operation, no user click beyond
155
+ * entering the directory in a shell.
156
+ * • `.vscode` — `settings.json`'s `terminal.integrated.env.*` and `tasks.json`'s
157
+ * `runOn: folderOpen` execute when the user opens the project in their
158
+ * editor, which is the most likely thing they do next after opening a design
159
+ * project.
160
+ *
161
+ * THE PREDICATE, recorded so this list is extended by RE-DERIVATION rather than
162
+ * by pattern-matching the entries above: **an in-project path that reaches
163
+ * execution without a further agent action.** If you add something, add the
164
+ * reasoning that found it, not just the string.
165
+ *
166
+ * Deliberately NOT a broad denylist: every entry costs a prompt on a genuinely
167
+ * in-project path, and prompt fatigue is itself a security failure. Equally
168
+ * deliberately, this is a SHAPE and not a proof of completeness — the plan's own
169
+ * recurring-enumeration warning applies here verbatim. In-project `*.sh` that the
170
+ * helper surface executes is a known member NOT covered, because the helper
171
+ * surface is reached via `Bash(maude:*)`, which is separately accepted as an
172
+ * arbitrary-code-execution surface already.
173
+ */
174
+ export const PROTECTED_IN_PROJECT: readonly string[] = [
175
+ '.git',
176
+ '.gitattributes',
177
+ '.gitmodules',
178
+ '.claude',
179
+ 'CLAUDE.md',
180
+ '.mcp.json',
181
+ '.envrc',
182
+ '.vscode',
183
+ 'node_modules',
184
+ 'package.json',
185
+ 'package-lock.json',
186
+ 'pnpm-lock.yaml',
187
+ 'yarn.lock',
188
+ 'bun.lock',
189
+ 'bun.lockb',
190
+ ];
191
+
192
+ /**
193
+ * Protected path PREFIXES — matched as a leading run of segments rather than a
194
+ * single segment, for the case where the parent must stay writable.
195
+ *
196
+ * `.github/workflows/**` executes in CI with the repository's secrets, and THIS
197
+ * APP SHIPS THE TRIGGER: "Save version" → "Publish" (`/_api/git/commit` →
198
+ * `/_api/git/push`) is a two-click path from an auto-approved workflow write to
199
+ * CI execution. But `.github/` itself holds ordinary versioned files
200
+ * (ISSUE_TEMPLATE, CODEOWNERS, dependabot.yml), so a segment match on `.github`
201
+ * would deny normal work — hence a prefix rule for this one.
202
+ */
203
+ export const PROTECTED_PREFIXES: readonly string[][] = [['.github', 'workflows']];
204
+
205
+ /**
206
+ * True when `target` (resolved, already known to be inside `root`) touches a
207
+ * protected path. Segment-wise at ANY depth, never a prefix compare: a prefix
208
+ * would catch `.gitignore` and `.github/` (ordinary files) — the same
209
+ * sibling-prefix bug as the root check itself — while missing `sub/.git/config`,
210
+ * which is the real thing one directory down.
211
+ */
212
+ export function isProtectedInProject(target: string, root: string): boolean {
213
+ // SECURITY (ethical-hacker A6) — fold UNCONDITIONALLY, not just on win32.
214
+ //
215
+ // `realpathSync.native` canonicalizes the casing of components that EXIST on
216
+ // disk, which is why `.GIT/config` resolves to `.git/config` and is caught.
217
+ // But a protected path that does NOT yet exist falls back to
218
+ // `<realpath of nearest existing ancestor> + the trailing segments verbatim`,
219
+ // so the caller's casing survives into this comparison — and macOS's default
220
+ // filesystem is case-INSENSITIVE, so `.CLAUDE/settings.json` is the same file
221
+ // as `.claude/settings.json`. Measured on the shipped module: `claude.md`,
222
+ // `.CLAUDE/settings.json` and `.MCP.json` were all auto-approved while their
223
+ // lowercase forms were denied. In a typical design project `.claude/`,
224
+ // `CLAUDE.md`, `.mcp.json`, `.gitattributes` and `.gitmodules` are all ABSENT,
225
+ // so every one of them was reachable by pressing shift.
226
+ //
227
+ // Cost on a case-SENSITIVE filesystem: a genuine file named `.Git` or
228
+ // `Claude.md` now prompts. That is the right direction — a case-variant of a
229
+ // protected name is never a legitimate distinct file, and this check only ever
230
+ // ADDS a prompt.
231
+ //
232
+ // Deliberately NOT applied to `isInsideRoot`: that one is a CONTAINMENT check,
233
+ // where folding on a case-sensitive filesystem could wrongly judge a sibling
234
+ // directory as inside. Opposite risk, opposite default.
235
+ const fold = (v: string) => v.toLowerCase();
236
+ const rel = relative(fold(root), fold(target));
237
+ if (!rel || isAbsolute(rel)) return false;
238
+ const segments = rel.split(sep);
239
+ if (segments.some((seg) => PROTECTED_IN_PROJECT.some((p) => fold(p) === seg))) return true;
240
+ return PROTECTED_PREFIXES.some((prefix) => prefix.every((seg, i) => fold(seg) === segments[i]));
241
+ }
242
+
243
+ /** The `rawInput` keys a write tool can carry its target on. `file_path` is
244
+ * Write/Edit/MultiEdit; `notebook_path` is NotebookEdit (which, per the header,
245
+ * emits no `locations` at all so this is its ONLY source); `path`/`abs_path`
246
+ * are belt-and-braces for a tool that renames the field. Order matters only for
247
+ * tie-breaking the discovery order in `resolved`. */
248
+ const RAW_INPUT_PATH_KEYS = ['file_path', 'notebook_path', 'path', 'abs_path', 'filePath'] as const;
249
+
250
+ interface ToolCallLike {
251
+ toolCallId?: unknown;
252
+ locations?: Array<{ path?: unknown } | null> | null;
253
+ rawInput?: unknown;
254
+ }
255
+
256
+ /** Every path string the tool call names, split by where it came from. Kept
257
+ * separate so `writeTargetsInsideProject` can run the (today tautological, see
258
+ * header) locations-vs-rawInput cross-check before collapsing them. */
259
+ function collectTargets(toolCall: ToolCallLike | null | undefined): {
260
+ fromLocations: string[];
261
+ fromRawInput: string[];
262
+ } {
263
+ const fromLocations: string[] = [];
264
+ const locations = toolCall?.locations;
265
+ if (Array.isArray(locations)) {
266
+ for (const l of locations) {
267
+ const p = l?.path;
268
+ if (typeof p === 'string' && p) fromLocations.push(p);
269
+ }
270
+ }
271
+ const fromRawInput: string[] = [];
272
+ const raw = toolCall?.rawInput;
273
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
274
+ for (const key of RAW_INPUT_PATH_KEYS) {
275
+ const p = (raw as Record<string, unknown>)[key];
276
+ if (typeof p === 'string' && p) fromRawInput.push(p);
277
+ }
278
+ }
279
+ return { fromLocations, fromRawInput };
280
+ }
281
+
282
+ /**
283
+ * Resolve `p` to a real absolute path, following symlinks as far as the
284
+ * filesystem actually goes.
285
+ *
286
+ * A write target routinely does NOT exist yet (that is what `Write` is for), and
287
+ * `realpathSync` throws on a missing path — so walk UP to the nearest existing
288
+ * ancestor, resolve THAT, and re-join the non-existent remainder. The parent
289
+ * decides, which is the correct semantic: creating `<symlink-to-/etc>/x.conf`
290
+ * must be judged against `/etc`, not against the symlink's own location.
291
+ *
292
+ * Falls back to the lexically-resolved path if nothing up the chain resolves
293
+ * (a fully non-existent root, or an EACCES walking up) — lexical resolution
294
+ * still collapses `..`, so the fallback is strictly safer than the raw input,
295
+ * just less thorough than a realpath.
296
+ */
297
+ export function resolveRealPath(p: string, base: string): string {
298
+ const abs = isAbsolute(p) ? resolve(p) : resolve(base, p);
299
+ const trailing: string[] = [];
300
+ let cur = abs;
301
+ for (;;) {
302
+ try {
303
+ // `.native` so Windows resolves 8.3 short names (`PROGRA~1`) and drive
304
+ // casing to their canonical long form BEFORE any comparison — a documented
305
+ // bypass class for prefix-style path checks.
306
+ const real = realpathSync.native(cur);
307
+ return trailing.length ? join(real, ...trailing) : real;
308
+ } catch {
309
+ const parent = dirname(cur);
310
+ if (parent === cur) return abs; // hit the filesystem root without resolving
311
+ trailing.unshift(basename(cur));
312
+ cur = parent;
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * True when `target` (already resolved) sits strictly INSIDE `root`.
319
+ *
320
+ * Deliberately `path.relative`, never `startsWith(root)`: a prefix compare also
321
+ * matches `/repo-evil` against a root of `/repo` — the classic sibling-prefix
322
+ * bug. The relative path must be non-empty (the root ITSELF is not a file write
323
+ * target), must not be absolute (different drive/root on Windows), and must not
324
+ * climb out via `..`.
325
+ *
326
+ * Windows is compared case-insensitively. `path.relative` already lowercases the
327
+ * drive letter on win32, but not the rest of the path, so `C:\Repo\x` vs
328
+ * `C:\repo` would otherwise produce a spurious `..\Repo\x`. Both sides have
329
+ * already been through `realpathSync.native`, so short names and casing are
330
+ * canonical by this point; the fold is belt-and-braces for a filesystem that
331
+ * canonicalizes differently than the directory entry.
332
+ */
333
+ export function isInsideRoot(target: string, root: string): boolean {
334
+ const fold = (s: string) => (process.platform === 'win32' ? s.toLowerCase() : s);
335
+ const rel = relative(fold(root), fold(target));
336
+ if (!rel) return false;
337
+ if (isAbsolute(rel)) return false;
338
+ if (rel === '..' || rel.startsWith(`..${sep}`)) return false;
339
+ return true;
340
+ }
341
+
342
+ /**
343
+ * Resolve a repo root ONCE, at bridge construction, into the value every later
344
+ * containment check compares against.
345
+ *
346
+ * Task 11 / Solution E — this is deliberately a separate, eagerly-computed
347
+ * value rather than a read of `opts.repoRoot` at check time. A session that
348
+ * outlives a project switch (see the plan's Addendum) must keep the write scope
349
+ * of the project it was CREATED in; it must never acquire the currently-open
350
+ * project's scope. Recomputing at check time is exactly the refactor that would
351
+ * silently break that.
352
+ */
353
+ export function pinScopeRoot(repoRoot: string): string {
354
+ return resolveRealPath(repoRoot, process.cwd());
355
+ }
356
+
357
+ /**
358
+ * The gate. Auto-approve is granted ONLY on `{ inside: true }`.
359
+ *
360
+ * `toolName` is required and must come from the streamed `tool_call`'s
361
+ * `_meta.claudeCode.toolName` — see the header for why it is not derivable from
362
+ * the permission request itself. A name that isn't a known write tool returns
363
+ * `inside: false` with reason `no-target`: this helper never grants anything it
364
+ * wasn't explicitly asked about.
365
+ *
366
+ * `scopeRoot` must be the value from `pinScopeRoot` (already realpath-resolved).
367
+ * Passing a raw, unresolved root would compare a symlinked root against resolved
368
+ * targets and reject every legitimate in-project write.
369
+ */
370
+ export function writeTargetsInsideProject(
371
+ toolCall: ToolCallLike | null | undefined,
372
+ scopeRoot: string,
373
+ toolName: unknown
374
+ ): WriteScopeVerdict {
375
+ if (!isWriteToolName(toolName)) return { inside: false, reason: 'no-target', resolved: [] };
376
+ return resolveWriteTargets(toolCall, scopeRoot);
377
+ }
378
+
379
+ /**
380
+ * Does this tool call LOOK like a write, without knowing its name?
381
+ *
382
+ * SECURITY (security-auditor F2) — the name-gated `writeTargetsInsideProject`
383
+ * above fails closed for the GRANT but was failing OPEN for the HARDENING: an
384
+ * unknown name meant no `scope` on the frame, which meant `allow_always` was NOT
385
+ * stripped (one click installs a session-wide standing rule for `Write` —
386
+ * Decision D defeated) and the card fell back to `toolCall.title`, i.e. the
387
+ * model's own `Write docs/../../../.zshenv`. Both protections were riding on the
388
+ * strict name check, which is the wrong coupling: granting must be strict,
389
+ * warning must be generous.
390
+ *
391
+ * Deliberately NOT "`rawInput` has a `file_path`" — `Read` carries one too, and
392
+ * a Read of an out-of-project file would then get "Claude wants to write a file
393
+ * outside this project", a claim the server never made, plus its `allow_always`
394
+ * stripped for no reason. `kind: 'edit'` is what the adapter sets for
395
+ * Write/Edit (`toolInfoFromToolUse`), and `notebook_path` is NotebookEdit's
396
+ * signature (it has no case in the mapper, so it arrives as `kind: 'other'`).
397
+ */
398
+ export function looksLikeWriteToolCall(toolCall: ToolCallLike | null | undefined): boolean {
399
+ if (!toolCall) return false;
400
+ if ((toolCall as { kind?: unknown }).kind === 'edit') return true;
401
+ const raw = toolCall.rawInput;
402
+ return !!raw && typeof raw === 'object' && 'notebook_path' in (raw as Record<string, unknown>);
403
+ }
404
+
405
+ /**
406
+ * The path half of the gate, WITHOUT the tool-name check.
407
+ *
408
+ * Split out so the caller can apply the two different bars the auditor's F2
409
+ * asks for: a STRICT name match to auto-approve, and a GENEROUS one to strip
410
+ * `allow_always` and render resolved paths. Never call this directly to decide
411
+ * a grant — `writeTargetsInsideProject` is the grant entry point.
412
+ */
413
+ export function resolveWriteTargets(
414
+ toolCall: ToolCallLike | null | undefined,
415
+ scopeRoot: string
416
+ ): WriteScopeVerdict {
417
+ const { fromLocations, fromRawInput } = collectTargets(toolCall);
418
+ if (fromLocations.length === 0 && fromRawInput.length === 0) {
419
+ // A write tool arriving with nothing resolvable is treated as out-of-project
420
+ // — the gate's fail-closed default. It costs a prompt on a malformed call,
421
+ // which is the cheap direction to be wrong in.
422
+ return { inside: false, reason: 'no-target', resolved: [] };
423
+ }
424
+
425
+ // Resolve BOTH sources before comparing them. Comparing the raw strings would
426
+ // call `./x.tsx` and `/repo/x.tsx` a disagreement when they are the same file.
427
+ const resolvedLocations = fromLocations.map((p) => resolveRealPath(p, scopeRoot));
428
+ const resolvedRawInput = fromRawInput.map((p) => resolveRealPath(p, scopeRoot));
429
+
430
+ // Cross-check (see the header — tautological against today's adapter, kept
431
+ // because a gate whose whole job is fail-closed should not assume a
432
+ // well-behaved counterparty). If BOTH sources named something and their sets
433
+ // differ at all, we cannot say which one the CLI will actually write, so we
434
+ // refuse to auto-approve either.
435
+ if (resolvedLocations.length > 0 && resolvedRawInput.length > 0) {
436
+ const a = new Set(resolvedLocations);
437
+ const b = new Set(resolvedRawInput);
438
+ const agree = a.size === b.size && [...a].every((p) => b.has(p));
439
+ if (!agree) {
440
+ return {
441
+ inside: false,
442
+ reason: 'disagreement',
443
+ resolved: [...new Set([...resolvedLocations, ...resolvedRawInput])],
444
+ };
445
+ }
446
+ }
447
+
448
+ const resolved = [...new Set([...resolvedLocations, ...resolvedRawInput])];
449
+ // EVERY target must pass — a multi-location write with one escape is an escape.
450
+ const inside = resolved.every((p) => isInsideRoot(p, scopeRoot));
451
+ if (!inside) return { inside: false, reason: 'outside', resolved };
452
+ // Inside is NECESSARY but not SUFFICIENT. A write under `.git/` or `.claude/`
453
+ // is in-project and still reaches execution, so it goes to the prompt like any
454
+ // out-of-project write would. See PROTECTED_IN_PROJECT.
455
+ if (resolved.some((p) => isProtectedInProject(p, scopeRoot))) {
456
+ return { inside: false, reason: 'in-project-denied', resolved };
457
+ }
458
+ return { inside: true, reason: 'inside', resolved };
459
+ }