@brainervirus/workit-core 8.12.3 → 8.13.1

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.
@@ -108,10 +108,13 @@ const subagentStartText = (
108
108
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
109
109
  if (role !== "judge" || !event.agentId) return readOnly;
110
110
  const own = subagentSession(session, event.agentId);
111
- // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it.
111
+ // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
112
+ // and only with the token it hands to this subagent alone.
112
113
  // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
113
- if (session) recordIssuedSession(input.cwd || process.cwd(), session, own);
114
- return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
114
+ // Issued once: a replayed SubagentStart for the same id gets no token.
115
+ const token = session ? recordIssuedSession(input.cwd || process.cwd(), session, own) : null;
116
+ const flags = token ? `--session ${own} --token ${token}` : `--session ${own}`;
117
+ return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> ${flags} ...\` so the ledger tells it apart from the author.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
115
118
  };
116
119
 
117
120
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
@@ -25,6 +25,7 @@ import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { releaseBranch, resolveBranchPolicyFor, validateBranchNameFor } from "../core/branch";
28
+ import { releaseTagPrefixOf } from "../core/branch-policy";
28
29
  import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
29
30
  import { appendHookObserved, readLedger } from "../ledger";
30
31
  import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
@@ -1340,7 +1341,7 @@ const refusal = (action: RawAction, branch: string | null, dir?: string): string
1340
1341
  // custom protected branch): the push has its own granted verb.
1341
1342
  const plainPush = action.kind === "push" && !action.blindForce && !action.deletes;
1342
1343
  if (dir && branch && plainPush && releaseBranch(dir, branch))
1343
- return `workit: raw ${quote("git push")} onto ${branch}, a protected release branch, bypasses workit's protected-branch check. To finish a release or hotfix, push it with ${quote(`workit git push --release ${branch} [--release v<version>]`)}: it pushes ${branch} (fast-forward only) and the release tag, verifies the remote and records it, under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}). Feature work goes through ${quote("workit git push")} and ${quote("workit pr merge")}.`;
1344
+ return `workit: raw ${quote("git push")} onto ${branch}, a protected release branch, bypasses workit's protected-branch check. To finish a release or hotfix, push it with ${quote(`workit git push --release ${branch} [--release ${releaseTagPrefixOf(dir)}<version>]`)}: it pushes ${branch} (fast-forward only) and the release tag, verifies the remote and records it, under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}). Feature work goes through ${quote("workit git push")} and ${quote("workit pr merge")}.`;
1344
1345
  return `workit: raw ${quote("git push")}${action.kind === "push" && action.blindForce ? " --force" : ""} onto ${branch}, a protected branch or the default target, bypasses workit's protected-branch check. Push your feature branch with ${quote("workit git push")} (${quote("workit git push --force-with-lease")} to rewrite it) and land it with ${quote("workit pr merge")}.`;
1345
1346
  };
1346
1347
 
@@ -1459,14 +1460,15 @@ const isTag = (facts: RefFacts, name: string): boolean =>
1459
1460
  * the release grant: denied with the workit command. `--follow-tags` only
1460
1461
  * nudges (it pushes annotated tags the pushed commits already reach).
1461
1462
  */
