@brainervirus/workit-core 8.13.0 → 8.14.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/package.json +1 -1
- package/scripts/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +8 -5
- package/skills/workit-review/SKILL.md +1 -1
- package/src/core/hygiene.ts +37 -1
- package/src/core/live-session.ts +333 -0
- package/src/core/store-lock.ts +9 -1
- package/src/core/workspaces.ts +135 -15
- package/src/fanout-brief.ts +8 -0
- package/src/fanout-status.ts +18 -2
- package/src/forge/pr-ops.ts +84 -18
- package/src/forge/redact.ts +6 -2
- package/src/forge/resolve.ts +15 -0
- package/src/git/ops.ts +20 -0
- package/src/hooks/handle.ts +13 -9
- package/src/hooks/index.ts +2 -1
- package/src/hooks/stop.ts +4 -2
- package/src/hooks/write-gate.ts +303 -3
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/src/hooks/write-gate.ts
CHANGED
|
@@ -5,13 +5,28 @@
|
|
|
5
5
|
// Reads only the task index, one task snapshot and the ledger; never
|
|
6
6
|
// migrates, never captures a candidate, and fails open on any state error.
|
|
7
7
|
import { realpathSync } from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { fileSignature, TaskStore } from "../core/task-store";
|
|
10
11
|
import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy/derive";
|
|
11
12
|
import { applicableDecision } from "../core/task-evaluation";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
claimLiveSession,
|
|
15
|
+
liveConflict,
|
|
16
|
+
type LiveConflict,
|
|
17
|
+
type LiveDeps,
|
|
18
|
+
type LiveOwner,
|
|
19
|
+
} from "../core/live-session";
|
|
20
|
+
import { workspaceLiveSessionGuard } from "../core/workspaces";
|
|
21
|
+
import { appendHookObserved, ledgerPath } from "../ledger";
|
|
13
22
|
import type { HookDecision } from "./protocol";
|
|
14
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
inWorkitWorkspace,
|
|
25
|
+
rawInvocations,
|
|
26
|
+
workitInvocations,
|
|
27
|
+
type RawInvocation,
|
|
28
|
+
} from "./raw-git";
|
|
29
|
+
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
15
30
|
|
|
16
31
|
const NONE: HookDecision = { kind: "none" };
|
|
17
32
|
|
|
@@ -204,13 +219,298 @@ const gateFor = (cwd: string): Gate | null => {
|
|
|
204
219
|
return gate;
|
|
205
220
|
};
|
|
206
221
|
|
|
222
|
+
// --- the live-session guard (#274) ------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** git subcommands that change a checkout's files, index or HEAD. */
|
|
225
|
+
const MUTATING_GIT = new Set([
|
|
226
|
+
"add",
|
|
227
|
+
"am",
|
|
228
|
+
"apply",
|
|
229
|
+
"checkout",
|
|
230
|
+
"cherry-pick",
|
|
231
|
+
"clean",
|
|
232
|
+
"commit",
|
|
233
|
+
"merge",
|
|
234
|
+
"mv",
|
|
235
|
+
"pull",
|
|
236
|
+
"rebase",
|
|
237
|
+
"reset",
|
|
238
|
+
"restore",
|
|
239
|
+
"revert",
|
|
240
|
+
"rm",
|
|
241
|
+
"stash",
|
|
242
|
+
"switch",
|
|
243
|
+
]);
|
|
244
|
+
/** Subcommands whose `-n` means `--dry-run` (for `commit`, `-n` skips hooks). */
|
|
245
|
+
const DRY_N = new Set(["add", "clean", "rm", "mv"]);
|
|
246
|
+
/** `workit git` verbs that commit or move HEAD in the checkout. */
|
|
247
|
+
const MUTATING_WORKIT_GIT = new Set(["commit", "branch"]);
|
|
248
|
+
|
|
249
|
+
/** A placeholder file name that stands for its directory (an unknown write, a git command). */
|
|
250
|
+
const DIR_TARGET = ".workit-live-target";
|
|
251
|
+
|
|
252
|
+
/** A one-command override: `WORKIT_LIVE_SESSION=override <command>`. */
|
|
253
|
+
const OVERRIDE = "WORKIT_LIVE_SESSION=override";
|
|
254
|
+
|
|
255
|
+
/** Only asks for help or a dry run: changes nothing. */
|
|
256
|
+
const inert = (args: readonly string[], sub: string): boolean =>
|
|
257
|
+
args.some(
|
|
258
|
+
(arg) =>
|
|
259
|
+
arg === "--help" ||
|
|
260
|
+
arg === "-h" ||
|
|
261
|
+
arg === "--dry-run" ||
|
|
262
|
+
(DRY_N.has(sub) && /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg) && !arg.startsWith("--")),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The directories mutating git commands in `command` run in (raw-git's
|
|
267
|
+
* analysis: `cd`/`pushd`, groups, `-C`, env and wrapper prefixes, `bash -c`,
|
|
268
|
+
* `$(…)`, `eval`, `xargs`, `find -exec`, PowerShell, `cmd /c`). A command
|
|
269
|
+
* whose directory cannot be known is skipped, never denied.
|
|
270
|
+
*/
|
|
271
|
+
const gitMutationDirs = (cwd: string, command: string, dialect: ShellDialect): string[] => {
|
|
272
|
+
let invocations: readonly RawInvocation[];
|
|
273
|
+
try {
|
|
274
|
+
invocations = rawInvocations(command, dialect);
|
|
275
|
+
} catch {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
return invocations.flatMap((invocation) => {
|
|
279
|
+
if (invocation.tool !== "git" || invocation.dir === undefined) return [];
|
|
280
|
+
const at = invocation.args.findIndex((arg) => !arg.startsWith("-"));
|
|
281
|
+
const sub = invocation.args[at] ?? "";
|
|
282
|
+
const rest = invocation.args.slice(at + 1);
|
|
283
|
+
if (!MUTATING_GIT.has(sub) || inert(rest, sub)) return [];
|
|
284
|
+
// `git stash list|show` only reads.
|
|
285
|
+
if (sub === "stash" && ["list", "show"].includes(rest[0] ?? "")) return [];
|
|
286
|
+
return [path.resolve(cwd, invocation.dir ?? ".")];
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const CD = new Set(["cd", "chdir", "set-location", "sl"]);
|
|
291
|
+
const PUSHD = new Set(["pushd", "push-location"]);
|
|
292
|
+
const POPD = new Set(["popd", "pop-location"]);
|
|
293
|
+
|
|
294
|
+
/** A `cd` operand resolved against `dir`; undefined when it cannot be known. */
|
|
295
|
+
const cdInto = (dir: string | undefined, words: string[]): string | undefined => {
|
|
296
|
+
const target = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
|
|
297
|
+
// Only a leading `~` expands; a mid-path `~` is literal (Windows 8.3
|
|
298
|
+
// short names like RUNNER~1).
|
|
299
|
+
if (dir === undefined || target === "-" || (target && /^~|[$`*?]/.test(target))) return undefined;
|
|
300
|
+
return target === undefined ? os.homedir() : path.resolve(dir, target);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const quoteWord = (word: string): string =>
|
|
304
|
+
/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Per top-level segment, the files it writes (redirects, `touch`, `rm`,
|
|
308
|
+
* `sed -i`…) and `workit git commit|branch`, resolved against the
|
|
309
|
+
* directory that segment runs in (`cd`, `pushd`/`popd`, `( … )` groups). A
|
|
310
|
+
* relative target in an unknown directory is skipped.
|
|
311
|
+
*/
|
|
312
|
+
const segmentTargets = (cwd: string, command: string, dialect: ShellDialect): string[] => {
|
|
313
|
+
const targets: string[] = [];
|
|
314
|
+
let dir: string | undefined = cwd;
|
|
315
|
+
let stack: Array<string | undefined> = [];
|
|
316
|
+
const groups: Array<{ dir: string | undefined; stack: Array<string | undefined> }> = [];
|
|
317
|
+
const at = (file: string) =>
|
|
318
|
+
path.isAbsolute(file) ? file : dir === undefined ? null : path.resolve(dir, file);
|
|
319
|
+
for (const segment of segmentsOf(command, dialect)) {
|
|
320
|
+
if (segment.group === "open") {
|
|
321
|
+
groups.push({ dir, stack: [...stack] });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (segment.group === "close") {
|
|
325
|
+
const saved = groups.pop();
|
|
326
|
+
if (saved) ({ dir, stack } = saved);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const lead = segment.words.findIndex((word) => !/^\w+=/.test(word));
|
|
330
|
+
const words = lead < 0 ? [] : segment.words.slice(lead);
|
|
331
|
+
const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
332
|
+
if (CD.has(name)) {
|
|
333
|
+
dir = cdInto(dir, words);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (PUSHD.has(name)) {
|
|
337
|
+
stack.push(dir);
|
|
338
|
+
dir = cdInto(dir, words);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (POPD.has(name)) {
|
|
342
|
+
dir = stack.length ? stack.pop() : undefined;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
for (const file of segment.redirects)
|
|
346
|
+
if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) {
|
|
347
|
+
const resolved = at(file);
|
|
348
|
+
if (resolved) targets.push(resolved);
|
|
349
|
+
}
|
|
350
|
+
const edits = editsOf(segment.words);
|
|
351
|
+
if (edits === null) {
|
|
352
|
+
if (dir !== undefined) targets.push(path.join(dir, DIR_TARGET));
|
|
353
|
+
} else
|
|
354
|
+
for (const file of edits) {
|
|
355
|
+
const resolved = at(file);
|
|
356
|
+
if (resolved) targets.push(resolved);
|
|
357
|
+
}
|
|
358
|
+
if (
|
|
359
|
+
dir !== undefined &&
|
|
360
|
+
workitInvocations(segment.words.map(quoteWord).join(" "), "posix").some(
|
|
361
|
+
(args) =>
|
|
362
|
+
args[0] === "git" && MUTATING_WORKIT_GIT.has(args[1] ?? "") && !inert(args.slice(2), ""),
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
targets.push(path.join(dir, DIR_TARGET));
|
|
366
|
+
}
|
|
367
|
+
return targets;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
|
|
371
|
+
const overridden = (command: string, dialect: ShellDialect): boolean => {
|
|
372
|
+
const segments = segmentsOf(command, dialect);
|
|
373
|
+
if (segments.length !== 1 || segments[0].group || segments[0].subs?.length) return false;
|
|
374
|
+
const words = segments[0].words;
|
|
375
|
+
const lead = words.findIndex((word) => !/^\w+=/.test(word));
|
|
376
|
+
return lead > 0 && words.slice(0, lead).includes(OVERRIDE);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const HOST_LABELS: Readonly<Record<string, string>> = {
|
|
380
|
+
claude_code: "Claude Code",
|
|
381
|
+
opencode: "OpenCode",
|
|
382
|
+
codex_cli: "Codex CLI",
|
|
383
|
+
codex_desktop: "Codex Desktop",
|
|
384
|
+
cursor: "Cursor",
|
|
385
|
+
pi: "Pi",
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const ageText = (ms: number): string => {
|
|
389
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
390
|
+
return seconds < 120 ? `${seconds}s` : `${Math.round(seconds / 60)} min`;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const liveDeny = ({ marker, checkout, ageMs, file }: LiveConflict): HookDecision => {
|
|
394
|
+
const unblock = `Work in your own worktree (\`git worktree add <path> -b <branch>\`, or \`workit fanout worktree create <slice>\`). Only if the user confirms that session is finished: release it with \`rm '${file}'\`, or prefix one command with ${OVERRIDE}.`;
|
|
395
|
+
return {
|
|
396
|
+
kind: "deny",
|
|
397
|
+
reason: `workit live-session guard: ${checkout} belongs to another live agent session, ${HOST_LABELS[marker.host] ?? marker.host} session ${marker.session}${marker.pid === null ? "" : ` (pid ${marker.pid})`}, last active ${ageText(ageMs)} ago. Do not edit a checkout another session is working in; read-only commands stay allowed. ${unblock}`,
|
|
398
|
+
unblock,
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** One target per directory. */
|
|
403
|
+
const guarded = (targets: readonly string[]): string[] => {
|
|
404
|
+
const seen = new Set<string>();
|
|
405
|
+
return targets.filter((target) => {
|
|
406
|
+
const dir = path.dirname(target);
|
|
407
|
+
if (seen.has(dir)) return false;
|
|
408
|
+
seen.add(dir);
|
|
409
|
+
return true;
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Deny when a target's checkout belongs to another live session and its
|
|
415
|
+
* workspace keeps `liveSessionGuard` on. Fails open.
|
|
416
|
+
*/
|
|
417
|
+
const liveGate = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): HookDecision => {
|
|
418
|
+
try {
|
|
419
|
+
for (const target of guarded(targets)) {
|
|
420
|
+
const conflict = liveConflict(target, owner, deps);
|
|
421
|
+
if (conflict && workspaceLiveSessionGuard(conflict.checkout)) return liveDeny(conflict);
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
// Fail open.
|
|
425
|
+
}
|
|
426
|
+
return NONE;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): void => {
|
|
430
|
+
for (const target of guarded(targets)) claimLiveSession(target, owner, deps);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/** The absolute paths an edit touches: its files, or cwd itself when unknown. */
|
|
434
|
+
const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
|
|
435
|
+
paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
|
|
436
|
+
|
|
207
437
|
/**
|
|
208
438
|
* Deny a working-tree edit while the branch task has unmet before-write
|
|
209
439
|
* requirements. `paths` are the files the tool writes, or null when unknown
|
|
210
440
|
* (a shell write, a patch whose headers cannot be read); unknown or empty
|
|
211
441
|
* targets are gated, and an edit touching only exempt files is always allowed.
|
|
442
|
+
* With `owner` (the hook's session), the live-session guard runs first, and
|
|
443
|
+
* an allowed edit refreshes the owner's marker in each checkout it touches.
|
|
444
|
+
*/
|
|
445
|
+
export function writeGate(
|
|
446
|
+
cwd: string,
|
|
447
|
+
paths: readonly string[] | null,
|
|
448
|
+
owner?: LiveOwner | null,
|
|
449
|
+
deps?: LiveDeps,
|
|
450
|
+
): HookDecision {
|
|
451
|
+
if (!owner) return beforeWriteGate(cwd, paths);
|
|
452
|
+
const targets = liveTargets(cwd, paths);
|
|
453
|
+
const live = liveGate(owner, targets, deps);
|
|
454
|
+
if (live.kind !== "none") return live;
|
|
455
|
+
const decision = beforeWriteGate(cwd, paths);
|
|
456
|
+
if (decision.kind === "none") claimAll(owner, targets, deps);
|
|
457
|
+
return decision;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The write gates on one shell command: the live-session guard on its writes
|
|
462
|
+
* and mutating git commands in the directories they run in, then the
|
|
463
|
+
* before-write gate on its writes. A command that is only
|
|
464
|
+
* `WORKIT_LIVE_SESSION=override <command>` skips the guard and leaves a
|
|
465
|
+
* ledger row. An allowed mutation refreshes the owner's marker.
|
|
212
466
|
*/
|
|
213
|
-
export function
|
|
467
|
+
export function shellGate(
|
|
468
|
+
cwd: string,
|
|
469
|
+
command: string,
|
|
470
|
+
owner: LiveOwner | null,
|
|
471
|
+
dialect: ShellDialect = "posix",
|
|
472
|
+
deps?: LiveDeps,
|
|
473
|
+
): HookDecision {
|
|
474
|
+
const writes = shellWrites(command);
|
|
475
|
+
let targets: string[] = [];
|
|
476
|
+
if (owner) {
|
|
477
|
+
try {
|
|
478
|
+
targets = [
|
|
479
|
+
...segmentTargets(cwd, command, dialect),
|
|
480
|
+
...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
|
|
481
|
+
];
|
|
482
|
+
} catch {
|
|
483
|
+
targets = [];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (owner && targets.length) {
|
|
487
|
+
const live = liveGate(owner, targets, deps);
|
|
488
|
+
if (live.kind !== "none") {
|
|
489
|
+
if (!overridden(command, dialect)) return live;
|
|
490
|
+
noteOverride(cwd, owner, command);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const decision = writes.writes ? beforeWriteGate(cwd, writes.targets) : NONE;
|
|
494
|
+
if (owner && targets.length && decision.kind === "none") claimAll(owner, targets, deps);
|
|
495
|
+
return decision;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** A ledger row for an override that let a command past the guard (managed repositories only). */
|
|
499
|
+
const noteOverride = (cwd: string, owner: LiveOwner, command: string): void => {
|
|
500
|
+
try {
|
|
501
|
+
if (!inWorkitWorkspace(cwd)) return;
|
|
502
|
+
appendHookObserved(cwd, {
|
|
503
|
+
type: "live_session.override",
|
|
504
|
+
actor: { host: owner.host, session: owner.session, agentId: null },
|
|
505
|
+
session: owner.session,
|
|
506
|
+
command: command.slice(0, 200),
|
|
507
|
+
});
|
|
508
|
+
} catch {
|
|
509
|
+
// A note never blocks the command.
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
function beforeWriteGate(cwd: string, paths: readonly string[] | null): HookDecision {
|
|
214
514
|
try {
|
|
215
515
|
const gate = gateFor(cwd);
|
|
216
516
|
if (!gate || gate.blockers.length === 0) return NONE;
|
package/src/host-session.ts
CHANGED
|
@@ -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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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.
|
|
243
|
+
return fs.readFileSync(
|
|
244
|
+
path.join(userIssuedRoot(), hash(claimed.slice(0, cut)), hash(claimed)),
|
|
245
|
+
"utf8",
|
|
246
|
+
);
|
|
199
247
|
} catch {
|
|
200
|
-
return
|
|
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
|
|
234
|
-
* when
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|