@brainervirus/workit-core 8.13.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.13.0",
3
+ "version": "8.13.1",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -29,9 +29,9 @@ for (const pkg of [
29
29
  ]) {
30
30
  const file = resolve(root, `packages/${pkg}/package.json`);
31
31
  const data = JSON.parse(readFileSync(file, "utf8"));
32
- // devDependencies too: the Claude Code plugin bundles core and the CLI into
33
- // dist/, so they are build-time only, but a published manifest must still
34
- // never carry workspace:*.
32
+ // devDependencies too: every adapter bundles core (and the MCP server) into
33
+ // dist/, so they are build-time only (N19) and npm never installs them, but
34
+ // a published manifest must still never carry workspace:*.
35
35
  const groups = [data.dependencies, data.devDependencies].filter(Boolean);
36
36
  if (groups.length === 0) continue;
37
37
  for (const deps of groups)
@@ -72,11 +72,12 @@ Which session id a verdict carries, per host:
72
72
 
73
73
  - Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
74
74
  SubagentStart hook names each `verifier` and `reviewer` its own session
75
- (`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
76
- ids it issues, and only those are accepted. An `implementer` is issued
75
+ (`<lead>:<agent id>`) and a token only that subagent sees, passed exactly as
76
+ `--session` and `--token`: the hook records the ids it issues, and only
77
+ those, with their token, are accepted. An `implementer` is issued
77
78
  none: it keeps your id in its env for its workit commands and commits.
78
79
  - Codex: the `workit-verifier` and `workit-reviewer` agents get the same
79
- issued `--session` from SubagentStart; any other verifier passes
80
+ issued `--session` and `--token` from SubagentStart; any other verifier passes
80
81
  `--as verifier`.
81
82
  - OpenCode: a child session's shell already has
82
83
  `WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
@@ -8,7 +8,7 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
8
8
  Review the candidate, never the author's summary of it. A session that wrote a
9
9
  commit on the branch cannot record a passing verdict: if that is you, hand the
10
10
  review to a fresh agent with its own host-derived session: Claude Code and
11
- Codex, the `reviewer` or `verifier` agent (`--session` from SubagentStart);
11
+ Codex, the `reviewer` or `verifier` agent (`--session` and `--token` from SubagentStart);
12
12
  OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
13
13
  as `<lead>-v1`: it is refused as `forged_session`.
14
14
  An author's `--self` verdict reads self-reviewed and never counts as
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { changelogUnreleasedStats } from "./changelog";
4
5
  import { assetRoot } from "./package-root";
@@ -75,6 +76,41 @@ export const hygieneFiles = (
75
76
  return { state, openSource };
76
77
  };
77
78
 
79
+ export type ProjectTarget = { ok: true; root: string } | { ok: false; refused: string };
80
+
81
+ /**
82
+ * Where project files (gitignore, hygiene) belong: the git toplevel of `dir`.
83
+ * Outside a repository there is no project to scaffold, and a folder holding
84
+ * several projects must never receive them, so the answer is a refusal naming
85
+ * the way out: the child repositories found one level down, or `cd <repo>`.
86
+ */
87
+ export const projectTarget = (dir: string): ProjectTarget => {
88
+ const start = path.resolve(dir);
89
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: start, encoding: "utf8" });
90
+ const root = top.status === 0 ? (top.stdout ?? "").trim() : "";
91
+ if (root) return { ok: true, root: path.resolve(root) };
92
+ let children: string[] = [];
93
+ try {
94
+ children = readdirSync(start, { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory() && existsSync(path.join(start, entry.name, ".git")))
96
+ .map((entry) => path.join(start, entry.name))
97
+ .toSorted();
98
+ } catch {
99
+ children = [];
100
+ }
101
+ const where =
102
+ children.length > 0
103
+ ? `run it inside one of its repositories: ${children
104
+ .slice(0, 5)
105
+ .map((child) => `cd ${child}`)
106
+ .join(", ")}${children.length > 5 ? ` (+${children.length - 5} more)` : ""}`
107
+ : "cd <repo> (or git init here) and run workit init again";
108
+ return {
109
+ ok: false,
110
+ refused: `${start} is not a git repository, so no project files were added there; ${where}`,
111
+ };
112
+ };
113
+
78
114
  // Pure planner: which hygiene files would be created and with what content,
79
115
  // without touching the filesystem. Drives the wizard preview (WZ-08) and the
80
116
  // apply path; ensureHygieneFiles is just "plan then write".
@@ -44,8 +44,16 @@ export const RECLAIM_GUARD_TTL_MS = 30_000;
44
44
  * wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
45
45
  * the short default; the CLI raises it for its own process.
46
46
  */
47
- let defaultLockTimeoutMs = 250;
47
+ export const IN_PROCESS_LOCK_TIMEOUT_MS = 250;
48
+ let defaultLockTimeoutMs = IN_PROCESS_LOCK_TIMEOUT_MS;
48
49
  export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
50
+ /**
51
+ * The wait for a lock that a CLI process may hold longer (the ledger's
52
+ * network-filesystem lock): `cliMs` once the process raised the default
53
+ * (the CLI does), else the in-process cap.
54
+ */
55
+ export const hostAwareLockTimeout = (cliMs: number): number =>
56
+ defaultLockTimeoutMs > IN_PROCESS_LOCK_TIMEOUT_MS ? cliMs : IN_PROCESS_LOCK_TIMEOUT_MS;
49
57
  export const setDefaultLockTimeout = (ms: number): void => {
50
58
  defaultLockTimeoutMs = ms;
51
59
  };
@@ -35,6 +35,7 @@ import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from
35
35
  import {
36
36
  appendObserved,
37
37
  checkVerdicts,
38
+ tamperedUnblock,
38
39
  readLedger,
39
40
  type LedgerActor,
40
41
  type VerdictCheck,
@@ -366,6 +367,8 @@ export type MergeOutcome = {
366
367
  grant: { source: AutonomySource };
367
368
  deletedBranch: boolean | { error: string };
368
369
  recorded: { id: string } | { error: string };
370
+ /** The `pr.merge.verified` row confirming `recorded`, or why it was not written. */
371
+ confirmed: { id: string } | { error: string };
369
372
  /**
370
373
  * Release tracks: when the PR landed on a track's production branch, the
371
374
  * branches it must flow back into (mergeBackBranches); empty otherwise.
@@ -420,7 +423,9 @@ export type MergeRefusal = {
420
423
  | "already_merged"
421
424
  | "closed"
422
425
  | "protected_branch"
423
- | "base_mismatch";
426
+ | "base_mismatch"
427
+ | "ledger_tampered"
428
+ | "intent_unrecorded";
424
429
  next?: string;
425
430
  blockers?: string[];
426
431
  verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
@@ -434,6 +439,8 @@ export type MergeResult =
434
439
  error: string;
435
440
  unblock?: string;
436
441
  refusal?: MergeRefusal;
442
+ /** merge_unverified: the `pr.merged` row (verified:false) written when the forge accepted it. */
443
+ recorded?: { id: string } | { error: string };
437
444
  };
438
445
 
439
446
  const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
@@ -635,7 +642,7 @@ export async function mergePullRequest(
635
642
  { reason: "protected_branch" },
636
643
  );
637
644
  }
638
- const ledger = readLedger(cwd);
645
+ const ledger = readLedger(cwd, { integrity: true });
639
646
  if (!ledger.ok) return ledger;
640
647
  const check = checkVerdicts(cwd, branch, ledger.value.rows);
641
648
  const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
@@ -680,6 +687,12 @@ export async function mergePullRequest(
680
687
  `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
681
688
  { reason: "head_mismatch", verdict: summary },
682
689
  );
690
+ else if (!covered && check.accepted.reasons.includes("ledger_tampered"))
691
+ return refuse(
692
+ `ledger_tampered: a ledger row was edited, removed or moved, so no verdict for ${label} is accepted until it is repaired`,
693
+ tamperedUnblock(cwd),
694
+ { reason: "ledger_tampered", verdict: summary },
695
+ );
683
696
  else if (!covered)
684
697
  return refuse(
685
698
  `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
@@ -689,12 +702,57 @@ export async function mergePullRequest(
689
702
 
690
703
  const squashMessage = squashMessageFor(cwd, resolved, input, doc);
691
704
  if (squashMessage && !squashMessage.ok) return squashMessage;
705
+ // The intent goes in before the forge write: a merge call that errors after
706
+ // the forge did merge still leaves a record `pr status` reads. A merge that
707
+ // cannot be recorded never starts.
708
+ const intent = appendObserved(cwd, {
709
+ type: "pr.merge.intent",
710
+ actor: input.actor,
711
+ branch,
712
+ head,
713
+ pr: doc.number,
714
+ base: doc.base,
715
+ method: input.method,
716
+ verdictId: verdict.verdictId,
717
+ ...(unverified ? { unverified: unverified.recorded } : {}),
718
+ grant: grant.source,
719
+ });
720
+ if (!intent.ok)
721
+ return refuse(
722
+ `intent_unrecorded: the merge intent for ${label} could not be recorded (${intent.error}); nothing was merged`,
723
+ "fix the ledger (workit ledger check), then merge again",
724
+ { reason: "intent_unrecorded" },
725
+ );
726
+ const intentId = String(intent.value.id);
692
727
  const merged = resolved.forge.merge(doc.number, {
693
728
  sha: head,
694
729
  method: input.method,
695
730
  ...(squashMessage ? { squashMessage: squashMessage.data } : {}),
696
731
  });
697
- if (!merged.ok) return merged;
732
+ if (!merged.ok)
733
+ return {
734
+ ...merged,
735
+ error: `${merged.error} (merge intent ${intentId} is recorded: if the forge merged anyway, workit pr status reads it)`,
736
+ };
737
+ // Recorded as soon as the forge accepts it (N3): a failed read below must
738
+ // not leave a merge that `pr status`, the stop hook and `verify-delivery
739
+ // merged` cannot see. The post-verify then confirms it in its own row.
740
+ const row = appendObserved(cwd, {
741
+ type: "pr.merged",
742
+ actor: input.actor,
743
+ branch,
744
+ head,
745
+ pr: doc.number,
746
+ base: doc.base,
747
+ mergeSha: merged.data.mergeSha,
748
+ method: input.method,
749
+ verdictId: verdict.verdictId,
750
+ ...(unverified ? { unverified: unverified.recorded } : {}),
751
+ grant: grant.source,
752
+ intent: intentId,
753
+ verified: false,
754
+ });
755
+ const recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
698
756
  // Post-verify: the forge must report the PR merged.
699
757
  let state = "open";
700
758
  for (const delay of [0, ...VERIFY_DELAYS_MS]) {
@@ -707,9 +765,29 @@ export async function mergePullRequest(
707
765
  return {
708
766
  ok: false,
709
767
  code: "failed",
710
- error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}`,
768
+ error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}${"id" in recorded ? `; recorded as pr.merged ${recorded.id} (verified: false)` : `; the pr.merged row could not be written (${recorded.error})`}`,
711
769
  unblock: `workit verify-delivery merged --pr ${doc.number}`,
770
+ recorded,
712
771
  };
772
+ const confirmation =
773
+ "id" in recorded
774
+ ? appendObserved(cwd, {
775
+ type: "pr.merge.verified",
776
+ actor: input.actor,
777
+ branch,
778
+ head,
779
+ pr: doc.number,
780
+ base: doc.base,
781
+ merged: recorded.id,
782
+ mergeSha: merged.data.mergeSha,
783
+ })
784
+ : null;
785
+ const confirmed: MergeOutcome["confirmed"] =
786
+ confirmation === null
787
+ ? { error: "no pr.merged row to confirm" }
788
+ : confirmation.ok
789
+ ? { id: String(confirmation.value.id) }
790
+ : { error: confirmation.error };
713
791
 
714
792
  let deletedBranch: MergeOutcome["deletedBranch"] = false;
715
793
  if (input.deleteBranch) {
@@ -720,19 +798,6 @@ export async function mergePullRequest(
720
798
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
721
799
  }
722
800
  const mergeBack = mergeBackFor(cwd, doc.base, branch);
723
- const row = appendObserved(cwd, {
724
- type: "pr.merged",
725
- actor: input.actor,
726
- branch,
727
- head,
728
- pr: doc.number,
729
- base: doc.base,
730
- mergeSha: merged.data.mergeSha,
731
- method: input.method,
732
- verdictId: verdict.verdictId,
733
- ...(unverified ? { unverified: unverified.recorded } : {}),
734
- grant: grant.source,
735
- });
736
801
  return {
737
802
  ok: true,
738
803
  data: {
@@ -747,7 +812,8 @@ export async function mergePullRequest(
747
812
  unverified,
748
813
  grant: { source: grant.source },
749
814
  deletedBranch,
750
- recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
815
+ recorded,
816
+ confirmed,
751
817
  mergeBack: mergeBack.branches,
752
818
  mergeBackTrack: mergeBack.track,
753
819
  },
@@ -8,8 +8,12 @@ const R = "[REDACTED]";
8
8
  const TOKEN_PATTERNS: readonly RegExp[] = [
9
9
  // GitHub tokens (classic, fine-grained, app, OAuth, refresh).
10
10
  /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/gu,
11
- // GitLab tokens: glpat-, glrt-, gldt-, glcbt-, glptt-, glft-, gloas-, …
12
- /\bgl[a-z]{1,8}-[A-Za-z0-9_-]{16,}/gu,
11
+ // GitLab tokens, by every prefix GitLab documents
12
+ // (https://docs.gitlab.com/security/tokens/#token-prefixes) and only those:
13
+ // a bare `gl…-` also matches words such as `global-setup-teardown-hook`.
14
+ /\b(?:glpat|gloas|gldt|glrtr|glrt|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{20,}/gu,
15
+ // GitLab's session cookie.
16
+ /\b_gitlab_session=[A-Za-z0-9%_-]{16,}/gu,
13
17
  /\bnpm_[A-Za-z0-9]{30,}/gu,
14
18
  /\bxox[abposr]-[A-Za-z0-9-]{10,}/gu,
15
19
  /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
@@ -181,6 +181,21 @@ function resolveCredential(
181
181
  `token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
182
182
  `if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
183
183
  );
184
+ // Like youtrack.token: a token other users can read is refused (N14).
185
+ if (process.platform !== "win32") {
186
+ let mode = 0;
187
+ try {
188
+ mode = fs.statSync(tokenFile.trim()).mode;
189
+ } catch {
190
+ // unreadable: reported below
191
+ }
192
+ if ((mode & 0o077) !== 0)
193
+ return failure(
194
+ "blocked",
195
+ `workspace ${workspace?.name ?? "?"} vcs.tokenFile is readable by group or other (mode ${(mode & 0o777).toString(8).padStart(4, "0")})`,
196
+ `chmod 600 ${tokenFile.trim()}`,
197
+ );
198
+ }
184
199
  let token = "";
185
200
  try {
186
201
  token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
package/src/git/ops.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from "../core/workspaces";
50
50
  import { redactText } from "../forge/redact";
51
51
  import { failure, success, type ForgeResult } from "../forge/types";
52
+ import { issuedBinding, sessionClaimRefusal } from "../host-session";
52
53
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
53
54
  import {
54
55
  GIT_TIMEOUTS,
@@ -649,6 +650,25 @@ export function gitCommit(
649
650
  "invalid_input",
650
651
  "WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
651
652
  );
653
+ // A commit names its author session (commit.recorded, the trailer): the
654
+ // same claim check as a verdict, and an id issued to a verifier or
655
+ // reviewer never authors code (audit mv83zyg6 P2b).
656
+ if (session) {
657
+ const env = input.env ?? process.env;
658
+ if (issuedBinding(cwd, session) !== null)
659
+ return failure(
660
+ "blocked",
661
+ `judge_session: ${session} was issued to a verifier or reviewer subagent; it records verdicts and never authors commits`,
662
+ "commit from the implementer's or the lead's own session (unset WORKIT_SESSION_ID back to the shell's)",
663
+ );
664
+ const refusal = sessionClaimRefusal(env, session, cwd);
665
+ if (refusal)
666
+ return failure(
667
+ "blocked",
668
+ refusal.error,
669
+ "commit from the session this shell really is: never set WORKIT_SESSION_ID by hand for a commit (unset it, or use the id the host put in this shell)",
670
+ );
671
+ }
652
672
  // A commit that only rewords (amend) or is empty on purpose needs no change.
653
673
  const changeRequired = !input.amend && !input.allowEmpty;
654
674
 
@@ -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 {
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
 
package/src/ledger.ts CHANGED
@@ -38,8 +38,12 @@
38
38
  // the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
39
39
  // running chain. A row without it after the chain began, or whose hashes do
40
40
  // not match, is reported as unverified (`workit ledger verify-integrity`, a
41
- // warning in `ledger check`). Nothing is refused: this catches a hand edit
42
- // made by mistake, it is not a defense against a forger.
41
+ // warning in `ledger check`). Appends are never refused, but verdict gates
42
+ // (`checkVerdicts`) never accept an unverified or pre-chain verdict, nor a
43
+ // `--session` verdict whose id no SubagentStart hook issued, and ignore
44
+ // an unverified row's `supersedes`. The chain is keyless: it stops a row
45
+ // appended or edited by hand, not a forger who recomputes the hashes.
46
+ // The file is created 0600 (POSIX).
43
47
  // - Branch renames: rows keyed to a branch's earlier names (from its reflog)
44
48
  // count for it, up to the rename.
45
49
  //
@@ -49,9 +53,10 @@ import { spawnSync } from "node:child_process";
49
53
  import { createHash, randomBytes } from "node:crypto";
50
54
  import fs from "node:fs";
51
55
  import path from "node:path";
56
+ import { hostAwareLockTimeout } from "./core/store-lock";
52
57
  import { vcsConfig } from "./core/vcs-config";
53
58
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
54
- import { hostSessionFromEnv } from "./host-session";
59
+ import { hostSessionFromEnv, issuedBinding } from "./host-session";
55
60
  import { resolveStore } from "./store/paths";
56
61
 
57
62
  export const LEDGER_VERSION = 1;
@@ -146,6 +151,8 @@ export type VerdictRow = RowCommon & {
146
151
  self: boolean;
147
152
  selfReason: SelfReason | null;
148
153
  sessionSource: SessionSource;
154
+ /** The issued session's own subagent presented the token its hook bound the id to. */
155
+ sessionBound?: boolean;
149
156
  evidenceRefs: string[];
150
157
  };
151
158
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
@@ -171,8 +178,12 @@ export type LedgerRow =
171
178
  * the ledger had a chain).
172
179
  */
173
180
  export type RowIntegrity = "verified" | "unverified" | "legacy";
174
- /** unsigned: no chain fields (hand-appended) · hash_mismatch: edited · broken_link: a row before it was edited or removed. */
175
- export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link";
181
+ /**
182
+ * unsigned: no chain fields (hand-appended) · hash_mismatch: edited ·
183
+ * broken_link: a row before it was edited or removed · duplicate_id: a copy
184
+ * of an earlier row (a replayed line).
185
+ */
186
+ export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link" | "duplicate_id";
176
187
  /** A row as read back: any known or future type, plus its position. */
177
188
  export type ReadRow = RowCommon &
178
189
  Record<string, unknown> & {
@@ -182,6 +193,8 @@ export type ReadRow = RowCommon &
182
193
  supersedeIgnored: boolean;
183
194
  /** Set only when read with `{ integrity: true }`. */
184
195
  integrity?: RowIntegrity;
196
+ /** Why an `unverified` row is unverified. */
197
+ integrityReason?: UnverifiedReason;
185
198
  };
186
199
 
187
200
  export type LedgerError = {
@@ -497,11 +510,15 @@ export const ledgerLock = {
497
510
  },
498
511
  };
499
512
 
500
- /** Run `fn` holding the ledger's advisory lock; null when it stays held for `waitMs`. */
513
+ /**
514
+ * Run `fn` holding the ledger's advisory lock; null when it stays held for
515
+ * `waitMs`. The wait blocks the thread, so in-process hosts (OpenCode, Pi,
516
+ * MCP) wait at most 250 ms by default; the CLI keeps LOCK_WAIT_MS.
517
+ */
501
518
  export function withLedgerLock<T>(
502
519
  file: string,
503
520
  fn: () => T,
504
- waitMs: number = LOCK_WAIT_MS,
521
+ waitMs: number = hostAwareLockTimeout(LOCK_WAIT_MS),
505
522
  ): T | null {
506
523
  const lock = `${file}.lock`;
507
524
  const deadline = Date.now() + waitMs;
@@ -569,6 +586,15 @@ const lastLine = (fd: number, size: number): string | null => {
569
586
  return null;
570
587
  };
571
588
 
589
+ /** A ledger created before 0600 loses its group/other bits on the next append. */
590
+ const tighten = (fd: number): void => {
591
+ try {
592
+ if ((fs.fstatSync(fd).mode & 0o077) !== 0) fs.fchmodSync(fd, 0o600);
593
+ } catch {
594
+ // Not the owner (a shared store): the append still goes through.
595
+ }
596
+ };
597
+
572
598
  /** The line to write, given the file's current last line (null: empty file). */
573
599
  type LineBuilder = (previous: string | null) => LedgerResult<string>;
574
600
 
@@ -576,8 +602,10 @@ function appendLine(file: string, build: LineBuilder, options: AppendOptions): L
576
602
  const write = (): LedgerResult<void> => {
577
603
  // a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
578
604
  // peek at the tail to chain onto the last row and fence off a torn one.
579
- const fd = fs.openSync(file, "a+");
605
+ // 0600: rows name sessions, branches and verdict notes (audit N14).
606
+ const fd = fs.openSync(file, "a+", 0o600);
580
607
  try {
608
+ if (process.platform !== "win32") tighten(fd);
581
609
  const size = fs.fstatSync(fd).size;
582
610
  let fence = "";
583
611
  if (size > 0) {
@@ -774,7 +802,9 @@ export function readLedger(
774
802
  const file = ledgerPath(cwd);
775
803
  if (!file.ok) return file;
776
804
  let text = "";
805
+ let stat: fs.Stats | null = null;
777
806
  try {
807
+ stat = fs.statSync(file.value);
778
808
  text = fs.readFileSync(file.value, "utf8");
779
809
  } catch (error) {
780
810
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
@@ -798,6 +828,7 @@ export function readLedger(
798
828
  // link. An edited row fails its own hash and breaks every link to it.
799
829
  const earlier = new Set<string | null>([null]);
800
830
  let chainStarted = false;
831
+ const ids = new Set<string>();
801
832
  for (const raw of text.split("\n")) {
802
833
  const trimmed = raw.trim();
803
834
  if (!trimmed) continue;
@@ -827,6 +858,9 @@ export function readLedger(
827
858
  else if (!linked(prevHash)) reason = "broken_link";
828
859
  } else if (chainStarted) reason = "unsigned";
829
860
  else rowIntegrity = "legacy";
861
+ // A replayed copy of an earlier line hashes and links like the original.
862
+ if (options.integrity && !reason && ids.has(parsed.id as string)) reason = "duplicate_id";
863
+ if (options.integrity) ids.add(parsed.id as string);
830
864
  if (reason) rowIntegrity = "unverified";
831
865
  const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
832
866
  const row: ReadRow = {
@@ -850,6 +884,7 @@ export function readLedger(
850
884
  superseded: false,
851
885
  supersedeIgnored: false,
852
886
  ...(rowIntegrity ? { integrity: rowIntegrity } : {}),
887
+ ...(reason ? { integrityReason: reason } : {}),
853
888
  };
854
889
  rows.push(row);
855
890
  if (!options.integrity) continue;
@@ -866,7 +901,8 @@ export function readLedger(
866
901
  }
867
902
  // Only a well-formed supersede link counts; anything else is ignored. A
868
903
  // link across branch names is checked against the renames (reflog read
869
- // only for such links, once per branch).
904
+ // only for such links, once per branch). An unverified row's link never
905
+ // counts: a hand-appended row cannot retire another one (N13).
870
906
  const scopes = new Map<string, BranchScope>();
871
907
  const scopeOf = (branch: string): BranchScope => {
872
908
  let scope = scopes.get(branch);
@@ -876,17 +912,71 @@ export function readLedger(
876
912
  const byId = new Map<string, ReadRow>();
877
913
  for (const row of rows) {
878
914
  const target = row.supersedes ? byId.get(row.supersedes) : undefined;
879
- if (target && supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null)
915
+ if (
916
+ target &&
917
+ row.integrity !== "unverified" &&
918
+ supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null
919
+ )
880
920
  target.superseded = true;
881
921
  else if (row.supersedes) row.supersedeIgnored = true;
882
922
  if (!byId.has(row.id)) byId.set(row.id, row);
883
923
  }
924
+ // A read that saw exactly the stat'ed file remembers its chain breaks.
925
+ if (options.integrity && stat && Buffer.byteLength(text) === stat.size)
926
+ tamperCache.set(file.value, {
927
+ size: stat.size,
928
+ stamp: fileStamp(stat),
929
+ breaks: integrity.unverified.filter((row) => TAMPER_REASONS.has(row.reason)),
930
+ });
884
931
  return {
885
932
  ok: true,
886
933
  value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
887
934
  };
888
935
  }
889
936
 
937
+ /** Chain breaks honest appends never produce: a row edited, removed or moved. */
938
+ const TAMPER_REASONS: ReadonlySet<UnverifiedReason> = new Set(["hash_mismatch", "broken_link"]);
939
+ const tamperCache = new Map<string, { size: number; stamp: string; breaks: UnverifiedRow[] }>();
940
+ /** Inode, mtime and ctime: `touch -r` can restore an mtime, never a ctime. */
941
+ const fileStamp = (stat: fs.Stats): string => `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
942
+
943
+ /**
944
+ * Rows anywhere in the ledger whose chain shows an edit, a removal or a move
945
+ * (`hash_mismatch`, `broken_link`). Cached per file size and mtime; empty
946
+ * when the ledger cannot be read.
947
+ */
948
+ export function ledgerTampering(cwd: string): UnverifiedRow[] {
949
+ const file = ledgerPath(cwd);
950
+ if (!file.ok) return [];
951
+ try {
952
+ const stat = fs.statSync(file.value);
953
+ const cached = tamperCache.get(file.value);
954
+ if (cached && cached.size === stat.size && cached.stamp === fileStamp(stat))
955
+ return cached.breaks;
956
+ } catch {
957
+ return [];
958
+ }
959
+ const read = readLedger(cwd, { integrity: true });
960
+ return read.ok
961
+ ? (read.value.integrity?.unverified ?? []).filter((row) => TAMPER_REASONS.has(row.reason))
962
+ : [];
963
+ }
964
+
965
+ /** How to repair a tampered ledger: see what broke, then start a new one and re-record. */
966
+ export function tamperedUnblock(cwd: string): string {
967
+ const file = ledgerPath(cwd);
968
+ const where = file.ok ? file.value : "the ledger file";
969
+ return `workit ledger verify-integrity # lists the edited or missing rows; if the edit is understood, move ${where} aside (mv ${where} ${where}.tampered-<date>) and record the verdicts again`;
970
+ }
971
+
972
+ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string =>
973
+ `the ledger was edited, or a row was removed or moved (${breaks
974
+ .slice(0, 3)
975
+ .map((row) => `${row.reason} at row ${row.seq}`)
976
+ .join(
977
+ ", ",
978
+ )}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
979
+
890
980
  export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
891
981
 
892
982
  export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
@@ -1002,7 +1092,21 @@ export type RejectReason =
1002
1092
  | "failing_verdict"
1003
1093
  /** The only passing current verdict is `type-check-only`, which proves no behavior. */
1004
1094
  | "type_check_only"
1005
- | "no_verdict";
1095
+ | "no_verdict"
1096
+ /** The verdict row is outside the CLI's hash chain: appended by hand, or pre-chain. */
1097
+ | "unsigned"
1098
+ /** The verdict row was edited after the CLI wrote it. */
1099
+ | "hash_mismatch"
1100
+ /** A row before the verdict was edited or removed. */
1101
+ | "broken_link"
1102
+ /** The verdict row repeats an earlier row's id (a replayed line). */
1103
+ | "duplicate_id"
1104
+ /** A `--session` verdict whose id no SubagentStart hook issued, recorded on a host. */
1105
+ | "unissued_session"
1106
+ /** An issued id recorded without the token its hook bound it to (another session reusing it). */
1107
+ | "unbound_session"
1108
+ /** A row somewhere in the ledger was edited, removed or moved: no verdict is accepted until it is repaired. */
1109
+ | "ledger_tampered";
1006
1110
 
1007
1111
  export type VerdictEntry = {
1008
1112
  kind: string;
@@ -1042,8 +1146,8 @@ export type VerdictCheck = {
1042
1146
  warnings: string[];
1043
1147
  };
1044
1148
 
1045
- const isLiveVerdict = (row: ReadRow): boolean =>
1046
- row.type === "verdict" && typeof row.result === "string" && !row.superseded;
1149
+ const isLiveVerdict = (row: ReadRow, retired: ReadonlySet<string>): boolean =>
1150
+ row.type === "verdict" && typeof row.result === "string" && !retired.has(row.id);
1047
1151
 
1048
1152
  /** Sessions recorded in `Workit-Session:` trailers of `base...head` commits. */
1049
1153
  function trailerSessions(cwd: string, base: string | null, head: string | null): string[] {
@@ -1161,6 +1265,83 @@ const authoredBy = (authors: Set<string>, session: string): boolean => {
1161
1265
  return false;
1162
1266
  };
1163
1267
 
1268
+ /** Reasons a gate cannot trust a verdict row at all, whoever it names. */
1269
+ const TRUST_REASONS: ReadonlySet<RejectReason> = new Set([
1270
+ "unsigned",
1271
+ "hash_mismatch",
1272
+ "broken_link",
1273
+ "duplicate_id",
1274
+ "unissued_session",
1275
+ "unbound_session",
1276
+ ]);
1277
+
1278
+ /**
1279
+ * Hosts whose shell itself names the session (OpenCode, Pi, Codex), and no
1280
+ * host at all (`cli`: CI or a terminal, where any id is a declaration): an
1281
+ * environment session from them was checked against that shell when recorded.
1282
+ */
1283
+ const SHELL_VOUCHED_HOSTS: ReadonlySet<string> = new Set([
1284
+ "cli",
1285
+ "opencode",
1286
+ "pi",
1287
+ "codex_cli",
1288
+ "codex_desktop",
1289
+ ]);
1290
+
1291
+ /**
1292
+ * A verdict acting as a session nothing vouches for (`ledger verdict` refuses
1293
+ * these when recording; rows by an older CLI or by hand are caught here):
1294
+ * - `unissued_session`: a `--session` id on a host, or a `<lead>:<x>`
1295
+ * environment id on a host that does not name it in its shell (Claude
1296
+ * Code, Cursor, any declared WORKIT_HOST), that no SubagentStart hook issued;
1297
+ * - `unbound_session`: such an id the hook bound to a token, recorded
1298
+ * without it (another session reusing an id it read in the ledger).
1299
+ */
1300
+ function sessionTrust(cwd: string, row: ReadRow): RejectReason | null {
1301
+ const session = row.actor.session;
1302
+ if (!session || row.self === true) return null;
1303
+ const source = typeof row.sessionSource === "string" ? row.sessionSource : "env";
1304
+ const host = row.actor.host;
1305
+ const issuedOnly =
1306
+ source === "flag"
1307
+ ? host !== "cli"
1308
+ : source === "env" && session.includes(":") && !SHELL_VOUCHED_HOSTS.has(host);
1309
+ if (!issuedOnly) return null;
1310
+ const binding = issuedBinding(cwd, session);
1311
+ if (binding === null) return "unissued_session";
1312
+ return binding === "token" && row.sessionBound !== true ? "unbound_session" : null;
1313
+ }
1314
+
1315
+ /**
1316
+ * Why a gate cannot trust `row` (D18, audit N1): outside the hash chain, or
1317
+ * acting as a session nothing vouches for. Rows read without integrity are unsigned.
1318
+ */
1319
+ function trustReasons(cwd: string, row: ReadRow): RejectReason[] {
1320
+ if (row.integrity === "unverified") return [row.integrityReason ?? "unsigned"];
1321
+ if (row.integrity !== "verified") return ["unsigned"];
1322
+ const session = sessionTrust(cwd, row);
1323
+ return session ? [session] : [];
1324
+ }
1325
+
1326
+ /**
1327
+ * `rows` with their place in the hash chain. Rows read without integrity are
1328
+ * re-read with it (and with integrity-aware supersede links): every caller of
1329
+ * `checkVerdicts` is a gate. A row the re-read cannot match stays unsigned.
1330
+ */
1331
+ function withIntegrity(cwd: string, rows: readonly ReadRow[]): readonly ReadRow[] {
1332
+ if (rows.every((row) => row.integrity !== undefined)) return rows;
1333
+ const read = readLedger(cwd, { integrity: true });
1334
+ const bySeq = new Map(read.ok ? read.value.rows.map((row) => [row.seq, row]) : []);
1335
+ return rows.map((row) => {
1336
+ if (row.integrity !== undefined) return row;
1337
+ const fresh = bySeq.get(row.seq);
1338
+ return fresh && fresh.id === row.id && fresh.at === row.at ? fresh : row;
1339
+ });
1340
+ }
1341
+
1342
+ const untrusted = (reasons: readonly RejectReason[]): boolean =>
1343
+ reasons.some((reason) => TRUST_REASONS.has(reason));
1344
+
1164
1345
  const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
1165
1346
  const reasons: RejectReason[] = [];
1166
1347
  if (!row.actor.session) reasons.push("no_session");
@@ -1181,7 +1362,7 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
1181
1362
  */
1182
1363
  function effectiveOf(
1183
1364
  rows: readonly ReadRow[],
1184
- authors: Set<string>,
1365
+ reasonsOf: (row: ReadRow) => RejectReason[],
1185
1366
  sameCode: (failed: ReadRow, row: ReadRow) => boolean,
1186
1367
  isCurrent: (row: ReadRow) => boolean,
1187
1368
  ): ReadRow | null {
@@ -1191,8 +1372,10 @@ function effectiveOf(
1191
1372
  effective = row;
1192
1373
  continue;
1193
1374
  }
1194
- const rowIndependent = independenceReasons(row, authors).length === 0;
1195
- const effIndependent = independenceReasons(effective, authors).length === 0;
1375
+ // A row the gate cannot trust never displaces one it can.
1376
+ if (untrusted(reasonsOf(row)) && !untrusted(reasonsOf(effective))) continue;
1377
+ const rowIndependent = reasonsOf(row).length === 0;
1378
+ const effIndependent = reasonsOf(effective).length === 0;
1196
1379
  if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
1197
1380
  if (
1198
1381
  effIndependent &&
@@ -1227,6 +1410,7 @@ function restackCarries(
1227
1410
  if (
1228
1411
  row.type === "stack.restacked" &&
1229
1412
  row.observer === "workit_cli" &&
1413
+ row.integrity === "verified" &&
1230
1414
  row.branch === branch &&
1231
1415
  row.patchEqual === true &&
1232
1416
  typeof row.fromHead === "string" &&
@@ -1248,7 +1432,12 @@ function restackCarries(
1248
1432
  }
1249
1433
 
1250
1434
  /** Current and accepted verdicts for `branch`, after carry-over (D18). */
1251
- export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
1435
+ export function checkVerdicts(
1436
+ cwd: string,
1437
+ branch: string,
1438
+ ledgerRows: readonly ReadRow[],
1439
+ ): VerdictCheck {
1440
+ const rows = withIntegrity(cwd, ledgerRows);
1252
1441
  const head = branchHead(cwd, branch);
1253
1442
  const fallbackBase = defaultBase(cwd);
1254
1443
  const scope = branchScope(cwd, branch);
@@ -1284,20 +1473,69 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1284
1473
  );
1285
1474
  };
1286
1475
  const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
1476
+ // A failure is an author's only when chained ledger rows written before it
1477
+ // made its session an author. `ledger verdict` refuses an author's
1478
+ // independent verdict, so authorship gained afterwards (a forged row, a
1479
+ // commit or an empty amend under the reviewer's id, a trailer) never
1480
+ // discounts a failure (audit N13, mv83zyg6 P2b).
1481
+ const failureAuthorCache = new Map<number, Set<string>>();
1482
+ const failureAuthors = (row: ReadRow): Set<string> => {
1483
+ let set = failureAuthorCache.get(row.seq);
1484
+ if (!set) {
1485
+ set = authorSessions(
1486
+ cwd,
1487
+ rows.filter((other) => other.integrity === "verified" && other.seq < row.seq),
1488
+ branch,
1489
+ { base: null, head: null },
1490
+ scope,
1491
+ );
1492
+ failureAuthorCache.set(row.seq, set);
1493
+ }
1494
+ return set;
1495
+ };
1496
+ const reasonCache = new Map<ReadRow, RejectReason[]>();
1497
+ const reasonsOf = (row: ReadRow): RejectReason[] => {
1498
+ let reasons = reasonCache.get(row);
1499
+ if (!reasons) {
1500
+ const failing = FAILING.has(String(row.result));
1501
+ reasons = [
1502
+ ...trustReasons(cwd, row),
1503
+ ...independenceReasons(row, failing ? failureAuthors(row) : authors),
1504
+ ];
1505
+ reasonCache.set(row, reasons);
1506
+ }
1507
+ return reasons;
1508
+ };
1509
+ // Only a trusted verdict retires another: an unverified row or one acting
1510
+ // as an unissued or unbound session never supersedes (N13, mv83zyg6 P1).
1511
+ const retired = new Set<string>();
1512
+ for (const row of rows)
1513
+ if (
1514
+ row.type === "verdict" &&
1515
+ row.supersedes &&
1516
+ !row.supersedeIgnored &&
1517
+ trustReasons(cwd, row).length === 0
1518
+ )
1519
+ retired.add(row.supersedes);
1287
1520
  const byKind = new Map<string, ReadRow[]>();
1288
1521
  for (const row of rows)
1289
- if (isLiveVerdict(row) && scope(row)) {
1522
+ if (isLiveVerdict(row, retired) && scope(row)) {
1290
1523
  const kind = typeof row.kind === "string" ? row.kind : "review";
1291
1524
  byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
1292
1525
  }
1293
1526
  const verdicts: VerdictEntry[] = [];
1294
1527
  const warnings: string[] = [];
1295
1528
  for (const [kind, list] of byKind) {
1296
- const row = effectiveOf(list, authors, sameCode, (candidate) => basisOf(candidate) !== "stale");
1529
+ const row = effectiveOf(
1530
+ list,
1531
+ reasonsOf,
1532
+ sameCode,
1533
+ (candidate) => basisOf(candidate) !== "stale",
1534
+ );
1297
1535
  if (!row) continue;
1298
1536
  const basis = basisOf(row);
1299
1537
  const current = basis !== "stale";
1300
- const independence = independenceReasons(row, authors);
1538
+ const independence = reasonsOf(row);
1301
1539
  const reasons: RejectReason[] = [
1302
1540
  ...(current ? [] : (["stale"] as const)),
1303
1541
  ...(PASSING.has(String(row.result)) ? [] : (["not_passing"] as const)),
@@ -1318,7 +1556,8 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1318
1556
  for (const later of list)
1319
1557
  if (
1320
1558
  later.seq > row.seq &&
1321
- independenceReasons(later, authors).length > 0 &&
1559
+ reasonsOf(later).length > 0 &&
1560
+ !untrusted(reasonsOf(later)) &&
1322
1561
  FAILING.has(String(later.result)) &&
1323
1562
  basisOf(later) !== "stale"
1324
1563
  )
@@ -1331,6 +1570,16 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1331
1570
  warnings.push(
1332
1571
  `ledger row ${row.id} (${row.type}) on ${row.branch ?? branch} is unverified: the workit CLI did not write it or it changed afterwards (workit ledger verify-integrity)`,
1333
1572
  );
1573
+ // An edited, removed or moved row anywhere breaks the chain; honest appends
1574
+ // never do. Until it is repaired no verdict is accepted (mv83zyg6 P3/P4).
1575
+ const tampered = ledgerTampering(cwd);
1576
+ if (tampered.length) {
1577
+ warnings.push(tamperedWarning(cwd, tampered));
1578
+ for (const entry of verdicts) {
1579
+ entry.accepted = false;
1580
+ entry.reasons = [...entry.reasons, "ledger_tampered"];
1581
+ }
1582
+ }
1334
1583
  verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
1335
1584
  const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
1336
1585
  const failing = verdicts.find(
@@ -1340,8 +1589,9 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1340
1589
  verdicts.findLast(
1341
1590
  (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
1342
1591
  ) ?? null;
1343
- const accepted =
1344
- newestAccepted && !failing
1592
+ const accepted = tampered.length
1593
+ ? { accepted: false, verdict: null, reasons: ["ledger_tampered"] as RejectReason[] }
1594
+ : newestAccepted && !failing
1345
1595
  ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
1346
1596
  : {
1347
1597
  accepted: false,
@@ -1358,7 +1608,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1358
1608
  ? undefined
1359
1609
  : verdicts.findLast(
1360
1610
  (entry) =>
1361
- entry.current && !entry.independent && STRONG_RESULTS.has(String(entry.verdict.result)),
1611
+ entry.current &&
1612
+ !entry.independent &&
1613
+ !untrusted(entry.reasons) &&
1614
+ STRONG_RESULTS.has(String(entry.verdict.result)),
1362
1615
  );
1363
1616
  return {
1364
1617
  branch,
@@ -1387,6 +1640,8 @@ export type RecordContext = {
1387
1640
  supersedes?: string;
1388
1641
  /** Where `actor.session` came from (verdicts record it). Default: the environment. */
1389
1642
  sessionSource?: SessionSource;
1643
+ /** The caller presented the token bound to its issued session (verdicts record it). */
1644
+ sessionBound?: boolean;
1390
1645
  now?: Date;
1391
1646
  };
1392
1647
 
@@ -1734,6 +1989,7 @@ export function recordVerdict(
1734
1989
  self,
1735
1990
  selfReason,
1736
1991
  sessionSource: context.sessionSource ?? "env",
1992
+ ...(context.sessionBound === true ? { sessionBound: true } : {}),
1737
1993
  evidenceRefs: input.evidenceRefs ?? [],
1738
1994
  },
1739
1995
  { now: context.now },
package/src/stack.ts CHANGED
@@ -1180,7 +1180,7 @@ export function qualify(
1180
1180
  export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
1181
1181
  const mismatch = repoMismatch(ctx, stack);
1182
1182
  if (mismatch) return mismatch;
1183
- const ledger = readLedger(ctx.cwd);
1183
+ const ledger = readLedger(ctx.cwd, { integrity: true });
1184
1184
  if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
1185
1185
  const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
1186
1186
  const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
@@ -1823,7 +1823,7 @@ export async function landStack(
1823
1823
  absorb(synced.data);
1824
1824
  }
1825
1825
  const ledgerRows = () => {
1826
- const ledger = readLedger(ctx.cwd);
1826
+ const ledger = readLedger(ctx.cwd, { integrity: true });
1827
1827
  return ledger.ok ? ledger.value.rows : [];
1828
1828
  };
1829
1829
  const simulated = new Set<string>();