1462
- const tagRefusal = (action: PushAction, facts: RefFacts): string | null => {
1463
+ const tagRefusal = (action: PushAction, facts: RefFacts, dir: string): string | null => {
1463
1464
  const tags = [
1464
1465
  ...(action.tags ?? []),
1465
1466
  ...action.targets.filter((name): name is string => name !== null && isTag(facts, name)),
1466
1467
  ];
1467
1468
  if (!tags.length && !action.allTags) return null;
1468
- const named = tags.length ? tags.join(" --release ") : "v<version>";
1469
- return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (v<version> tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
1469
+ const shape = `${releaseTagPrefixOf(dir)}<version>`;
1470
+ const named = tags.length ? tags.join(" --release ") : shape;
1471
+ return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (${shape} tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
1470
1472
  };
1471
1473
 
1472
1474
  /** The remote's branches (`ls-remote --heads`, bounded); null when it cannot be read. */
@@ -1557,7 +1559,7 @@ const pushRefusal = (action: PushAction, repo: Repo, config: GitConfig): string
1557
1559
  if (guarded) return refusal(action, guarded, repo.dir);
1558
1560
  }
1559
1561
  return (
1560
- tagRefusal(pushed, facts) ??
1562
+ tagRefusal(pushed, facts, repo.dir) ??
1561
1563
  (action.deletes ? null : offPolicyPush(pushed, repo, facts, remote))
1562
1564
  );
1563
1565
  };
package/src/hooks/stop.ts CHANGED
@@ -253,9 +253,11 @@ export function stopObligation(
253
253
  if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
254
254
  const branch = currentBranch(input.cwd);
255
255
  if (!branch || guardedBranch(input.cwd, branch)) return null;
256
- const ledger = readLedger(input.cwd);
256
+ // Read with the hash chain: a hand-appended row neither creates nor
257
+ // clears an obligation, and verdicts are judged with integrity (N1).
258
+ const ledger = readLedger(input.cwd, { integrity: true });
257
259
  if (!ledger.ok) return null;
258
- const all = ledger.value.rows;
260
+ const all = ledger.value.rows.filter((row) => row.integrity !== "unverified");
259
261
  const rows = all.filter((row) => row.branch === branch);
260
262
  const mine = rows.filter((row) => sessionOf(row) === session);
261
263
  const commits = new Set(
@@ -7,7 +7,7 @@
7
7
  // - Pi: PI_SESSION_ID (the bash tool exposes the Pi session id);
8
8
  // - Codex CLI and Desktop: CODEX_THREAD_ID (the thread id hooks get as session_id).
9
9
  // Cursor puts no conversation id in its shell; its hook names the id to use.
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { resolveStateDir } from "./core/logger";
@@ -87,11 +87,12 @@ const ISSUED_PER_LEAD = 64;
87
87
 
88
88
  /**
89
89
  * Written into each issued-id file. A hook bundle that contains this string
90
- * records issued ids per lead in the user's state dir (`/2`; `/1` hooks of
91
- * 8.10-8.12 wrote them per repository, which breaks cross-repo verdicts); `workit doctor` looks for it in the installed Claude
92
- * Code and Codex hooks to catch a plugin older than the CLI.
90
+ * records issued ids per lead in the user's state dir with a token only the
91
+ * subagent is told (`/3`; `/2` files carry no token, `/1` hooks of 8.10-8.12
92
+ * wrote them per repository); `workit doctor` looks for it in the installed
93
+ * Claude Code and Codex hooks to catch a plugin older than the CLI.
93
94
  */
94
- export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/2";
95
+ export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/3";
95
96
 
96
97
  const hash = (value: string): string =>
97
98
  createHash("sha256").update(value).digest("hex").slice(0, 32);
@@ -100,6 +101,13 @@ const hash = (value: string): string =>
100
101
  // started in one repository delegates review of others, and the verifier
101
102
  // records its verdict from whichever repository it reviews (#294).
102
103
  const userIssuedRoot = (): string => path.join(resolveStateDir(), "issued");
104
+ /**
105
+ * Token hashes, one file per issued id under `<state>/issued-tokens/<lead
106
+ * hash>/`: a tree hooks older than tokens never write, so an old cached
107
+ * plugin hook that rewrites a record in `issued/` cannot strip its token.
108
+ * Once a lead has one, every id under it needs a token (sticky binding).
109
+ */
110
+ const userTokenRoot = (): string => path.join(resolveStateDir(), "issued-tokens");
103
111
 
104
112
  /**
105
113
  * Hooks of 8.10-8.12 wrote issued ids into `<store>/hooks/issued` of the
@@ -126,7 +134,13 @@ function migrateRepoIssued(cwd: string | undefined): void {
126
134
  const to = path.join(userIssuedRoot(), lead);
127
135
  fs.mkdirSync(to, { recursive: true, mode: 0o700 });
128
136
  for (const id of fs.readdirSync(from)) {
129
- fs.copyFileSync(path.join(from, id), path.join(to, id));
137
+ // Never over an existing per-user record: a legacy in-repo copy must
138
+ // not replace a token-bound one (EEXIST just drops the legacy copy).
139
+ try {
140
+ fs.copyFileSync(path.join(from, id), path.join(to, id), fs.constants.COPYFILE_EXCL);
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
143
+ }
130
144
  fs.rmSync(path.join(from, id), { force: true });
131
145
  }
132
146
  removeIfEmpty(from);
@@ -170,14 +184,40 @@ const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
170
184
  codex: "codex plugin remove workit@workit && codex plugin add workit@workit",
171
185
  };
172
186
 
173
- /** Remember that a SubagentStart hook issued `issued` to a subagent of `lead`. Fails open. */
174
- export function recordIssuedSession(cwd: string, lead: string, issued: string): void {
187
+ const tokenHash = (token: string): string => createHash("sha256").update(token).digest("hex");
188
+
189
+ /**
190
+ * Remember that a SubagentStart hook issued `issued` to a subagent of `lead`,
191
+ * and return the token that binds the id to that subagent: the hook hands it
192
+ * only to the subagent, and only its sha256 is stored, so a lead or sibling
193
+ * that reads the id in the ledger cannot act as it (audit mv83zyg6 P1).
194
+ * Each id is issued once: a SubagentStart fired again for an id already
195
+ * issued (a lead replaying the hook) gets no new token (review mv8daa5d P1).
196
+ * Null when nothing was issued: already issued, or the write failed (fails open).
197
+ */
198
+ export function recordIssuedSession(cwd: string, lead: string, issued: string): string | null {
199
+ const token = randomBytes(18).toString("base64url");
175
200
  try {
176
201
  migrateRepoIssued(cwd);
202
+ const own = hash(issued);
203
+ const tokens = path.join(userTokenRoot(), hash(lead));
204
+ fs.mkdirSync(tokens, { recursive: true, mode: 0o700 });
205
+ // `wx`: the first SubagentStart for an id wins; any later one fails here.
206
+ fs.writeFileSync(path.join(tokens, own), `${tokenHash(token)}\n`, { mode: 0o600, flag: "wx" });
177
207
  const dir = path.join(userIssuedRoot(), hash(lead));
178
208
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
179
- const own = hash(issued);
180
- fs.writeFileSync(path.join(dir, own), ISSUED_SESSIONS_MARKER);
209
+ fs.writeFileSync(path.join(dir, own), `${ISSUED_SESSIONS_MARKER}\n`, { mode: 0o600 });
210
+ pruneIssued(dir, own, tokens);
211
+ return token;
212
+ } catch {
213
+ // Already issued (EEXIST), or a failed write: no token is handed out.
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /** Keep the newest ISSUED_PER_LEAD ids of one lead (and their token hashes); `own` is never pruned. */
219
+ function pruneIssued(dir: string, own: string, tokens: string): void {
220
+ try {
181
221
  const entries = fs.readdirSync(dir);
182
222
  if (entries.length <= ISSUED_PER_LEAD) return;
183
223
  // Oldest first; the id just issued is never the one pruned.
@@ -185,22 +225,75 @@ export function recordIssuedSession(cwd: string, lead: string, issued: string):
185
225
  .filter((name) => name !== own)
186
226
  .map((name) => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs }))
187
227
  .toSorted((a, b) => a.at - b.at);
188
- for (const { name } of byAge.slice(0, entries.length - ISSUED_PER_LEAD))
228
+ for (const { name } of byAge.slice(0, entries.length - ISSUED_PER_LEAD)) {
189
229
  fs.rmSync(path.join(dir, name), { force: true });
230
+ fs.rmSync(path.join(tokens, name), { force: true });
231
+ }
190
232
  } catch {
191
233
  // Fail open: an unrecorded id is refused later, never a broken hook.
192
234
  }
193
235
  }
194
236
 
195
- const wasIssued = (cwd: string | undefined, lead: string, claimed: string): boolean => {
237
+ /** The issued-id file's text, or null when no hook issued `claimed`. */
238
+ const issuedRecord = (cwd: string | undefined, claimed: string): string | null => {
239
+ const cut = claimed.lastIndexOf(":");
240
+ if (cut <= 0) return null;
196
241
  migrateRepoIssued(cwd);
197
242
  try {
198
- return fs.existsSync(path.join(userIssuedRoot(), hash(lead), hash(claimed)));
243
+ return fs.readFileSync(
244
+ path.join(userIssuedRoot(), hash(claimed.slice(0, cut)), hash(claimed)),
245
+ "utf8",
246
+ );
199
247
  } catch {
200
- return false;
248
+ return null;
201
249
  }
202
250
  };
203
251
 
252
+ const TOKEN_HASH = /^([0-9a-f]{64})$/mu;
253
+
254
+ /** The stored token hash for `claimed`, and whether its lead has any token at all. */
255
+ const issuedToken = (claimed: string): { stored: string | null; leadBound: boolean } => {
256
+ const cut = claimed.lastIndexOf(":");
257
+ if (cut <= 0) return { stored: null, leadBound: false };
258
+ const dir = path.join(userTokenRoot(), hash(claimed.slice(0, cut)));
259
+ try {
260
+ const text = fs.readFileSync(path.join(dir, hash(claimed)), "utf8");
261
+ return { stored: TOKEN_HASH.exec(text)?.[1] ?? null, leadBound: true };
262
+ } catch {
263
+ return { stored: null, leadBound: safeList(dir).length > 0 };
264
+ }
265
+ };
266
+
267
+ /**
268
+ * How a SubagentStart hook issued `claimed` (`<lead>:<agent id>`): `token`
269
+ * (it must be presented, `--token`), `legacy` (a hook older than tokens
270
+ * recorded it) or null (never issued). Binding is sticky: once its lead has
271
+ * any token-bound id, a token-less record (an old cached hook rewriting one,
272
+ * or issuing a new one) reads `token` with no token that can match.
273
+ */
274
+ export type IssuedBinding = "token" | "legacy";
275
+ export function issuedBinding(cwd: string | undefined, claimed: string): IssuedBinding | null {
276
+ const record = issuedRecord(cwd, claimed);
277
+ const { stored, leadBound } = issuedToken(claimed);
278
+ if (stored) return "token";
279
+ if (record === null) return null;
280
+ return leadBound ? "token" : "legacy";
281
+ }
282
+
283
+ /** Whether `token` is the one the hook gave the subagent it issued `claimed` to. */
284
+ export function issuedTokenMatches(
285
+ cwd: string | undefined,
286
+ claimed: string,
287
+ token: string | null | undefined,
288
+ ): boolean {
289
+ migrateRepoIssued(cwd);
290
+ const { stored } = issuedToken(claimed);
291
+ if (!stored || !token) return false;
292
+ const given = Buffer.from(tokenHash(token.trim()));
293
+ const want = Buffer.from(stored);
294
+ return given.length === want.length && timingSafeEqual(given, want);
295
+ }
296
+
204
297
  const leadOf = (session: string): string => session.slice(0, session.lastIndexOf(":"));
205
298
 
206
299
  /**
@@ -230,12 +323,17 @@ export type SessionClaimRefusal = { error: string; unblock: string };
230
323
  * workspace's record of hook-issued ids):
231
324
  * - no host session (CI, a plain terminal): any id; `--session X` and
232
325
  * WORKIT_SESSION_ID=X are the same declaration;
233
- * - the shell's own session (a declared `<lead>:<x>` on Claude Code only
234
- * when its SubagentStart hook issued it);
326
+ * - the shell's own session; a declared `<lead>:<x>` (any WORKIT_HOST but
327
+ * cli) only when a SubagentStart hook issued it;
235
328
  * - on a host that sets the id, `<lead>:<this session>`: a child naming its
236
329
  * own host session;
237
330
  * - `<this session>:<agent id>` only when this lead's SubagentStart hook
238
331
  * issued it (Claude Code, Codex).
332
+ * An issued id the hook bound to a token is accepted only with that token
333
+ * (`options.token`): a lead or sibling that read the id cannot reuse it.
334
+ * `options.flag`: named by `--session`. On a host that is only for an issued
335
+ * id; a shell's own or derived session comes from its environment, so a
336
+ * `--session` the gate would not accept is refused here, not later.
239
337
  * Anything else is a forged id: a verdict recorded under it would make author
240
338
  * is not verifier a naming convention.
241
339
  */
@@ -243,6 +341,7 @@ export function sessionClaimRefusal(
243
341
  env: NodeJS.ProcessEnv,
244
342
  claimed: string,
245
343
  cwd?: string,
344
+ options: { token?: string | null; flag?: boolean } = {},
246
345
  ): SessionClaimRefusal | null {
247
346
  const real = realHostSession(env);
248
347
  const host = family(real.host);
@@ -254,16 +353,27 @@ export function sessionClaimRefusal(
254
353
  unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
255
354
  };
256
355
  if (real.kind === "none" || !real.session) return null;
257
- let issuedClaim = false;
258
- if (claimed === real.session) {
259
- const declaredChild =
260
- real.kind === "declared" && host === "claude_code" && claimed.includes(":");
261
- if (!declaredChild || wasIssued(cwd, leadOf(claimed), claimed)) return null;
262
- issuedClaim = true;
263
- } else if (real.kind === "derived" && claimed.endsWith(`:${real.session}`)) return null;
264
- else if (claimed.startsWith(`${real.session}:`)) {
265
- if (wasIssued(cwd, real.session, claimed)) return null;
266
- issuedClaim = true;
356
+ const ownShell =
357
+ (claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
358
+ (real.kind === "derived" && claimed.endsWith(`:${real.session}`));
359
+ if (ownShell)
360
+ return options.flag
361
+ ? {
362
+ error: `session_flag: --session ${claimed} names this shell's own ${real.host} session; on a host only an issued subagent id is passed as --session`,
363
+ unblock: "drop --session: the shell's own session is already the acting one",
364
+ }
365
+ : null;
366
+ const issuedClaim = claimed === real.session || claimed.startsWith(`${real.session}:`);
367
+ if (issuedClaim) {
368
+ const binding = issuedBinding(cwd, claimed);
369
+ if (binding === "legacy") return null;
370
+ if (binding === "token")
371
+ return issuedTokenMatches(cwd, claimed, options.token)
372
+ ? null
373
+ : {
374
+ error: `unbound_session: ${claimed} was issued to a subagent, and this call did not present the token its SubagentStart hook gave it`,
375
+ unblock: `only that subagent acts as ${claimed}: it passes --token exactly as its SubagentStart context names it (or WORKIT_SESSION_TOKEN); anyone else spawns their own verifier`,
376
+ };
267
377
  }
268
378
  const error = `forged_session: ${claimed} is neither this shell's session (${real.session}) nor an id its host issued to a child of it`;
269
379
  // A well-formed subagent id, but no hook here ever recorded one: the hook
@@ -278,7 +388,7 @@ export function sessionClaimRefusal(
278
388
  error,
279
389
  unblock: ownChildShell
280
390
  ? `record the verdict from the verifier's own ${host === "pi" ? "Pi worker" : "OpenCode child session"} shell, where WORKIT_SESSION_ID is already set; never set WORKIT_SESSION_ID or --session by hand`
281
- : "never name a session yourself: spawn a verifier or reviewer subagent and pass exactly the --session its SubagentStart context names, or pass --as verifier",
391
+ : "never name a session yourself: spawn a verifier or reviewer subagent and pass exactly the --session and --token its SubagentStart context names, or pass --as verifier",
282
392
  };
283
393
  }
284
394