@vibe-commit/capture 0.1.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook client — and the enforcement of the hook half of the exit contract.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN.md §13.7, restated because every line below exists to hold it:
|
|
5
|
+
*
|
|
6
|
+
* exit ALWAYS 0. Every path: success, 401, 403, network failure,
|
|
7
|
+
* uncaught exception, unsupported Node, signal.
|
|
8
|
+
* stderr ALWAYS empty.
|
|
9
|
+
* wall clock ALWAYS under the hooks.json timeout for that event.
|
|
10
|
+
* stdout At most one well-formed JSON object carrying `systemMessage`;
|
|
11
|
+
* otherwise empty. If JSON construction fails for ANY reason →
|
|
12
|
+
* empty stdout. The fallback is always silence.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ **That stdout line reads "EMPTY on the happy path" in DESIGN.md §13.7, and
|
|
15
|
+
* as of `CR-023d` it is no longer true.** Style guide §10.1's fourth state
|
|
16
|
+
* (`capture confirmed`) fires on a successful send, once per `(repo, day)` — so
|
|
17
|
+
* a populated stdout no longer implies something went wrong. §13.7 and §10.1
|
|
18
|
+
* disagreed on this before this task; the code now follows §10.1, which D65
|
|
19
|
+
* records as the executed ruling. DESIGN.md is orchestrator-owned (D66 §3), so
|
|
20
|
+
* the amendment there is reported, not made here.
|
|
21
|
+
*
|
|
22
|
+
* **A hook that exits non-zero feeds stderr back into the user's agent and
|
|
23
|
+
* derails the task they were actually doing.** That is the whole reason.
|
|
24
|
+
*
|
|
25
|
+
* ## Read this before trusting a green contract test
|
|
26
|
+
*
|
|
27
|
+
* `silenceStderr()` makes the stderr-empty assertion true STRUCTURALLY. That is
|
|
28
|
+
* the correct engineering choice — it is the only thing that holds when a
|
|
29
|
+
* dependency three levels down decides to warn — but it also means a green
|
|
30
|
+
* "stderr is empty" proves the hook never speaks, and proves **nothing** about
|
|
31
|
+
* whether the credential is handled carefully. The credential's own control is
|
|
32
|
+
* separate and lives in `credential.ts` (opaque wrapper + redaction), with its
|
|
33
|
+
* own red-proof in `test/credential.test.ts`. Do not read one as evidence for
|
|
34
|
+
* the other.
|
|
35
|
+
*/
|
|
36
|
+
import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync, writeSync, } from "node:fs";
|
|
37
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
38
|
+
import { zstdCompressSync } from "node:zlib";
|
|
39
|
+
import { isProjectAllowed } from "../consent.js";
|
|
40
|
+
import { loadCredential } from "../credential.js";
|
|
41
|
+
import { EXIT } from "../exit.js";
|
|
42
|
+
import { gitProbe, headRef, resolveRepoSlug } from "../git.js";
|
|
43
|
+
import { redactSpan, rulesForTranscript } from "../redact.js";
|
|
44
|
+
import { deliver, ingestUrl } from "../post.js";
|
|
45
|
+
import { markSkipped } from "../policy.js";
|
|
46
|
+
import { fileState, loadSessionState, saveSessionState, withFileState } from "../state.js";
|
|
47
|
+
import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
|
|
48
|
+
import { admitsTranscript, agentForTranscript, dialectFor, transcriptRoots, } from "../agents/registry.js";
|
|
49
|
+
import { announcedSubagentFileKey, isInsideAny, subagentFileKey, subagentsDir, } from "../paths.js";
|
|
50
|
+
import { resolveProjectKeys } from "../project.js";
|
|
51
|
+
import { startSpawnBudget } from "../spawn_budget.js";
|
|
52
|
+
import { capSpool, capSuccessors, readRewrites, readSpool } from "../spool.js";
|
|
53
|
+
import { meetsNodeFloor } from "../runtime.js";
|
|
54
|
+
import { renderNotice } from "../system_message.js";
|
|
55
|
+
/**
|
|
56
|
+
* Default wall-clock budget. `CR-028` sets the real per-event `timeout` in
|
|
57
|
+
* `claude-plugin/hooks/hooks.json`; this is the client-side backstop, which must
|
|
58
|
+
* be the SMALLER of the two or the backstop never fires.
|
|
59
|
+
*
|
|
60
|
+
* ⚠ **That relation was prose and was checked by nothing until `CR-183`.** It is
|
|
61
|
+
* now asserted for every event of every dialect in `test/agent-registry.test.ts`,
|
|
62
|
+
* which also pins this constant against the `claude-code` dialect's own entry.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_HOOK_BUDGET_MS = 5_000;
|
|
65
|
+
/** The subset of the network budget one attempt may take. */
|
|
66
|
+
const SEND_BUDGET_FRACTION = 0.6;
|
|
67
|
+
/**
|
|
68
|
+
* A floor under the send timeout, for the case the budget arithmetic cannot
|
|
69
|
+
* cover: `consumedMs` is MEASURED, not planned, so a scheduler stall during the
|
|
70
|
+
* settle can overshoot the budget outright and leave nothing to divide.
|
|
71
|
+
* `settleDelayMs`'s cap bounds the plan; this bounds reality.
|
|
72
|
+
*
|
|
73
|
+
* When it binds, the outer watchdog is already the real limit — it fires at
|
|
74
|
+
* `budgetMs` regardless — so a small positive timeout here costs nothing and a
|
|
75
|
+
* zero or negative one would abort the send before it was attempted.
|
|
76
|
+
*/
|
|
77
|
+
const MIN_SEND_TIMEOUT_MS = 50;
|
|
78
|
+
/**
|
|
79
|
+
* What one send attempt may take, after the settle has taken its share.
|
|
80
|
+
*
|
|
81
|
+
* `consumedMs` is 0 on every event but `SessionEnd`, and at 0 this is exactly
|
|
82
|
+
* the expression that was inline here before `CR-020d`
|
|
83
|
+
* (`Math.floor(budgetMs * SEND_BUDGET_FRACTION)`) for any budget above ~84 ms —
|
|
84
|
+
* so `Stop` and `PreCompact` are unchanged in both timing and code path.
|
|
85
|
+
*/
|
|
86
|
+
export function sendTimeoutMs(budgetMs, consumedMs) {
|
|
87
|
+
const remaining = Math.max(0, budgetMs - consumedMs);
|
|
88
|
+
return Math.max(MIN_SEND_TIMEOUT_MS, Math.floor(remaining * SEND_BUDGET_FRACTION));
|
|
89
|
+
}
|
|
90
|
+
/** A promise that resolves after `ms`. The one clock the settle path needs. */
|
|
91
|
+
function wait(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Below this much REMAINING budget, stop starting sub-agent files — `CR-124`,
|
|
96
|
+
* D95.
|
|
97
|
+
*
|
|
98
|
+
* This, not a file count, is the real bound. `sendTimeoutMs` gives each attempt
|
|
99
|
+
* 0.6 of what is LEFT, so the remainder decays geometrically (5000 → 2000 → 800
|
|
100
|
+
* → …) and the sum can never reach the budget however many files there are. What
|
|
101
|
+
* a floor adds is refusing to START an attempt so small it can only time out:
|
|
102
|
+
* a 200 ms send against a slow server burns the wall clock and delivers nothing,
|
|
103
|
+
* and the file's offset would have been held anyway.
|
|
104
|
+
*
|
|
105
|
+
* The leftovers are not lost. Their offsets simply do not advance, and the next
|
|
106
|
+
* hook picks them up — except on `SessionEnd`, where there is no next hook and
|
|
107
|
+
* they stamp a gap instead (D95).
|
|
108
|
+
*/
|
|
109
|
+
const MIN_SUBAGENT_BUDGET_MS = 400;
|
|
110
|
+
/**
|
|
111
|
+
* A hard backstop on files attempted per invocation.
|
|
112
|
+
*
|
|
113
|
+
* The floor above is the bound that binds when the server is slow; this one
|
|
114
|
+
* bounds the pathological shape the floor cannot see — a directory with
|
|
115
|
+
* hundreds of entries answering instantly, where the loop would spend its time
|
|
116
|
+
* in `stat` rather than in sends. The busiest session measured on this machine
|
|
117
|
+
* has 27 sub-agent files, so eight per invocation clears it in four Stop hooks,
|
|
118
|
+
* and a session that spawned 27 delegated agents will fire far more than four.
|
|
119
|
+
*/
|
|
120
|
+
const MAX_SUBAGENT_FILES = 8;
|
|
121
|
+
/**
|
|
122
|
+
* What `stampGaps` gets to keep, carved OUT of the send budget on `SessionEnd`
|
|
123
|
+
* — `CR-182`.
|
|
124
|
+
*
|
|
125
|
+
* ## The defect, and why it is "starved" rather than "never runs"
|
|
126
|
+
*
|
|
127
|
+
* `stampGaps` is called AFTER the send loop, and the watchdog fires at
|
|
128
|
+
* `budgetMs` regardless. The loop's own arithmetic does leave it something: each
|
|
129
|
+
* attempt takes 0.6 of what REMAINS, so the last one starts with at least
|
|
130
|
+
* `MIN_SUBAGENT_BUDGET_MS` and returns with 0.4 of that — 160 ms — still on the
|
|
131
|
+
* clock. But that 160 ms is measured from `startedAt`, which is the settle, and
|
|
132
|
+
* **the watchdog is measured from `runHook`**. Everything before the settle —
|
|
133
|
+
* the stdin read, the consent gate's `git rev-parse`, the credential load — is
|
|
134
|
+
* time the loop never sees and the watchdog already spent. `CR-126` measured a
|
|
135
|
+
* comparable pre-entry interval at p99 392 ms idle and 184 ms under load, so the
|
|
136
|
+
* 160 ms cushion is routinely already gone.
|
|
137
|
+
*
|
|
138
|
+
* MEASURED against the unmodified client (12 delegated files, hung ingest
|
|
139
|
+
* server, `SessionEnd`): idle, every run stamped. Under CPU saturation, a
|
|
140
|
+
* 2200 ms budget produced **one state cell and zero gaps** — the loop's last
|
|
141
|
+
* send overshot and the process was cut off before it could record anything.
|
|
142
|
+
* That is D56 §D8's quietly-short record, which is the one outcome this client
|
|
143
|
+
* may not produce.
|
|
144
|
+
*
|
|
145
|
+
* ## Why 150 ms, and why a fixed number is honest here
|
|
146
|
+
*
|
|
147
|
+
* `stampGaps` is pure synchronous fs — one `loadSessionState` + `saveSessionState`
|
|
148
|
+
* pair per undelivered stream, bounded by `MAX_SUBAGENT_FILES + 1` (9) — with no
|
|
149
|
+
* network, no spawn and no timer in it. So unlike a send, its cost does not
|
|
150
|
+
* depend on anything outside this process, and a fixed reserve is a real bound
|
|
151
|
+
* rather than a guess dressed as one. 150 ms is ~17 ms per stream at the cap on
|
|
152
|
+
* a machine where the whole 9-file loop measures under 5 ms, i.e. an order of
|
|
153
|
+
* magnitude of margin over the effect — the same ratio `SESSION_END_SETTLE_DELAY_MS`
|
|
154
|
+
* is set at, and small enough that it costs the send 90 ms of 2850 on the
|
|
155
|
+
* default budget.
|
|
156
|
+
*
|
|
157
|
+
* ⚠ **A mitigation, not a guarantee.** Enough scheduler pressure defeats any
|
|
158
|
+
* fixed reserve, exactly as it defeats the watchdog itself. What this buys is
|
|
159
|
+
* that the ordinary slow-server case — the one Codex's 3 s cap makes routine —
|
|
160
|
+
* stops eating the gap record.
|
|
161
|
+
*
|
|
162
|
+
* ⛔ Zero on every other event: `stampGaps` runs on `SessionEnd` alone, so
|
|
163
|
+
* reserving elsewhere would shrink `Stop`'s send for a stamp that never happens.
|
|
164
|
+
*/
|
|
165
|
+
const GAP_STAMP_RESERVE_MS = 150;
|
|
166
|
+
/**
|
|
167
|
+
* The budget the SENDS may spend — the whole budget, less the stamp's share.
|
|
168
|
+
*
|
|
169
|
+
* A function, and exported, for the reason `sendTimeoutMs` is: the carve-out is
|
|
170
|
+
* the thing this task changes, and a wall-clock test cannot pin it. Every
|
|
171
|
+
* discriminator that distinguishes reserve from no-reserve through a real
|
|
172
|
+
* process turned out to slide with per-send overhead — a 149 ms window that
|
|
173
|
+
* moves by 25 ms for every 10 ms of connect-and-compress cost — so pinning it
|
|
174
|
+
* there would be tuning a constant to one machine. Here it is arithmetic.
|
|
175
|
+
*
|
|
176
|
+
* ⛔ `budgetMs` itself still governs the SETTLE and the WATCHDOG. Only the sends
|
|
177
|
+
* are carved.
|
|
178
|
+
*/
|
|
179
|
+
export function sendBudgetMs(budgetMs, eventName) {
|
|
180
|
+
return budgetMs - (eventName === SESSION_END_EVENT ? GAP_STAMP_RESERVE_MS : 0);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Parse hook stdin. Claude Code supplies `{ session_id, transcript_path, cwd,
|
|
184
|
+
* hook_event_name }` on stdin — NOT in the environment.
|
|
185
|
+
*
|
|
186
|
+
* ⚠ **And an `announce` dialect adds `agent_transcript_path` (`CR-196`).** It is
|
|
187
|
+
* OPTIONAL in both directions: a `discover` install never sends it, and reading
|
|
188
|
+
* it here rather than in a Cursor-specific parser is deliberate — stdin has one
|
|
189
|
+
* shape and one parser, and a second one would be a second place for the
|
|
190
|
+
* confinement boundary to be forgotten.
|
|
191
|
+
*
|
|
192
|
+
* Returns null on anything malformed. A hook cannot report a parse error (stderr
|
|
193
|
+
* is empty by contract), so the only honest response is to do nothing. ⚠ A
|
|
194
|
+
* malformed `agent_transcript_path` is NOT malformed input: it is dropped and
|
|
195
|
+
* the main transcript still goes, because losing the whole capture over a
|
|
196
|
+
* delegated extra would be the larger loss.
|
|
197
|
+
*/
|
|
198
|
+
export function parseHookInput(raw) {
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(raw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
207
|
+
return null;
|
|
208
|
+
const o = parsed;
|
|
209
|
+
const sessionId = o.session_id;
|
|
210
|
+
const transcriptPath = o.transcript_path;
|
|
211
|
+
const cwd = o.cwd;
|
|
212
|
+
const eventName = o.hook_event_name;
|
|
213
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
214
|
+
return null;
|
|
215
|
+
if (typeof transcriptPath !== "string" || transcriptPath === "")
|
|
216
|
+
return null;
|
|
217
|
+
const announced = o.agent_transcript_path;
|
|
218
|
+
return {
|
|
219
|
+
sessionId,
|
|
220
|
+
transcriptPath,
|
|
221
|
+
cwd: typeof cwd === "string" && cwd !== "" ? cwd : process.cwd(),
|
|
222
|
+
eventName: typeof eventName === "string" ? eventName : "",
|
|
223
|
+
...(typeof announced === "string" && announced !== ""
|
|
224
|
+
? { agentTranscriptPath: announced }
|
|
225
|
+
: {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Replace stderr with a sink for the rest of the process.
|
|
230
|
+
*
|
|
231
|
+
* Node writes uncaught exceptions, unhandled rejections and process warnings to
|
|
232
|
+
* `process.stderr`, so overriding `write` is what makes the contract hold on the
|
|
233
|
+
* paths we do not control. The handlers below stop the non-zero exit; this stops
|
|
234
|
+
* the bytes.
|
|
235
|
+
*/
|
|
236
|
+
export function silenceStderr() {
|
|
237
|
+
const sink = (_chunk, encodingOrCallback, callback) => {
|
|
238
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
239
|
+
if (typeof done === "function")
|
|
240
|
+
done();
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
// The cast is unavoidable: `Writable.write` is overloaded and the sink
|
|
244
|
+
// satisfies every overload's contract without matching any one signature.
|
|
245
|
+
process.stderr.write = sink;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Write the single stdout payload and exit 0.
|
|
249
|
+
*
|
|
250
|
+
* `writeSync(1, …)` rather than `process.stdout.write`: stdout to a pipe is
|
|
251
|
+
* asynchronous, and `process.exit` does not flush it. A `systemMessage` lost to a
|
|
252
|
+
* truncated write is the silent-unconfigured failure DX3 exists to kill.
|
|
253
|
+
*/
|
|
254
|
+
function finish(payload) {
|
|
255
|
+
if (payload !== null) {
|
|
256
|
+
try {
|
|
257
|
+
writeSync(1, `${payload}\n`);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Silence is the fallback, always.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
process.exit(EXIT.ok);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Install every contract guard, then run the body.
|
|
267
|
+
*
|
|
268
|
+
* Guards go in FIRST, before any work, because the paths they cover include
|
|
269
|
+
* "the body threw on its first line".
|
|
270
|
+
*/
|
|
271
|
+
export async function runHook(ctx) {
|
|
272
|
+
silenceStderr();
|
|
273
|
+
const budgetMs = hookBudgetMs(ctx.env, ctx.agentId);
|
|
274
|
+
// The watchdog covers the wall-clock clause on paths no `await` can reach: a
|
|
275
|
+
// socket that never settles, a promise that never resolves. `unref` so it
|
|
276
|
+
// never itself keeps the process alive.
|
|
277
|
+
const watchdog = setTimeout(() => finish(null), budgetMs);
|
|
278
|
+
watchdog.unref();
|
|
279
|
+
// ⛔ AND THE HALF THE WATCHDOG CANNOT COVER (`TODOS[87]`). A timer cannot fire
|
|
280
|
+
// while the event loop is BLOCKED, and `execFileSync` blocks it by definition
|
|
281
|
+
// — so a git binary that never returns is an unbounded hang, not a slow hook,
|
|
282
|
+
// and no amount of watchdog fixes it. Armed HERE, from the same `budgetMs`, so
|
|
283
|
+
// the two halves of one bound cannot drift apart.
|
|
284
|
+
startSpawnBudget(budgetMs);
|
|
285
|
+
process.on("uncaughtException", () => finish(null));
|
|
286
|
+
process.on("unhandledRejection", () => finish(null));
|
|
287
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
288
|
+
process.on(signal, () => finish(null));
|
|
289
|
+
}
|
|
290
|
+
let payload = null;
|
|
291
|
+
try {
|
|
292
|
+
payload = await hookBody(ctx, budgetMs);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
payload = null;
|
|
296
|
+
}
|
|
297
|
+
finish(payload);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The wall clock this invocation gets.
|
|
301
|
+
*
|
|
302
|
+
* ⛔ **THE TIGHTEST BUDGET THE DIALECT REGISTERS, and the reason is the ORDER
|
|
303
|
+
* things happen in `runHook`** — the watchdog is armed BEFORE stdin is read, so
|
|
304
|
+
* the event name is not known yet and a per-event budget cannot be applied here
|
|
305
|
+
* without re-arming the guard. Taking the minimum is the direction that is safe
|
|
306
|
+
* to be wrong in: it can only finish early, never overrun a registered timeout
|
|
307
|
+
* and let the agent kill the process instead.
|
|
308
|
+
*
|
|
309
|
+
* ⚠ **Claude Code is unchanged, arithmetically** — its three events all carry
|
|
310
|
+
* `DEFAULT_HOOK_BUDGET_MS`, so the minimum IS that constant. What this costs is
|
|
311
|
+
* an agent whose events differ: it runs every event at its tightest one, which
|
|
312
|
+
* under-uses the wide ones. Stated rather than smuggled; widening it means
|
|
313
|
+
* re-arming the watchdog after the parse, which is its own change.
|
|
314
|
+
*
|
|
315
|
+
* `VIBECOMMIT_HOOK_TIMEOUT_MS` still wins outright, as it always has.
|
|
316
|
+
*/
|
|
317
|
+
export function hookBudgetMs(env, agentId) {
|
|
318
|
+
const raw = env.VIBECOMMIT_HOOK_TIMEOUT_MS;
|
|
319
|
+
if (raw === undefined)
|
|
320
|
+
return dialectBudgetMs(agentId);
|
|
321
|
+
const parsed = Number(raw);
|
|
322
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : dialectBudgetMs(agentId);
|
|
323
|
+
}
|
|
324
|
+
/** The smallest `clientBudgetMs` across a dialect's events. */
|
|
325
|
+
function dialectBudgetMs(agentId) {
|
|
326
|
+
const budgets = Object.values(dialectFor(agentId).events).map((e) => e.clientBudgetMs);
|
|
327
|
+
return Math.min(...budgets);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The hook body. Returns the stdout payload, or null for silence.
|
|
331
|
+
*
|
|
332
|
+
* Ordering is load-bearing in two places:
|
|
333
|
+
* 1. the Node floor is checked FIRST, because everything below it may use
|
|
334
|
+
* syntax or APIs the old runtime lacks;
|
|
335
|
+
* 2. the CONSENT GATE runs BEFORE `transcript_path` is opened (D56 §D19). A
|
|
336
|
+
* gate that runs after the read has already read the thing it was gating.
|
|
337
|
+
*/
|
|
338
|
+
async function hookBody(ctx, budgetMs) {
|
|
339
|
+
const raw = await ctx.readStdin();
|
|
340
|
+
const input = parseHookInput(raw);
|
|
341
|
+
if (input === null)
|
|
342
|
+
return null;
|
|
343
|
+
// `repoKey` is passed per call rather than captured, because the three
|
|
344
|
+
// session-keyed states can fire BEFORE a project has been resolved at all —
|
|
345
|
+
// `unsupported_runtime` fires before anything else runs. Only
|
|
346
|
+
// `capture_confirmed` needs one, and only it supplies one.
|
|
347
|
+
const notice = (state, repoKey = null) => renderNotice({
|
|
348
|
+
home: ctx.home,
|
|
349
|
+
sessionId: input.sessionId,
|
|
350
|
+
nodeVersion: ctx.nodeVersion,
|
|
351
|
+
repoKey,
|
|
352
|
+
now: () => new Date(),
|
|
353
|
+
}, state);
|
|
354
|
+
if (!meetsNodeFloor(ctx.nodeVersion))
|
|
355
|
+
return notice("unsupported_runtime");
|
|
356
|
+
// --- CONSENT GATE. Nothing above this line has opened the transcript. ---
|
|
357
|
+
// The null arm is not new behaviour — `isProjectAllowed(home, null)` is already
|
|
358
|
+
// false, so a `cwd` outside a work tree already stopped here. It is stated
|
|
359
|
+
// separately because `CR-017d` makes this value half the delivery key as well
|
|
360
|
+
// as the consent key, and a key the compiler cannot prove is present is one
|
|
361
|
+
// some later edit supplies a placeholder for.
|
|
362
|
+
//
|
|
363
|
+
// ⛔ THE GATE CHECKS `keys.consent` — THE GIT COMMON DIR (`D184 §1`).
|
|
364
|
+
//
|
|
365
|
+
// It is shared by a main clone and every worktree linked to it, which is the
|
|
366
|
+
// whole point. Consent was keyed on `--show-toplevel`, that differs per
|
|
367
|
+
// worktree, and absence means no — so an agent session inside a `git worktree`
|
|
368
|
+
// captured NOTHING, silently, under the house convention (`D19`) of one
|
|
369
|
+
// worktree per builder.
|
|
370
|
+
//
|
|
371
|
+
// ⛔⛔ AND THIS IS ONE OF FOUR SITES, ALL OF WHICH NOW AGREE. `D184 §9`: this
|
|
372
|
+
// READ gate, `post_commit.ts` (the COMMIT gate), `commands/connect.ts`
|
|
373
|
+
// (`grantProject`, the WRITE side and the ONLY writer of the allow list) and
|
|
374
|
+
// `commands/status.ts` (`revokeProject`, the REVOKE side). If any one of them
|
|
375
|
+
// keyed on the toplevel while the others used the common dir, the allow list
|
|
376
|
+
// would never match: the null arm below returns silently BY DESIGN, so every
|
|
377
|
+
// session in every repository would capture nothing, with no notice — strictly
|
|
378
|
+
// worse than the worktree blackout this change exists to fix.
|
|
379
|
+
//
|
|
380
|
+
// ⚠ AND IT WOULD FAIL GREEN. The suite's fixtures hand-write `projects.json`,
|
|
381
|
+
// deliberately, so every cell agrees with this gate whichever key both use —
|
|
382
|
+
// the asymmetry is only visible END TO END, through the real `connect` path
|
|
383
|
+
// and a real session in a real worktree. `test/consent-key-end-to-end.test.ts`
|
|
384
|
+
// is that cell, and under a mutation that grants the toplevel here **135
|
|
385
|
+
// existing cells stay green and only those two red**.
|
|
386
|
+
//
|
|
387
|
+
// ⛔ THAT CELL ASSERTS CONTENT, NOT ONLY ADMISSION, and the distinction is
|
|
388
|
+
// `D184 §3`: a session admitted by this gate whose confinement root set has
|
|
389
|
+
// collapsed delivers a payload of redaction markers. The request arrives, the
|
|
390
|
+
// byte accounting is intact, and the content is gone. Anything asserting only
|
|
391
|
+
// that a worktree session was admitted passes on an empty payload.
|
|
392
|
+
//
|
|
393
|
+
// ⚠ `resolveProjectKeys` is called once, not twice: the redaction root set
|
|
394
|
+
// needs the common dir too, and a second resolution could disagree with the
|
|
395
|
+
// one consent was checked against. It is stricter than `resolveProjectKey` in
|
|
396
|
+
// one way — the common dir must also resolve and hold a `HEAD` — which fails
|
|
397
|
+
// closed, silently, exactly as this gate already does.
|
|
398
|
+
const keys = resolveProjectKeys(input.cwd);
|
|
399
|
+
if (keys === null || !isProjectAllowed(ctx.home, keys.consent)) {
|
|
400
|
+
// Unconsented is not "not connected": say nothing at all. A machine with no
|
|
401
|
+
// credential says state 1 below; a repo the user declined stays silent,
|
|
402
|
+
// because nagging about a decision the user already made is the tool noise
|
|
403
|
+
// DX3's one-line rule exists to avoid.
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
// The THREE roles that never move (`D184 §1`) — the session-state bucket
|
|
407
|
+
// (`D58`/`CR-017d`), the `resolveRepoSlug` input, and a directory to run git
|
|
408
|
+
// in — under the name every site below already uses for them. Renaming them
|
|
409
|
+
// would be churn across a file this change has no other reason to touch, and
|
|
410
|
+
// `report.ts:343` is the standing proof that the NAME is not what tells you
|
|
411
|
+
// which role a site wants.
|
|
412
|
+
const projectKey = keys.worktree;
|
|
413
|
+
const load = loadCredential({ env: ctx.env, home: ctx.home });
|
|
414
|
+
switch (load.kind) {
|
|
415
|
+
case "absent":
|
|
416
|
+
return notice("not_connected");
|
|
417
|
+
case "wrong-class":
|
|
418
|
+
case "insecure-file":
|
|
419
|
+
case "unreadable":
|
|
420
|
+
// Interactive verbs print four different fixes for these (§13.6). A hook
|
|
421
|
+
// has one channel and a one-line budget, so they collapse to the state
|
|
422
|
+
// whose fix — `vibecommit connect` — is the same for all four.
|
|
423
|
+
return notice("not_connected");
|
|
424
|
+
case "ok":
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
// --- PATH CONFINEMENT. `/cso` finding 1, HIGH. ---
|
|
428
|
+
// `transcript_path` is attacker-influenced in the one way that matters: it
|
|
429
|
+
// reaches a read whose result is uploaded. Confining it to the directories the
|
|
430
|
+
// agents in the registry actually write transcripts to is what stops this verb
|
|
431
|
+
// being a silent arbitrary-file exfiltrator. Silent refusal, per the contract.
|
|
432
|
+
//
|
|
433
|
+
// ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5).
|
|
434
|
+
// The union is deliberately independent of `ctx.agentId`: the flag comes from
|
|
435
|
+
// an install that may have got it wrong, and a wrong SELECTION must not be able
|
|
436
|
+
// to widen the boundary — at most it picks a suboptimal redaction profile.
|
|
437
|
+
//
|
|
438
|
+
// ⛔ AND THE FILE GATE, ADDED HERE BY `CR-193` (D177 §2). The boundary answers
|
|
439
|
+
// "may this path be read"; it does not answer "is this a transcript", and
|
|
440
|
+
// under Cursor's root those are different questions — `find -type f` there
|
|
441
|
+
// returns 113 files and exactly ONE is a transcript. A narrowing over an
|
|
442
|
+
// already-computed boundary, so it can only refuse. See `admitsTranscript`.
|
|
443
|
+
if (!admitsTranscript(ctx.home, ctx.env, input.transcriptPath))
|
|
444
|
+
return null;
|
|
445
|
+
const url = ingestUrl(ctx.env);
|
|
446
|
+
// `/cso` finding 2: a rejected override is a silent no-send, not a fallback to
|
|
447
|
+
// the default. Falling back would send the credential somewhere the operator
|
|
448
|
+
// did not ask for, which is the opposite of what the check is for.
|
|
449
|
+
if (url === null)
|
|
450
|
+
return null;
|
|
451
|
+
// CR-016's bootstrap re-sent the whole file from byte 0 every time. CR-018
|
|
452
|
+
// replaces it, because `later` (hold the offset) and `never` (advance past the
|
|
453
|
+
// failing bytes) are both defined in terms of an offset and mean nothing
|
|
454
|
+
// without one. `CR-017d` widened the key that offset is kept under to
|
|
455
|
+
// `(repo, session)`; `CR-124` (W6) owns globbing
|
|
456
|
+
// `<session-dir>/subagents/agent-*.jsonl` — this sends the `main` file only.
|
|
457
|
+
//
|
|
458
|
+
// ⚠ The attribution above said `CR-020` until `CR-020d`. It was wrong, and D76
|
|
459
|
+
// says so by name: `CR-020`'s finding (D57 §DX7) is SessionEnd timing only and
|
|
460
|
+
// says nothing about sub-agent files. Corrected in passing rather than left to
|
|
461
|
+
// send the next reader after work that lives in another task.
|
|
462
|
+
//
|
|
463
|
+
// `SessionEnd` SETTLES FIRST (D57 §DX7). Every other event has a next
|
|
464
|
+
// invocation to pick up a lagging write; this one does not, so bytes missed
|
|
465
|
+
// here are the final turn, lost permanently.
|
|
466
|
+
const isSessionEnd = input.eventName === SESSION_END_EVENT;
|
|
467
|
+
const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
|
|
468
|
+
// `CR-182`. Carved OUT of the budget, never added on top — the same shape the
|
|
469
|
+
// settle takes, and for the same reason: the watchdog does not move.
|
|
470
|
+
const sendBudget = sendBudgetMs(budgetMs, input.eventName);
|
|
471
|
+
const settleStart = Date.now();
|
|
472
|
+
const eof = settleFor > 0
|
|
473
|
+
? await settledSize(() => fileSize(input.transcriptPath), wait, settleFor)
|
|
474
|
+
: fileSize(input.transcriptPath);
|
|
475
|
+
if (eof === null)
|
|
476
|
+
return null;
|
|
477
|
+
// MEASURED, not assumed to be `settleFor`: the wait can overshoot under load,
|
|
478
|
+
// and the budget cares about the time actually spent.
|
|
479
|
+
const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
|
|
480
|
+
// The WIRE identity — `owner/repo` when `origin` is GitHub, `host/owner/repo`
|
|
481
|
+
// for any other host, else `local:<hash>`. Required by the merged server
|
|
482
|
+
// (`CR-019b`), which 400s a delta without it rather than writing a row nobody
|
|
483
|
+
// can read (D57 §OV1).
|
|
484
|
+
//
|
|
485
|
+
// ⛔ RESOLVED EXACTLY ONCE PER HOOK (`TODOS[87]`). It spawns
|
|
486
|
+
// `git remote get-url origin`, and it used to be called here AND once per
|
|
487
|
+
// sub-agent file — up to nine spawns for a value that cannot change within an
|
|
488
|
+
// invocation, since `projectKey` is fixed by the consent gate above. Passing
|
|
489
|
+
// it down rather than re-deriving it is also what keeps the two paths from
|
|
490
|
+
// ever disagreeing about which repository this is.
|
|
491
|
+
const repoSlug = resolveRepoSlug(projectKey);
|
|
492
|
+
// ⛔ THE ONE NEW SPAWN `CR-170` ADDS TO THIS PATH, and it is for REF MOVES,
|
|
493
|
+
// not commits. `post-commit` observes commits; it cannot see a `pull`, a
|
|
494
|
+
// `checkout` or a `reset --hard`, because those move a ref WITHOUT creating a
|
|
495
|
+
// commit and no commit hook fires. A direct look at `HEAD` is the only record.
|
|
496
|
+
//
|
|
497
|
+
// ⚠ It shares `CR-169`'s spawn budget like every other probe, so a hung git
|
|
498
|
+
// here is bounded and cannot defeat the watchdog. Null on an empty repository
|
|
499
|
+
// (`rev-parse HEAD` exits 128 there) — a state, not an error.
|
|
500
|
+
const head = headRef(projectKey);
|
|
501
|
+
// Commits OBSERVED since the last delivery. Capped, with the remainder left in
|
|
502
|
+
// the spool for the next hook rather than dropped.
|
|
503
|
+
const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
|
|
504
|
+
const spooled = capSpool(readSpool(ctx.home, spoolKey));
|
|
505
|
+
// ⛔ REWRITES ARE THEIR OWN FILE WITH THEIR OWN CAP (`T5`). The units differ —
|
|
506
|
+
// 41 bytes for a sha, 82 for an `ancestor:successor` pair — so one constant
|
|
507
|
+
// sized against the other would make one of the two headers wrong.
|
|
508
|
+
const rewritten = capSuccessors(readRewrites(ctx.home, spoolKey));
|
|
509
|
+
// ⛔ THE REDACTION BOUNDARY, and it is NOT the consent key (`D184 §3`).
|
|
510
|
+
// Resolved here rather than at the gate so the derivation's one spawn is not
|
|
511
|
+
// paid by the invocations that return above without reading a byte.
|
|
512
|
+
const redactionRoots = confinementRoots(keys);
|
|
513
|
+
const delivery = await deliver({
|
|
514
|
+
home: ctx.home,
|
|
515
|
+
env: ctx.env,
|
|
516
|
+
url,
|
|
517
|
+
credential: load.credential,
|
|
518
|
+
// The consent gate's key, reused rather than re-resolved: a second
|
|
519
|
+
// `git rev-parse` could disagree with the one consent was checked against.
|
|
520
|
+
repoKey: projectKey,
|
|
521
|
+
repoSlug,
|
|
522
|
+
sessionId: input.sessionId,
|
|
523
|
+
fileKey: "main",
|
|
524
|
+
// ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that
|
|
525
|
+
// contains it (`CR-204`, D177 §7). Deliberately the same expression the
|
|
526
|
+
// `readBody` closure below takes, so the bytes and the producer can never
|
|
527
|
+
// describe two different files.
|
|
528
|
+
transcriptPath: input.transcriptPath,
|
|
529
|
+
// ⛔ `sendBudget`, not `budgetMs`. The main send is the single largest
|
|
530
|
+
// consumer on this path, so a reserve the loop honours and this does not
|
|
531
|
+
// is one the biggest spender walks straight through (`CR-182`).
|
|
532
|
+
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
533
|
+
nowMs: Date.now(),
|
|
534
|
+
// ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries below leave all three
|
|
535
|
+
// unset: each is its own `capture_id`, and `capture_commits`' PK is
|
|
536
|
+
// `(org_id, capture_id, commit_sha)`, so attaching these to all nine
|
|
537
|
+
// deliveries would admit nine legal, permanent rows for one commit — the
|
|
538
|
+
// objection that killed the polling design (D154).
|
|
539
|
+
head,
|
|
540
|
+
// ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`). The shas and
|
|
541
|
+
// their rungs are only safe as a pair — `parseObservedCommits` drops the
|
|
542
|
+
// ENTIRE BATCH when the two lists differ in length — and `count` is the
|
|
543
|
+
// number of spool LINES consumed, which is what the 2xx truncates by. A
|
|
544
|
+
// held entry is consumed without being sent, so `shas.length` is the wrong
|
|
545
|
+
// number for that and passing the pieces separately is how they diverge.
|
|
546
|
+
commits: spooled,
|
|
547
|
+
rewrites: rewritten.pairs,
|
|
548
|
+
}, eof,
|
|
549
|
+
// ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED.
|
|
550
|
+
// Those were one value until `D184`, and this line is where the difference
|
|
551
|
+
// becomes observable: redaction is measured against `[worktree,
|
|
552
|
+
// mainToplevel]`, never against the git common dir. If it followed the
|
|
553
|
+
// consent key there, `isInside` would fail-close every file of an external
|
|
554
|
+
// worktree to "outside the main clone" and hand the server a span of
|
|
555
|
+
// markers — a capture that succeeds, with turn counts that look right and
|
|
556
|
+
// no content in it (`D184 §3`).
|
|
557
|
+
(from, to) => readSpan(input.transcriptPath, from, to, redactionRoots, ctx.home, ctx.env));
|
|
558
|
+
// --- DELEGATED WORK. `CR-124`, D76. ---
|
|
559
|
+
// Claude Code writes sub-agent transcripts to `<session>/subagents/`, which
|
|
560
|
+
// `transcript_path` never names: ~16% of transcript volume, and the work a
|
|
561
|
+
// reviewer most wants attributed. MAIN WENT FIRST, deliberately (D95) — the
|
|
562
|
+
// content most likely to matter is the content most likely to fit.
|
|
563
|
+
const delegated = await deliverSubagents({
|
|
564
|
+
ctx,
|
|
565
|
+
input,
|
|
566
|
+
url,
|
|
567
|
+
credential: load.credential,
|
|
568
|
+
projectKey,
|
|
569
|
+
redactionRoots,
|
|
570
|
+
repoSlug,
|
|
571
|
+
startedAt: settleStart,
|
|
572
|
+
sendBudget,
|
|
573
|
+
});
|
|
574
|
+
// --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
|
|
575
|
+
//
|
|
576
|
+
// ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE. `markSkipped` sets
|
|
577
|
+
// `sentOffset = to`, so stamping first would leave `nextSpan` nothing to
|
|
578
|
+
// return for any stream — it would gap the whole session and send none of it.
|
|
579
|
+
//
|
|
580
|
+
// ⛔ AND IT CANNOT HANG OFF `finish()`, which the signal handlers reach and
|
|
581
|
+
// which must stay minimal.
|
|
582
|
+
//
|
|
583
|
+
// It sits here rather than inside `deliverSubagents` because that function
|
|
584
|
+
// returns early when there are no delegated files at all — and the MAIN
|
|
585
|
+
// transcript's gap must not be conditional on a `subagents/` directory
|
|
586
|
+
// existing. `eof`, not a fresh `statSync`: the gap records what this
|
|
587
|
+
// invocation COMMITTED to sending and could not, which is exactly what
|
|
588
|
+
// `SubagentFile.size` is for the delegated streams.
|
|
589
|
+
if (isSessionEnd) {
|
|
590
|
+
stampGaps(ctx, input, projectKey, [{ fileKey: "main", size: eof }, ...delegated]);
|
|
591
|
+
}
|
|
592
|
+
// The three classes are decided in `policy.ts` and applied in `post.ts`. Two
|
|
593
|
+
// of the four outcomes reach a human.
|
|
594
|
+
//
|
|
595
|
+
// `fatal`: a hook that stays silent about a revoked credential captures
|
|
596
|
+
// nothing forever. `later` and `never` say nothing — a hook has one line per
|
|
597
|
+
// session and spending it on a condition that resolves itself next turn is the
|
|
598
|
+
// tool noise D57 §DX3 exists to avoid.
|
|
599
|
+
//
|
|
600
|
+
// `ok`: the fourth state (`CR-023d`), and the FIRST time this hook has ever
|
|
601
|
+
// spoken on success. Its `(repo, day)` claim is what keeps it to once — no
|
|
602
|
+
// "have I already said this today" check belongs here, because the atomic `wx`
|
|
603
|
+
// claim already is that check, and a second one could disagree with it.
|
|
604
|
+
if (delivery.kind === "attempted") {
|
|
605
|
+
if (delivery.disposition === "fatal")
|
|
606
|
+
return notice("credential_revoked");
|
|
607
|
+
if (delivery.disposition === "ok")
|
|
608
|
+
return notice("capture_confirmed", projectKey);
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Send whatever delegated transcripts fit in what is left of the budget.
|
|
614
|
+
*
|
|
615
|
+
* ## Why this is bounded and not a loop over the directory
|
|
616
|
+
*
|
|
617
|
+
* `entry.ts` gets ONE wall clock (DESIGN.md §13.7) and the watchdog cuts the
|
|
618
|
+
* process off at `budgetMs` regardless. A real session on this machine has 27
|
|
619
|
+
* sub-agent files; twenty-seven sends cannot each have a share of five seconds.
|
|
620
|
+
* D95 settles the shape: one MEASURED deadline, the running elapsed handed to
|
|
621
|
+
* `sendTimeoutMs` so file N gets the real remainder, and a floor below which no
|
|
622
|
+
* new file is started.
|
|
623
|
+
*
|
|
624
|
+
* Nothing is lost by stopping early. An unattempted file's offset does not
|
|
625
|
+
* advance, so the next hook picks it up — the same property `CR-018`'s failure
|
|
626
|
+
* classes already rely on.
|
|
627
|
+
*
|
|
628
|
+
* **Except on `SessionEnd`, where there is no next hook.** There, an undelivered
|
|
629
|
+
* file STAMPS A GAP (D95): the offset advances and the hole is recorded, because
|
|
630
|
+
* a record that says what it could not send is honest and one that is quietly
|
|
631
|
+
* short is not. That is D56 §D8's own shape, applied to the one event where
|
|
632
|
+
* advance-regardless would otherwise lose the bytes silently. The stamp itself
|
|
633
|
+
* is the CALLER's, not this function's (`CR-182`) — it covers the main
|
|
634
|
+
* transcript too, and must therefore happen whether or not this ran.
|
|
635
|
+
*
|
|
636
|
+
* Serial, never `Promise.all`: each `deliver()` does load-modify-save on one
|
|
637
|
+
* JSON file and claims `session.seq + 1`, so concurrent calls would lose seq
|
|
638
|
+
* updates to each other and the server would read a replay.
|
|
639
|
+
*
|
|
640
|
+
* Returns every delegated file it DISCOVERED, attempted or not — the caller
|
|
641
|
+
* stamps against that set, and an unattempted file is precisely the one most in
|
|
642
|
+
* need of a hole.
|
|
643
|
+
*/
|
|
644
|
+
async function deliverSubagents(opts) {
|
|
645
|
+
const { ctx, input, url, credential, projectKey, redactionRoots, repoSlug, startedAt, sendBudget } = opts;
|
|
646
|
+
// ⛔ TWO MECHANISMS, NEVER BOTH AT ONCE — `CR-196`, D177 §9. Each of these
|
|
647
|
+
// returns `[]` unless the CONTAINING ROOT's dialect declares its own
|
|
648
|
+
// mechanism, so the concatenation is a union of two disjoint sets rather than
|
|
649
|
+
// a fallback chain. Written this way so `findSubagentFiles` is byte-unchanged
|
|
650
|
+
// and `CR-204`'s root-derived gating (pinned by `test/wire-agent.test.ts` G4)
|
|
651
|
+
// keeps its meaning.
|
|
652
|
+
const files = [
|
|
653
|
+
...findSubagentFiles(ctx, input.transcriptPath),
|
|
654
|
+
...announcedSubagentFiles(ctx, input),
|
|
655
|
+
];
|
|
656
|
+
if (files.length === 0)
|
|
657
|
+
return files;
|
|
658
|
+
// ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
|
|
659
|
+
// optimisation, it is what stops the tail starving.
|
|
660
|
+
//
|
|
661
|
+
// The order is by mtime, which is STABLE: delivering a file does not touch it.
|
|
662
|
+
// So a cap counting ATTEMPTS re-attempts the same eight oldest files on every
|
|
663
|
+
// invocation, each returning `nothing-to-send`, and files nine and up are
|
|
664
|
+
// never reached at all. Caught by the test that runs two invocations and
|
|
665
|
+
// expects the second to make progress; it did not.
|
|
666
|
+
//
|
|
667
|
+
// Filtering on pending bytes makes the cap count WORK rather than calls, so
|
|
668
|
+
// each invocation starts where the last one stopped.
|
|
669
|
+
const key = { repoKey: projectKey, sessionId: input.sessionId };
|
|
670
|
+
const before = loadSessionState(ctx.home, key);
|
|
671
|
+
const pending = files.filter((f) => fileState(before, f.fileKey).sentOffset < f.size);
|
|
672
|
+
if (pending.length === 0)
|
|
673
|
+
return files;
|
|
674
|
+
for (const file of pending.slice(0, MAX_SUBAGENT_FILES)) {
|
|
675
|
+
const consumedMs = Date.now() - startedAt;
|
|
676
|
+
if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
677
|
+
break;
|
|
678
|
+
await deliver({
|
|
679
|
+
home: ctx.home,
|
|
680
|
+
env: ctx.env,
|
|
681
|
+
url,
|
|
682
|
+
credential,
|
|
683
|
+
repoKey: projectKey,
|
|
684
|
+
repoSlug,
|
|
685
|
+
sessionId: input.sessionId,
|
|
686
|
+
fileKey: file.fileKey,
|
|
687
|
+
// ⛔ THE DELEGATED FILE'S OWN PATH, not the main transcript's. Each
|
|
688
|
+
// sub-agent stream is its own `capture_id` and its own row; the root
|
|
689
|
+
// that contains IT is what names its producer.
|
|
690
|
+
transcriptPath: file.path,
|
|
691
|
+
// The remainder, MEASURED. This is what keeps N files inside one budget.
|
|
692
|
+
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
693
|
+
nowMs: Date.now(),
|
|
694
|
+
}, file.size,
|
|
695
|
+
// The SAME reader as the main transcript, so `CR-024d`'s redaction and
|
|
696
|
+
// `/cso` finding 1's confinement both apply. A delegated agent reading a
|
|
697
|
+
// third party's file is exactly as out-of-tree as the main thread doing it.
|
|
698
|
+
(from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env));
|
|
699
|
+
}
|
|
700
|
+
return files;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* On `SessionEnd`, record what could not be sent rather than losing it quietly.
|
|
704
|
+
*
|
|
705
|
+
* Covers both the files never started and the ones whose send was held: after
|
|
706
|
+
* this event there is no invocation to retry either, so both are permanently
|
|
707
|
+
* undelivered and both deserve the same honest hole.
|
|
708
|
+
*
|
|
709
|
+
* ## The MAIN transcript is one of them now — `CR-182`
|
|
710
|
+
*
|
|
711
|
+
* D95 scoped this to `subagents/` and its own note recorded the cost: *"the main
|
|
712
|
+
* transcript's own failed send on `SessionEnd` is equally permanent and stamps
|
|
713
|
+
* nothing."* That note is load-bearing rather than tidy, because the span caps
|
|
714
|
+
* make the main file's remainder something a single `SessionEnd` routinely
|
|
715
|
+
* cannot finish — so the widest, most-read stream was the one going quietly
|
|
716
|
+
* short.
|
|
717
|
+
*
|
|
718
|
+
* ⚠ **WHAT THIS COSTS, stated rather than smuggled.** `stampGaps` decides on
|
|
719
|
+
* `sentOffset < size` and cannot tell WHY a stream is behind, so the main
|
|
720
|
+
* transcript now inherits both cases D99 §1 measured for delegated streams: a
|
|
721
|
+
* `403 capture_not_approved` — which means org-approval-PENDING, i.e. later —
|
|
722
|
+
* and a `401` `fatal`, whose own comment in `post.ts` says the offset does not
|
|
723
|
+
* advance because *"these bytes are fine"*. On `SessionEnd` neither has a next
|
|
724
|
+
* invocation to be right about, so both are written off. Whether a refusal
|
|
725
|
+
* should stamp at all is `TODOS[93]`, still open and still not decided here;
|
|
726
|
+
* what changed is only that it now reaches `main` as well.
|
|
727
|
+
*/
|
|
728
|
+
function stampGaps(ctx, input, projectKey, files) {
|
|
729
|
+
const key = { repoKey: projectKey, sessionId: input.sessionId };
|
|
730
|
+
// Re-read per file rather than once: each `deliver()` above saved, so a
|
|
731
|
+
// snapshot taken before the loop would stamp gaps over work that succeeded.
|
|
732
|
+
for (const file of files) {
|
|
733
|
+
const session = loadSessionState(ctx.home, key);
|
|
734
|
+
const current = fileState(session, file.fileKey);
|
|
735
|
+
// The offset is the only thing that decides this, which is why no record of
|
|
736
|
+
// what was attempted is needed: a file the loop never reached and one whose
|
|
737
|
+
// send was held are both simply behind, and both are equally permanent now.
|
|
738
|
+
if (current.sentOffset >= file.size)
|
|
739
|
+
continue;
|
|
740
|
+
saveSessionState(ctx.home, key, withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Every `agent-*.jsonl` under this session's `subagents/`, oldest first.
|
|
745
|
+
*
|
|
746
|
+
* **`agent-*.jsonl`, never `agent-*`.** The directory carries an
|
|
747
|
+
* `agent-<id>.meta.json` beside every transcript — 636 of each on this machine,
|
|
748
|
+
* exactly 1:1 — so the looser glob would double every upload and send metadata
|
|
749
|
+
* as transcript content.
|
|
750
|
+
*
|
|
751
|
+
* **Every path is confined.** `isInsideAny(transcriptRoots(...))` gates each
|
|
752
|
+
* entry before it is opened, exactly as the main transcript is gated: this adds
|
|
753
|
+
* a new SET of readers to the surface `/cso` finding 1 rated HIGH, and a
|
|
754
|
+
* symlinked entry in `subagents/` must not walk out of the transcript roots.
|
|
755
|
+
*
|
|
756
|
+
* Oldest first, because it cannot starve. Ordering by pending bytes would let a
|
|
757
|
+
* small file lose every round forever; by mtime, every file reaches the front.
|
|
758
|
+
*
|
|
759
|
+
* A missing or unreadable directory is simply "no sub-agents" — silent, per the
|
|
760
|
+
* hook contract.
|
|
761
|
+
*/
|
|
762
|
+
function findSubagentFiles(ctx, transcriptPath) {
|
|
763
|
+
// ⛔ `CR-183` — THE GLOB IS THIS DIALECT'S MECHANISM, not the lane's. An agent
|
|
764
|
+
// that ANNOUNCES its delegated transcript on stdin has no `subagents/` to read,
|
|
765
|
+
// and running the glob against its layout would be a directory walk that can
|
|
766
|
+
// only ever find nothing. Nothing parses an announced path yet, so an
|
|
767
|
+
// announcing dialect delegates nothing today — stated, not disguised.
|
|
768
|
+
//
|
|
769
|
+
// ⛔ SELECTED BY THE CONTAINING ROOT, NOT BY `ctx.agentId` (`CR-204`,
|
|
770
|
+
// D177 §7). Sub-agent LAYOUT is a property of the TRANSCRIPT, so the flag is
|
|
771
|
+
// the wrong oracle for it in both directions — and both are live, not
|
|
772
|
+
// theoretical. MEASURED on a no-flag install (`test/wire-agent.test.ts` G4):
|
|
773
|
+
// a Codex-root transcript with a `subagents/` directory beside it had Claude's
|
|
774
|
+
// glob run over it and UPLOADED what it found. The other direction is the one
|
|
775
|
+
// D177 §7 names: a cross-fired hook globbing Claude's `agent-*.jsonl` under a
|
|
776
|
+
// foreign root and silently finding nothing.
|
|
777
|
+
//
|
|
778
|
+
// ⚠ An unattributed path resolves `unknown`, and `dialectFor` falls back to
|
|
779
|
+
// Claude — so it would glob. It cannot arrive here: confinement refuses
|
|
780
|
+
// everything outside the union first, and `subagentsDir` returns null for
|
|
781
|
+
// anything that is not a `.jsonl` anyway.
|
|
782
|
+
if (dialectFor(agentForTranscript(ctx.home, ctx.env, transcriptPath)).delegatedTranscripts !==
|
|
783
|
+
"discover") {
|
|
784
|
+
return [];
|
|
785
|
+
}
|
|
786
|
+
const dir = subagentsDir(transcriptPath);
|
|
787
|
+
if (dir === null)
|
|
788
|
+
return [];
|
|
789
|
+
const roots = transcriptRoots(ctx.home, ctx.env);
|
|
790
|
+
let entries;
|
|
791
|
+
try {
|
|
792
|
+
entries = readdirSync(dir);
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
const found = [];
|
|
798
|
+
for (const entry of entries) {
|
|
799
|
+
const fileKey = subagentFileKey(entry);
|
|
800
|
+
if (fileKey === null)
|
|
801
|
+
continue;
|
|
802
|
+
const path = join(dir, entry);
|
|
803
|
+
if (!isInsideAny(roots, path))
|
|
804
|
+
continue;
|
|
805
|
+
try {
|
|
806
|
+
const stat = statSync(path);
|
|
807
|
+
if (!stat.isFile() || stat.size === 0)
|
|
808
|
+
continue;
|
|
809
|
+
found.push({ path, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// Vanished between readdir and stat. Not ours to report.
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return found.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* ⛔ **THE `announce` READER — the first one there has ever been.** `CR-196`,
|
|
819
|
+
* D177 §9.
|
|
820
|
+
*
|
|
821
|
+
* `agents/types.ts` said `announce` *"has no reader yet"* from `CR-183` until
|
|
822
|
+
* this function existed; that sentence is amended by this task rather than left
|
|
823
|
+
* to rot. Until now a dialect declaring `announce` delegated nothing at all, so
|
|
824
|
+
* `codex_cli` and `cursor` both had a discriminator whose only effect was to
|
|
825
|
+
* SUPPRESS Claude's glob.
|
|
826
|
+
*
|
|
827
|
+
* ## ⛔ AN ANNOUNCED PATH IS INPUT, AND IS CONFINED EXACTLY AS THE GLOB'S ARE
|
|
828
|
+
*
|
|
829
|
+
* `findSubagentFiles` gates every entry it finds on `isInsideAny` plus the
|
|
830
|
+
* `.jsonl` family test, and this gates on `admitsTranscript` — the same
|
|
831
|
+
* registry-computed boundary plus the same file gate, in one call. ⚠ **Being
|
|
832
|
+
* HANDED a path is not evidence about where it points.** A hook definition that
|
|
833
|
+
* announced `~/.ssh/id_rsa` would otherwise get it read and uploaded under the
|
|
834
|
+
* user's ingest credential, which is `/cso` finding 1 with the agent's help.
|
|
835
|
+
*
|
|
836
|
+
* ## Selected by the CONTAINING ROOT, like everything else since `CR-204`
|
|
837
|
+
*
|
|
838
|
+
* The main transcript's root names the producer, and the producer's dialect
|
|
839
|
+
* says whether it announces. ⛔ Not `ctx.agentId`: no install passes the flag,
|
|
840
|
+
* so a flag-gated reader would read announced paths for `claude-code` — a
|
|
841
|
+
* dialect that declares `discover` and never announces anything — and ignore
|
|
842
|
+
* them for the two that do.
|
|
843
|
+
*
|
|
844
|
+
* ## ⚠ NOT GATED ON THE EVENT NAME, and that is a decision about `D5`
|
|
845
|
+
*
|
|
846
|
+
* The plan's `D5` — the exact `hook_event_name` strings Cursor sends — is
|
|
847
|
+
* founder-blocked and gates `CR-195`. `parseHookInput` falls back to `""` for a
|
|
848
|
+
* non-string, so an unrecognised spelling is not an error but a value that
|
|
849
|
+
* matches nothing; gating here on a guessed `subagentStop` spelling would make
|
|
850
|
+
* this reader silently inert on the day the guess was wrong. The PRESENCE of
|
|
851
|
+
* the field is the signal, and it is one the client can verify.
|
|
852
|
+
*
|
|
853
|
+
* ## ⚠ `M0.8` IS UNMEASURED, NOT ABSENT
|
|
854
|
+
*
|
|
855
|
+
* Two query forms — a path glob under the root and
|
|
856
|
+
* `find ~/.cursor -type d -name subagents` — return **zero** `subagents`
|
|
857
|
+
* directories on this machine. ⛔ **But no session here has ever spawned a
|
|
858
|
+
* Cursor sub-agent**, so that is *"the producing event has not occurred"*, not
|
|
859
|
+
* *"Cursor does not populate it."* The fixtures below it are therefore REAL IN
|
|
860
|
+
* SHAPE AND SYNTHETIC IN CONTENT, and no cell claims otherwise
|
|
861
|
+
* (`feedback_single_artifact_absence`).
|
|
862
|
+
*
|
|
863
|
+
* Returns at most one file: one `subagentStop`, one announced path. An array
|
|
864
|
+
* rather than a nullable, so the caller's cap, ordering and gap-stamping treat
|
|
865
|
+
* it identically to a globbed one.
|
|
866
|
+
*/
|
|
867
|
+
function announcedSubagentFiles(ctx, input) {
|
|
868
|
+
const announced = input.agentTranscriptPath;
|
|
869
|
+
if (announced === undefined)
|
|
870
|
+
return [];
|
|
871
|
+
if (dialectFor(agentForTranscript(ctx.home, ctx.env, input.transcriptPath))
|
|
872
|
+
.delegatedTranscripts !== "announce") {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
// ⛔ CONFINEMENT AND THE FILE GATE, over the ANNOUNCED path — not over the
|
|
876
|
+
// main transcript, which was gated separately and says nothing about this one.
|
|
877
|
+
if (!admitsTranscript(ctx.home, ctx.env, announced))
|
|
878
|
+
return [];
|
|
879
|
+
const fileKey = announcedSubagentFileKey(basename(announced));
|
|
880
|
+
if (fileKey === null)
|
|
881
|
+
return [];
|
|
882
|
+
try {
|
|
883
|
+
const stat = statSync(announced);
|
|
884
|
+
if (!stat.isFile() || stat.size === 0)
|
|
885
|
+
return [];
|
|
886
|
+
return [
|
|
887
|
+
{ path: announced, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs },
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// Vanished, or never there. Silent, per the hook contract.
|
|
892
|
+
return [];
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Size in bytes, or null if it cannot be read. */
|
|
896
|
+
function fileSize(path) {
|
|
897
|
+
try {
|
|
898
|
+
return statSync(path).size;
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/** The porcelain record's opening line — `worktree <absolute path>`. */
|
|
905
|
+
const WORKTREE_LINE = "worktree ";
|
|
906
|
+
/**
|
|
907
|
+
* The MAIN work tree of the repository whose common dir this is, or **null**.
|
|
908
|
+
*
|
|
909
|
+
* ## ⛔ `rev-parse --show-toplevel` CANNOT ANSWER THIS, measured
|
|
910
|
+
*
|
|
911
|
+
* `D184 §4` mandates `git -C <common-dir> rev-parse --show-toplevel`. Measured
|
|
912
|
+
* on git 2.39.5 against real repositories, that command exits **128** —
|
|
913
|
+
* *"fatal: this operation must be run in a work tree"* — for **every shape a
|
|
914
|
+
* consent key takes**, because a consent key is always a git DIRECTORY and
|
|
915
|
+
* `--is-inside-work-tree` is `false` inside one:
|
|
916
|
+
*
|
|
917
|
+
* | `-C` | `--show-toplevel` |
|
|
918
|
+
* |---|---|
|
|
919
|
+
* | `<main>/.git` (main clone, and every worktree linked to it) | ⛔ ec 128 |
|
|
920
|
+
* | `<repos>/project.git` (bare) | ⛔ ec 128 |
|
|
921
|
+
*
|
|
922
|
+
* Implemented literally, "a failure means no second root" would fire 100% of
|
|
923
|
+
* the time: the set would be the singleton it is today, forever, and a
|
|
924
|
+
* main-clone file read from a worktree session would come back a marker. The
|
|
925
|
+
* whole change would pass its own tests while doing nothing.
|
|
926
|
+
*
|
|
927
|
+
* ## What answers instead, and why it is not a guess
|
|
928
|
+
*
|
|
929
|
+
* `git worktree list --porcelain` DOES run from a git dir and from a bare
|
|
930
|
+
* directory, and its FIRST record is the main work tree. Bare repositories say
|
|
931
|
+
* so in the record itself — `bare`, with no work tree of their own.
|
|
932
|
+
*
|
|
933
|
+
* ⛔ **The answer is then validated by CONTENT, never trusted by position:**
|
|
934
|
+
* `realpath(<candidate>/.git)` must BE the consented common dir. That check is
|
|
935
|
+
* independently fatal to every wrong answer this could produce — a bare repo
|
|
936
|
+
* (`<repos>/.git` does not exist), a linked worktree returned first by some
|
|
937
|
+
* future git (its `.git` is a FILE, so the realpath is the file, not the common
|
|
938
|
+
* dir), a submodule work tree (same). `D184 §4`'s hazard is what it is for: in a
|
|
939
|
+
* worktree of a bare repo the common dir is `…/repos/project.git`, whose parent
|
|
940
|
+
* is **the folder holding every other repository on the machine**, and a root
|
|
941
|
+
* derived by stripping a suffix would transmit every sibling repo's file
|
|
942
|
+
* contents verbatim.
|
|
943
|
+
*
|
|
944
|
+
* Every failure yields NO SECOND ROOT. Never a fallback, never a parent, never a
|
|
945
|
+
* suffix strip.
|
|
946
|
+
*/
|
|
947
|
+
function mainWorkTree(commonDir) {
|
|
948
|
+
const out = gitProbe(commonDir, ["worktree", "list", "--porcelain"]);
|
|
949
|
+
if (out === null)
|
|
950
|
+
return null;
|
|
951
|
+
const record = [];
|
|
952
|
+
for (const line of out.split("\n")) {
|
|
953
|
+
if (line === "")
|
|
954
|
+
break;
|
|
955
|
+
record.push(line);
|
|
956
|
+
}
|
|
957
|
+
// A BARE repository HAS no work tree, and git says so in the record itself.
|
|
958
|
+
if (record.includes("bare"))
|
|
959
|
+
return null;
|
|
960
|
+
const first = record[0];
|
|
961
|
+
if (first === undefined || !first.startsWith(WORKTREE_LINE))
|
|
962
|
+
return null;
|
|
963
|
+
const candidate = first.slice(WORKTREE_LINE.length);
|
|
964
|
+
if (!isAbsolute(candidate))
|
|
965
|
+
return null;
|
|
966
|
+
try {
|
|
967
|
+
return realpathSync(join(candidate, ".git")) === commonDir ? candidate : null;
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* ⛔ **The redaction confinement ROOT SET — `D184 §3`.**
|
|
975
|
+
*
|
|
976
|
+
* ```
|
|
977
|
+
* isInsideAny([worktree, mainToplevel], candidate)
|
|
978
|
+
* ```
|
|
979
|
+
*
|
|
980
|
+
* ## Why it is a set, and why the common dir is not in it
|
|
981
|
+
*
|
|
982
|
+
* `D184` moved the consent key to the git common dir so that a session inside a
|
|
983
|
+
* `git worktree` is admitted at all. ⛔ **The confinement root MUST NOT follow
|
|
984
|
+
* it there.** `isInside` realpaths both sides and fail-closes anything it cannot
|
|
985
|
+
* place to *outside* — which here means REDACT — so a root at `<main>/.git`
|
|
986
|
+
* would judge every file of an external worktree to be outside the main clone
|
|
987
|
+
* and replace its `content`, `oldString`, `newString` and `structuredPatch` with
|
|
988
|
+
* markers. **The capture still succeeds. The turn counts still look right. The
|
|
989
|
+
* content is gone**, and a test asserting *"worktree sessions capture now"*
|
|
990
|
+
* passes on an empty payload.
|
|
991
|
+
*
|
|
992
|
+
* Two roots, because both are genuinely the user's own tree: the worktree the
|
|
993
|
+
* session is running in, and the main clone it was cut from — a builder in a
|
|
994
|
+
* worktree reads the main clone's files constantly.
|
|
995
|
+
*
|
|
996
|
+
* ## ⛔ The `paths.ts:487-489` prohibition, answered — `D184 §5`
|
|
997
|
+
*
|
|
998
|
+
* > *"`roots` comes from the REGISTRY. It must never be derived from input."*
|
|
999
|
+
*
|
|
1000
|
+
* **That prohibition is about PROVENANCE, and both members have the right one.**
|
|
1001
|
+
* The named hazard is *anything on hook stdin* — attacker-supplied. Neither of
|
|
1002
|
+
* these is: `keys` can only be produced by `resolveProjectKeys`, which is git's
|
|
1003
|
+
* own answer about the directory the session runs in; `mainToplevel` is git's
|
|
1004
|
+
* answer about the repository **the consent gate already approved**, and it is
|
|
1005
|
+
* content-validated by `mainWorkTree` above. The worktree root has exactly the
|
|
1006
|
+
* same provenance and has been used this way since `CR-024d`.
|
|
1007
|
+
*
|
|
1008
|
+
* ⚠ **Provenance is the whole argument, so this code preserves it:** nothing
|
|
1009
|
+
* here is read from stdin, the derivation is validated by content, and a failure
|
|
1010
|
+
* yields no second root rather than a fallback. An empty set would refuse
|
|
1011
|
+
* everything (`paths.ts:491-492`), which is the correct direction — but this
|
|
1012
|
+
* function cannot return one, because `keys.worktree` is always a member.
|
|
1013
|
+
*
|
|
1014
|
+
* ⚠ The dedup is cosmetic. `isInsideAny` over a repeated root is a union with
|
|
1015
|
+
* itself; it is dropped so that a main-clone session reads as the single root it
|
|
1016
|
+
* has always had, and so a cell asserting "no second root" is asserting
|
|
1017
|
+
* something.
|
|
1018
|
+
*/
|
|
1019
|
+
export function confinementRoots(keys) {
|
|
1020
|
+
const main = mainWorkTree(keys.consent);
|
|
1021
|
+
return main === null || main === keys.worktree ? [keys.worktree] : [keys.worktree, main];
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Read `[from, to)` of the transcript, REDACT it, and compress it.
|
|
1025
|
+
*
|
|
1026
|
+
* The path is already confined to the Claude Code transcript root by the caller
|
|
1027
|
+
* (`/cso` finding 1) — this function must never be handed one that is not.
|
|
1028
|
+
* Returns null on any failure, because a hook cannot report a read error.
|
|
1029
|
+
*
|
|
1030
|
+
* ## The one chokepoint
|
|
1031
|
+
*
|
|
1032
|
+
* This is the single place transcript bytes become a request body, which is why
|
|
1033
|
+
* `CR-024d`'s redaction goes HERE and nowhere else — the same discipline
|
|
1034
|
+
* `post.ts` applies to `expose()`: one call site, greppable, reviewable. Any
|
|
1035
|
+
* future path that reads transcript bytes for upload must come through here or
|
|
1036
|
+
* it silently bypasses the confidentiality boundary.
|
|
1037
|
+
*
|
|
1038
|
+
* `redactSpan` is length-preserving BY CONTRACT (see `redact.ts`), which the
|
|
1039
|
+
* server's offset accounting depends on — so the compressed body still covers
|
|
1040
|
+
* exactly `[from, to)`.
|
|
1041
|
+
*
|
|
1042
|
+
* ## ⛔ The SCRUBBER is chosen HERE, from the path, and never from `ctx.agentId`
|
|
1043
|
+
*
|
|
1044
|
+
* `CR-185`, D164 §5. This function knows the file it is about to read, so it is
|
|
1045
|
+
* the one place that can answer *which registry root contains it* — and a
|
|
1046
|
+
* caller therefore cannot pick the wrong profile for a file it hands over. The
|
|
1047
|
+
* flag is not consulted: both no-flag install paths resolve to `claude-code`,
|
|
1048
|
+
* and running Claude's key set over a Codex record redacts NOTHING.
|
|
1049
|
+
*
|
|
1050
|
+
* ⚠ **`home` and `env` are here for that lookup and nothing else.** They are
|
|
1051
|
+
* passed rather than read from the process for the reason `paths.ts` states: a
|
|
1052
|
+
* module that reads the real HOME cannot be tested without touching the
|
|
1053
|
+
* developer's own transcripts.
|
|
1054
|
+
*
|
|
1055
|
+
* ## ⛔ `projectRoots` is a SET, and the consent key is not in it
|
|
1056
|
+
*
|
|
1057
|
+
* `D184 §3`. It was one string — the consented toplevel — while the consent key
|
|
1058
|
+
* and the confinement boundary were the same value. They are not any more:
|
|
1059
|
+
* `confinementRoots` above builds `[worktree, mainToplevel]` and the git common
|
|
1060
|
+
* dir the consent gate approved is deliberately absent from it. A single-element
|
|
1061
|
+
* set is the ordinary main-clone case and behaves exactly as the old parameter
|
|
1062
|
+
* did.
|
|
1063
|
+
*
|
|
1064
|
+
* EXPORTED for `connect`'s test capture (`CR-025`), which reads a transcript on
|
|
1065
|
+
* a path the hook never takes. It reuses this rather than opening one itself,
|
|
1066
|
+
* because a second reader would bypass both the confinement and the redaction —
|
|
1067
|
+
* exactly what the chokepoint note above warns about.
|
|
1068
|
+
*/
|
|
1069
|
+
export function readSpan(path, from, to, projectRoots, home, env) {
|
|
1070
|
+
let fd;
|
|
1071
|
+
try {
|
|
1072
|
+
fd = openSync(path, "r");
|
|
1073
|
+
const buf = Buffer.alloc(to - from);
|
|
1074
|
+
const read = readSync(fd, buf, 0, buf.length, from);
|
|
1075
|
+
const raw = read === buf.length ? buf : buf.subarray(0, read);
|
|
1076
|
+
return zstdCompressSync(redactSpan(raw, projectRoots, rulesForTranscript(home, env, path)).bytes);
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
finally {
|
|
1082
|
+
if (fd !== undefined) {
|
|
1083
|
+
try {
|
|
1084
|
+
closeSync(fd);
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
// Nothing useful to do, and nothing may be said.
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
//# sourceMappingURL=entry.js.map
|