@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.
- 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/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- 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 +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- 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/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -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/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- 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
|
+
}
|