@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 +1 -1
- package/scripts/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/references/orchestration.md +4 -3
- package/skills/workit-review/SKILL.md +1 -1
- package/src/core/hygiene.ts +37 -1
- package/src/core/store-lock.ts +9 -1
- 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 +6 -3
- package/src/hooks/stop.ts +4 -2
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/package.json
CHANGED
|
@@ -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:
|
|
33
|
-
// dist/, so they are build-time only
|
|
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>`)
|
|
76
|
-
|
|
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
|
package/src/core/hygiene.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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".
|
package/src/core/store-lock.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -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)
|
|
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
|
|
815
|
+
recorded,
|
|
816
|
+
confirmed,
|
|
751
817
|
mergeBack: mergeBack.branches,
|
|
752
818
|
mergeBackTrack: mergeBack.track,
|
|
753
819
|
},
|
package/src/forge/redact.ts
CHANGED
|
@@ -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
|
|
12
|
-
|
|
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,
|
package/src/forge/resolve.ts
CHANGED
|
@@ -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
|
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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(
|
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
|
|
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`).
|
|
42
|
-
//
|
|
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
|
-
/**
|
|
175
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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>();
|