pi-daddy 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/dist/approval-prompt.d.ts +116 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +124 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/approval-store.d.ts +149 -0
- package/dist/approval-store.d.ts.map +1 -0
- package/dist/approval-store.js +311 -0
- package/dist/approval-store.js.map +1 -0
- package/dist/approval.d.ts +221 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +244 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +31 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +45 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog.d.ts +81 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +164 -0
- package/dist/catalog.js.map +1 -0
- package/dist/definitions.d.ts +101 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +182 -0
- package/dist/definitions.js.map +1 -0
- package/dist/delegate.d.ts +133 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +261 -0
- package/dist/delegate.js.map +1 -0
- package/dist/fanout.d.ts +62 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +84 -0
- package/dist/fanout.js.map +1 -0
- package/dist/file-lock.d.ts +54 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +142 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-report.d.ts +106 -0
- package/dist/ledger-report.d.ts.map +1 -0
- package/dist/ledger-report.js +157 -0
- package/dist/ledger-report.js.map +1 -0
- package/dist/ledger.d.ts +139 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +94 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pane-reaper.d.ts +49 -0
- package/dist/pane-reaper.d.ts.map +1 -0
- package/dist/pane-reaper.js +120 -0
- package/dist/pane-reaper.js.map +1 -0
- package/dist/pi-tools.d.ts +20 -0
- package/dist/pi-tools.d.ts.map +1 -0
- package/dist/pi-tools.js +21 -0
- package/dist/pi-tools.js.map +1 -0
- package/dist/propagation.d.ts +185 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +255 -0
- package/dist/propagation.js.map +1 -0
- package/dist/resolve.d.ts +110 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +181 -0
- package/dist/resolve.js.map +1 -0
- package/dist/run-child.d.ts +50 -0
- package/dist/run-child.d.ts.map +1 -0
- package/dist/run-child.js +109 -0
- package/dist/run-child.js.map +1 -0
- package/dist/run-herdr.d.ts +91 -0
- package/dist/run-herdr.d.ts.map +1 -0
- package/dist/run-herdr.js +306 -0
- package/dist/run-herdr.js.map +1 -0
- package/dist/spawn.d.ts +60 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +116 -0
- package/dist/spawn.js.map +1 -0
- package/extensions/approvals.ts +285 -0
- package/extensions/delegation.ts +237 -0
- package/extensions/grants-command.ts +310 -0
- package/extensions/grants.ts +346 -0
- package/extensions/run-delegation.ts +270 -0
- package/extensions/session.ts +234 -0
- package/package.json +122 -0
- package/src/approval-prompt.ts +222 -0
- package/src/approval-store.ts +383 -0
- package/src/approval.ts +364 -0
- package/src/capabilities.ts +51 -0
- package/src/catalog.ts +186 -0
- package/src/definitions.ts +233 -0
- package/src/delegate.ts +383 -0
- package/src/fanout.ts +96 -0
- package/src/file-lock.ts +142 -0
- package/src/index.ts +59 -0
- package/src/ledger-report.ts +227 -0
- package/src/ledger.ts +214 -0
- package/src/pane-reaper.ts +132 -0
- package/src/pi-tools.ts +25 -0
- package/src/propagation.ts +308 -0
- package/src/resolve.ts +235 -0
- package/src/run-child.ts +149 -0
- package/src/run-herdr.ts +357 -0
- package/src/spawn.ts +156 -0
package/src/fanout.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded synchronous fan-out — ADR-0015's option A′, and the cardinality bound ADR-0008 never had.
|
|
3
|
+
*
|
|
4
|
+
* **The gap this closes.** ADR-0008 bounds what each child may *hold* and says nothing about how many
|
|
5
|
+
* children exist. `resolve()` is a set operation; nowhere in `src/` was there a count. Blocking `delegate`
|
|
6
|
+
* bounded cardinality to one *by accident of being blocking*, so the invariant was never tested. Fan-out
|
|
7
|
+
* removes that accident: with `maxDepth: 2` and five children per call, five delegators each spawning five
|
|
8
|
+
* is thirty concurrent model sessions — **every ledger line individually correct and narrow, the aggregate
|
|
9
|
+
* a machine DoS and an uncapped bill.** The critic's verdict on this was that silence is itself a finding.
|
|
10
|
+
*
|
|
11
|
+
* **Why a budget rather than a per-call limit.** A per-call cap of K with depth D still permits K^D
|
|
12
|
+
* descendants, which is the same exponential wearing a smaller number. A *budget* is subtractive and
|
|
13
|
+
* therefore total: a session holding budget B may create at most B descendants in its whole subtree,
|
|
14
|
+
* because it spends from B to spawn and hands each child a share of what is left. That composes across
|
|
15
|
+
* process boundaries with **no shared state** — the same property that makes depth work — so it needs no
|
|
16
|
+
* registry, no lock and no counter file.
|
|
17
|
+
*
|
|
18
|
+
* It is deliberately NOT a concurrency limit. How many run at once is a resource question for the executor;
|
|
19
|
+
* how many may exist at all is a governance question, and this is the governance answer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parseBound } from "./propagation.ts";
|
|
23
|
+
|
|
24
|
+
/** Total descendants a session may create across its entire subtree, when nothing is configured. */
|
|
25
|
+
export const DEFAULT_FANOUT_BUDGET = 8;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hard ceiling on children in a single call, independent of budget.
|
|
29
|
+
*
|
|
30
|
+
* A budget alone would let one call spend all of it at once, and a hundred simultaneous `pi` processes is
|
|
31
|
+
* a different failure from a hundred spread over a session. This is the blast-radius bound; the budget is
|
|
32
|
+
* the total bound. Both are needed because they answer different questions.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_CHILDREN_PER_CALL = 8;
|
|
35
|
+
|
|
36
|
+
/** Read the budget from the environment, failing to the default on absent *or* malformed input. */
|
|
37
|
+
export function budgetFromEnv(raw: string | undefined): number {
|
|
38
|
+
const parsed = parseBound(raw);
|
|
39
|
+
// Malformed and zero both fall back rather than disabling fan-out silently — G7's rule. A budget that a
|
|
40
|
+
// typo can switch off is the A-S4 defect wearing different clothes. To genuinely forbid delegation an
|
|
41
|
+
// operator sets `maxDepth: 0`, which says what it means.
|
|
42
|
+
return parsed === undefined || parsed === null || parsed === 0 ? DEFAULT_FANOUT_BUDGET : parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BudgetSplit {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
reason?: string;
|
|
48
|
+
/** Budget each child receives. */
|
|
49
|
+
perChild: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Spend `count` from a budget and divide the remainder among the children.
|
|
54
|
+
*
|
|
55
|
+
* The parent pays one unit per child it creates *before* sharing what is left, so a subtree can never
|
|
56
|
+
* exceed the budget it started with: spawning is itself an expenditure, not a free act that only its
|
|
57
|
+
* descendants pay for. `Math.floor` on the division means rounding always loses budget rather than
|
|
58
|
+
* inventing it — the safe direction, and the reason a deep tree converges to zero instead of oscillating.
|
|
59
|
+
*/
|
|
60
|
+
export function splitBudget(budget: number, count: number): BudgetSplit {
|
|
61
|
+
if (count <= 0) return { ok: false, reason: "a fan-out needs at least one child", perChild: 0 };
|
|
62
|
+
if (count > MAX_CHILDREN_PER_CALL) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `${count} children exceeds the per-call limit of ${MAX_CHILDREN_PER_CALL}`,
|
|
66
|
+
perChild: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (budget < count) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason:
|
|
73
|
+
`fan-out budget exhausted: ${count} children requested, ${budget} remaining in this subtree ` +
|
|
74
|
+
`(raise PI_GRANTS_FANOUT at the root, or delegate fewer at a time)`,
|
|
75
|
+
perChild: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, perChild: Math.floor((budget - count) / count) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A ledger id that distinguishes siblings.
|
|
83
|
+
*
|
|
84
|
+
* **Review finding F8.** Every child was recorded as `delegate@d1`, so four concurrent children produced
|
|
85
|
+
* four lines identical except `ts` — and two landing in the same millisecond were indistinguishable.
|
|
86
|
+
* ADR-0008 names `parent_id`/`child_id` as the correlation keys, but they were depth *labels* wearing id
|
|
87
|
+
* names, which made the ledger unjoinable to the returned result, to the OS process, or to the child's own
|
|
88
|
+
* lines one level down.
|
|
89
|
+
*
|
|
90
|
+
* The id is hierarchical and derived, not random: a child of `d0` is `d0.1`, its own second child `d0.1.2`.
|
|
91
|
+
* That means a line's ancestry is readable from the id alone with no join at all, and it is reproducible —
|
|
92
|
+
* two runs of the same fan-out produce the same ids, which is what makes a ledger diffable.
|
|
93
|
+
*/
|
|
94
|
+
export function childSpawnId(parentId: string, index: number): string {
|
|
95
|
+
return `${parentId}.${index + 1}`;
|
|
96
|
+
}
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cross-process file lock, used by both files this package writes.
|
|
3
|
+
*
|
|
4
|
+
* **Extracted rather than copied (R-49).** The ledger has had this lock since fan-out made a second writer
|
|
5
|
+
* possible; the approvals store had an unlocked read-modify-write, so session 1 could load, session 2 could
|
|
6
|
+
* revoke, and session 1's next save would **restore the revoked entry** — falsifying a property
|
|
7
|
+
* `approval-store.ts` documents in so many words. The mitigation already existed twenty lines away. A
|
|
8
|
+
* second implementation is how a fix comes to contain a smaller copy of the bug it fixed, which has happened
|
|
9
|
+
* twice in this package (R-38's preview, ADR-0022's republish path), so there is exactly one of these.
|
|
10
|
+
*
|
|
11
|
+
* **The two callers want opposite failure behaviour, and that is the caller's decision, not this module's.**
|
|
12
|
+
* A ledger write that cannot take the lock must fail the delegation closed — a child running with granted
|
|
13
|
+
* capabilities and no audit line is the thing the ledger exists to prevent. An approvals write that cannot
|
|
14
|
+
* take the lock must NOT fail the work: the human already said yes, and the store is a convenience cache
|
|
15
|
+
* (ADR-0020). So this throws `LockTimeoutError`, distinguishable from every other failure, and each caller
|
|
16
|
+
* decides what that means.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { open, readFile, rm, stat } from "node:fs/promises";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
/** How long to wait for another writer to finish before giving up. Short: failing closed beats hanging. */
|
|
23
|
+
export const LOCK_TIMEOUT_MS = 2000;
|
|
24
|
+
/** A lock older than this is treated as abandoned by a killed process and broken. */
|
|
25
|
+
export const STALE_LOCK_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Raised only when the wait ran out. Its own type so a caller can tell "somebody else is writing" from
|
|
29
|
+
* "this filesystem rejected the write", which want different messages and, for the approvals store,
|
|
30
|
+
* different outcomes.
|
|
31
|
+
*/
|
|
32
|
+
export class LockTimeoutError extends Error {
|
|
33
|
+
constructor(label: string) {
|
|
34
|
+
super(`${label} is locked by another writer (waited ${LOCK_TIMEOUT_MS}ms)`);
|
|
35
|
+
this.name = "LockTimeoutError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run `work` while holding an exclusive lock beside `path`.
|
|
41
|
+
*
|
|
42
|
+
* **Why a lock at all.** `O_APPEND` is atomic for one write to a regular file on a POSIX filesystem, and the
|
|
43
|
+
* guarantee does **not** hold on drvfs (`/mnt/c` under WSL2) or NFS — which is exactly where this project
|
|
44
|
+
* runs. The approvals store never had the guarantee anyway: read-modify-write is not one write.
|
|
45
|
+
*
|
|
46
|
+
* **A lock introduces its own failure mode and it is handled deliberately.** A process killed while holding
|
|
47
|
+
* the lock would otherwise block every future write forever, so a lock older than `STALE_LOCK_MS` is broken.
|
|
48
|
+
* Every delete proves ownership first (`removeIfOurs`) — see the token comment in the loop for the two
|
|
49
|
+
* mutual-exclusion breaks that came from not doing so, both reproduced across real OS processes.
|
|
50
|
+
*
|
|
51
|
+
* **What staleness actually measures, stated because it is not what it sounds like.** `STALE_LOCK_MS`
|
|
52
|
+
* compares the lock's mtime to now; it never checks whether the owner is alive. So *any* 10s stall of the
|
|
53
|
+
* holder hands the lock on — a `SIGSTOP`, a laptop suspend, swap thrash, a debugger breakpoint, a long GC
|
|
54
|
+
* pause. Measured on this project's own filesystems, no realistic `work()` comes near it: one ledger append
|
|
55
|
+
* is 0.1ms on ext4 and 21ms on drvfs, and a 10,000-entry `saveApproval` is 30ms / 97ms. Sixteen-way
|
|
56
|
+
* contention raises *waiters'* time, never the holder's — max hold measured at 49ms. So the threshold is
|
|
57
|
+
* two orders of magnitude clear of normal operation and is not guarded against abnormal suspension.
|
|
58
|
+
*
|
|
59
|
+
* The timeout is short *on purpose*: work refused because a file was busy is recoverable and loud, while
|
|
60
|
+
* work that hangs waiting for a lock is neither.
|
|
61
|
+
*/
|
|
62
|
+
export async function withFileLock<T>(path: string, label: string, work: () => Promise<T>): Promise<T> {
|
|
63
|
+
const lockPath = `${path}.lock`;
|
|
64
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
65
|
+
|
|
66
|
+
for (;;) {
|
|
67
|
+
// **A token, and the reason mutual exclusion depends on it (R-67).** `rm(path)` deletes whatever is at
|
|
68
|
+
// the path *now*, not the lock this process created — so the previous version broke its own invariant
|
|
69
|
+
// two ways, both reproduced across real OS processes:
|
|
70
|
+
//
|
|
71
|
+
// - the stale-break `stat` and `rm` are two awaits, so a process descheduled between them could delete
|
|
72
|
+
// a LIVE lock another waiter had just created, and then succeed at its own create: two holders;
|
|
73
|
+
// - worse, the `finally` removed the lock unconditionally. A holder whose lock had been broken out
|
|
74
|
+
// from under it still freed the NEW owner's lock on the way out — and the damage then propagated to
|
|
75
|
+
// processes that raced nothing and observed nothing wrong, which is how one break became a chain.
|
|
76
|
+
//
|
|
77
|
+
// Writing a unique token and re-reading it before every delete makes both inexpressible: this process
|
|
78
|
+
// only ever removes a file it can prove is its own. The docstring used to claim "whichever wins the
|
|
79
|
+
// exclusive create proceeds, which is correct because only one can" — true of the create and false of
|
|
80
|
+
// the delete, which is what made it convincing.
|
|
81
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
82
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
83
|
+
try {
|
|
84
|
+
handle = await open(lockPath, "wx");
|
|
85
|
+
await handle.writeFile(`${token}\n`, "utf8");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// The handle may exist even though the WRITE failed (ENOSPC, EDQUOT, EFBIG). The old code jumped
|
|
88
|
+
// straight here and rethrew, so the already-created lock file was left on disk and its descriptor
|
|
89
|
+
// leaked to GC — an orphan that blocks every writer for a full STALE_LOCK_MS and feeds the EMFILE
|
|
90
|
+
// path below. Close and remove before doing anything else; `ours` is safe because we just made it.
|
|
91
|
+
if (handle) {
|
|
92
|
+
await handle.close().catch(() => undefined);
|
|
93
|
+
await removeIfOurs(lockPath, token);
|
|
94
|
+
handle = undefined;
|
|
95
|
+
}
|
|
96
|
+
const code = (error as { code?: string }).code;
|
|
97
|
+
if (code !== "EEXIST") throw error;
|
|
98
|
+
|
|
99
|
+
// Someone else holds it. Break it only if it is old enough to be abandoned — and only the exact file
|
|
100
|
+
// we judged, so a lock created in the gap survives.
|
|
101
|
+
try {
|
|
102
|
+
const held = await stat(lockPath);
|
|
103
|
+
if (Date.now() - held.mtimeMs > STALE_LOCK_MS) {
|
|
104
|
+
const abandoned = await readFile(lockPath, "utf8").catch(() => undefined);
|
|
105
|
+
if (abandoned !== undefined) await removeIfOurs(lockPath, abandoned.trim());
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* it vanished between the check and the stat — the next attempt will simply take it */
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (Date.now() >= deadline) throw new LockTimeoutError(label);
|
|
112
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
return await work();
|
|
118
|
+
} finally {
|
|
119
|
+
await handle.close().catch(() => undefined);
|
|
120
|
+
// Only if it is still OURS. A lock broken out from under us belongs to somebody else now, and
|
|
121
|
+
// deleting it is what turned one lost race into a cascade.
|
|
122
|
+
await removeIfOurs(lockPath, token);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Delete the lock only if it still holds `token`.
|
|
129
|
+
*
|
|
130
|
+
* Read-then-delete is itself two operations, so this is not atomic either — but it narrows the window from
|
|
131
|
+
* "the whole of `work()`" to "between a read and an unlink", and it removes the *systematic* break entirely:
|
|
132
|
+
* a process can no longer delete a lock it demonstrably never owned.
|
|
133
|
+
*/
|
|
134
|
+
async function removeIfOurs(lockPath: string, token: string): Promise<void> {
|
|
135
|
+
try {
|
|
136
|
+
const held = await readFile(lockPath, "utf8");
|
|
137
|
+
if (held.trim() !== token) return;
|
|
138
|
+
await rm(lockPath, { force: true });
|
|
139
|
+
} catch {
|
|
140
|
+
/* already gone, or unreadable — either way this process is not the one that should force it */
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export {
|
|
2
|
+
resolve,
|
|
3
|
+
assertNarrowing,
|
|
4
|
+
toPiToolsAllowlist,
|
|
5
|
+
UNIVERSAL_CAPABILITIES,
|
|
6
|
+
type Capability,
|
|
7
|
+
type ResolveInput,
|
|
8
|
+
type ResolveResult,
|
|
9
|
+
} from "./resolve.ts";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
appendRecord,
|
|
13
|
+
buildRecord,
|
|
14
|
+
isEscalationAttempt,
|
|
15
|
+
type GrantRecord,
|
|
16
|
+
type LedgerOptions,
|
|
17
|
+
} from "./ledger.ts";
|
|
18
|
+
|
|
19
|
+
export { planSpawn, type SpawnPlan, type SpawnPlanInput } from "./spawn.ts";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
APPROVAL_TTL_DAYS,
|
|
23
|
+
DELEGATE_SUBJECT,
|
|
24
|
+
approvalKey,
|
|
25
|
+
entryVerdict,
|
|
26
|
+
expiryFor,
|
|
27
|
+
inheritApprovals,
|
|
28
|
+
offeredScopes,
|
|
29
|
+
resolveApprovals,
|
|
30
|
+
shouldSeekApproval,
|
|
31
|
+
type ApprovalEntry,
|
|
32
|
+
type ApprovalPath,
|
|
33
|
+
type ApprovalScope,
|
|
34
|
+
type ApprovalSource,
|
|
35
|
+
type EntryVerdict,
|
|
36
|
+
type SubjectSnapshot,
|
|
37
|
+
} from "./approval.ts";
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
approvalsPath,
|
|
41
|
+
loadApprovals,
|
|
42
|
+
revokeAll,
|
|
43
|
+
revokeApproval,
|
|
44
|
+
saveApproval,
|
|
45
|
+
type DroppedApproval,
|
|
46
|
+
type SubjectLookup,
|
|
47
|
+
} from "./approval-store.ts";
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
createApprovalGate,
|
|
51
|
+
createApprovalGateProvider,
|
|
52
|
+
timeoutMsFromEnv,
|
|
53
|
+
type ApprovalGate,
|
|
54
|
+
type InFlightApprovals,
|
|
55
|
+
type ApprovalUI,
|
|
56
|
+
type PromptOutcome,
|
|
57
|
+
type PromptOutcomeKind,
|
|
58
|
+
type PromptRequest,
|
|
59
|
+
} from "./approval-prompt.ts";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a ledger back — integrity, instruction versions, and the ADR-0020 approval tally.
|
|
3
|
+
*
|
|
4
|
+
* Split from `ledger.ts` when the file-size guard refused it at 419 lines. The seam is real rather than
|
|
5
|
+
* arbitrary: `ledger.ts` **writes** one record at a time and fails closed when it cannot, while everything
|
|
6
|
+
* here **reads** a whole file and must never fail at all — a report is a diagnostic, and a diagnostic that
|
|
7
|
+
* throws on damaged input is useless on exactly the input it exists for. Every defect this half has had
|
|
8
|
+
* (R-63's bias, R-64's three malformed shapes) was a *reporting* defect, and none of them could have
|
|
9
|
+
* touched the write path.
|
|
10
|
+
*
|
|
11
|
+
* Re-exported from `./ledger` so the published subpath is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import type { Capability } from "./resolve.ts";
|
|
16
|
+
import { DELEGATE_SUBJECT } from "./approval.ts";
|
|
17
|
+
import type { ApprovalSource } from "./approval.ts";
|
|
18
|
+
import type { GrantRecord } from "./ledger.ts";
|
|
19
|
+
import { isEscalationAttempt } from "./ledger.ts";
|
|
20
|
+
|
|
21
|
+
export interface LedgerReport {
|
|
22
|
+
/** False when the file is absent — a configuration state, not damage. */
|
|
23
|
+
exists: boolean;
|
|
24
|
+
/** Lines that parsed as records. */
|
|
25
|
+
records: number;
|
|
26
|
+
/** Lines that did not, with 1-based line numbers so the report is actionable. */
|
|
27
|
+
corrupt: Array<{ line: number; text: string }>;
|
|
28
|
+
/** Records where an agent asked for more than it held — ADR-0008's designated signal. */
|
|
29
|
+
escalationAttempts: number;
|
|
30
|
+
/**
|
|
31
|
+
* Every distinct set of instructions this ledger saw run, with how many spawns used it (R-51).
|
|
32
|
+
*
|
|
33
|
+
* ADR-0018 advertises that a record answers *"did these four children run the same instructions?"* and
|
|
34
|
+
* *"has this definition changed since?"* — and until this existed **nothing read `definitionDigest` at
|
|
35
|
+
* all**, so both questions required hand-written `jq` and the second was not even reproducible with
|
|
36
|
+
* `sha256sum`, because the digest covers the body and not the frontmatter. A field no tool reads is a
|
|
37
|
+
* field that quietly becomes decoration.
|
|
38
|
+
*
|
|
39
|
+
* Grouped by `name` + `sha256`, so two entries with one name are exactly the evidence that a definition
|
|
40
|
+
* changed mid-ledger. Sorted by name then digest so two runs of the same fan-out produce a diffable
|
|
41
|
+
* report, like the ids themselves.
|
|
42
|
+
*/
|
|
43
|
+
definitions: Array<{ name: string; source: string; sha256: string; spawns: number }>;
|
|
44
|
+
/**
|
|
45
|
+
* Where the yes came from, per approved capability, tallied across the whole ledger.
|
|
46
|
+
*
|
|
47
|
+
* **This is the measurement ADR-0020 asks for.** That ADR keeps the persistence layer on R-25's fatigue
|
|
48
|
+
* argument with *no number behind it*, and named the evidence that would settle it: counting `persisted`
|
|
49
|
+
* against `prompt` over a few weeks of real use. It also said this "needs no new machinery" — true of the
|
|
50
|
+
* data and false of the answer, which required hand-written `jq`. Same shape as R-51: a field no tool
|
|
51
|
+
* reads becomes decoration, and a measurement nobody can run does not get run.
|
|
52
|
+
*
|
|
53
|
+
* **`bySource` counts RECORDS and is an upper bound, not an answer.** Deleting the persistence layer does
|
|
54
|
+
* not turn every `persisted` record back into a prompt: precedence is `inherited → session → persisted →
|
|
55
|
+
* prompt`, and `session` approvals live in memory and do not depend on the store at all. So a session that
|
|
56
|
+
* spawns `deploy` twenty times under one persisted entry writes twenty `persisted` records, while without
|
|
57
|
+
* the store it would raise **one** prompt and satisfy the other nineteen from the session cache. Reporting
|
|
58
|
+
* twenty prompts avoided would overstate the layer's value twentyfold, on the one number that decides
|
|
59
|
+
* whether to keep it — the same direction of bias `unattributed` exists to avoid, arrived at a different way.
|
|
60
|
+
*
|
|
61
|
+
* `distinctBySource` is the closer estimate: distinct `capability@subject` pairs, which bounds the cost of
|
|
62
|
+
* deletion at one prompt per pair per session. The ledger carries no session id, so the exact figure is not
|
|
63
|
+
* computable from it; both numbers are printed and labelled rather than one being presented as the truth.
|
|
64
|
+
*
|
|
65
|
+
* Counted from `approvalSources` **only**. `approvalSource` is deliberately not used as a fallback: before
|
|
66
|
+
* 0.11.1 that scalar was written for the whole set even when the sources differed (R-46), so folding it in
|
|
67
|
+
* would report humans as having been asked about capabilities they were never asked about — biasing the
|
|
68
|
+
* one direction this measurement must not be biased in. Those records are counted as `unattributed`
|
|
69
|
+
* instead, so the sample size is visible rather than silently smaller.
|
|
70
|
+
*/
|
|
71
|
+
approvals: {
|
|
72
|
+
/** Raw record counts. An UPPER bound on prompts avoided — see above before quoting one. */
|
|
73
|
+
bySource: Record<ApprovalSource, number>;
|
|
74
|
+
/** Distinct `capability@subject` pairs per source. The closer estimate. */
|
|
75
|
+
distinctBySource: Record<ApprovalSource, number>;
|
|
76
|
+
/** Records carrying approvals from before per-capability sources existed. Not attributable; see above. */
|
|
77
|
+
unattributed: number;
|
|
78
|
+
/** Records where a human was asked and said no — the fatigue argument's other half. */
|
|
79
|
+
humanDenied: number;
|
|
80
|
+
/**
|
|
81
|
+
* Distinct `capability@subject` pairs a human declined.
|
|
82
|
+
*
|
|
83
|
+
* Same reason `distinctBySource` exists: R-29 shares a decline across every concurrent caller, so one
|
|
84
|
+
* click of *Deny* under an eight-wide fan-out writes eight `humanDenied` records. Reporting the raw
|
|
85
|
+
* count as "times a human declined" is the per-record bias R-63 removed from `persisted`, left in place
|
|
86
|
+
* on the number that argues hardest FOR the layer — which is the direction that flatters this package's
|
|
87
|
+
* own gating and therefore the one to be most careful with.
|
|
88
|
+
*/
|
|
89
|
+
humanDeniedPairs: number;
|
|
90
|
+
};
|
|
91
|
+
ok: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the ledger back and report what is wrong with it.
|
|
96
|
+
*
|
|
97
|
+
* **This is the gap that mattered most.** `appendRecord`'s strict mode catches write *errors*, never
|
|
98
|
+
* corruption, and nothing in this package had ever read a ledger back — so a torn line was silently
|
|
99
|
+
* indistinguishable from a spawn that never happened. An audit trail whose damage is invisible is not a
|
|
100
|
+
* compensating control, and ADR-0008 leans on the ledger as exactly that.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately reports rather than repairs. A corrupt line is evidence; rewriting the file to make it parse
|
|
103
|
+
* would destroy the one artifact an investigation has.
|
|
104
|
+
*/
|
|
105
|
+
export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
106
|
+
let text: string;
|
|
107
|
+
try {
|
|
108
|
+
text = await readFile(path, "utf8");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as { code?: string }).code === "ENOENT") {
|
|
111
|
+
return {
|
|
112
|
+
exists: false,
|
|
113
|
+
records: 0,
|
|
114
|
+
corrupt: [],
|
|
115
|
+
escalationAttempts: 0,
|
|
116
|
+
definitions: [],
|
|
117
|
+
approvals: {
|
|
118
|
+
bySource: { prompt: 0, session: 0, persisted: 0, inherited: 0 },
|
|
119
|
+
distinctBySource: { prompt: 0, session: 0, persisted: 0, inherited: 0 },
|
|
120
|
+
unattributed: 0,
|
|
121
|
+
humanDenied: 0,
|
|
122
|
+
humanDeniedPairs: 0,
|
|
123
|
+
},
|
|
124
|
+
ok: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const corrupt: Array<{ line: number; text: string }> = [];
|
|
131
|
+
// Keyed by name+digest: one name with two entries is the signal that the definition changed mid-ledger.
|
|
132
|
+
const digests = new Map<string, { name: string; source: string; sha256: string; spawns: number }>();
|
|
133
|
+
let records = 0;
|
|
134
|
+
let escalationAttempts = 0;
|
|
135
|
+
const bySource: Record<ApprovalSource, number> = { prompt: 0, session: 0, persisted: 0, inherited: 0 };
|
|
136
|
+
// `capability@subject` seen per source, so the report can state a bound as well as a raw count.
|
|
137
|
+
const distinct: Record<ApprovalSource, Set<string>> = {
|
|
138
|
+
prompt: new Set(),
|
|
139
|
+
session: new Set(),
|
|
140
|
+
persisted: new Set(),
|
|
141
|
+
inherited: new Set(),
|
|
142
|
+
};
|
|
143
|
+
let unattributed = 0;
|
|
144
|
+
let humanDenied = 0;
|
|
145
|
+
const deniedPairs = new Set<string>();
|
|
146
|
+
|
|
147
|
+
const lines = text.split("\n");
|
|
148
|
+
lines.forEach((raw, index) => {
|
|
149
|
+
// A trailing newline yields one empty final element, which is normal rather than damage.
|
|
150
|
+
if (raw.trim().length === 0) return;
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(raw) as GrantRecord;
|
|
153
|
+
if (!Array.isArray(parsed.denied)) throw new Error("not a grant record");
|
|
154
|
+
records += 1;
|
|
155
|
+
if (isEscalationAttempt(parsed)) escalationAttempts += 1;
|
|
156
|
+
if (parsed.humanDenied) {
|
|
157
|
+
humanDenied += 1;
|
|
158
|
+
const subject = parsed.agentType === undefined || parsed.agentType === "delegate" ? DELEGATE_SUBJECT : parsed.agentType;
|
|
159
|
+
for (const capability of parsed.gatedBlocked ?? []) deniedPairs.add(`${capability}@${subject}`);
|
|
160
|
+
}
|
|
161
|
+
// A **plain, non-empty** object. Three shapes were accepted here that must not be, all of them
|
|
162
|
+
// reachable from a torn, hand-edited or foreign line — which is the input class `verifyLedger` exists
|
|
163
|
+
// for, so "this package never writes that" is not a defence:
|
|
164
|
+
// - `{}` beside a non-empty `approved` counted NOWHERE, silently shrinking the sample the comment
|
|
165
|
+
// below promises to keep visible;
|
|
166
|
+
// - an ARRAY passed `typeof === "object"` and was tallied with numeric indices as capability names;
|
|
167
|
+
// - `null` is an object.
|
|
168
|
+
const rawSources = parsed.approvalSources;
|
|
169
|
+
const sources =
|
|
170
|
+
rawSources && typeof rawSources === "object" && !Array.isArray(rawSources) && Object.keys(rawSources).length > 0
|
|
171
|
+
? rawSources
|
|
172
|
+
: undefined;
|
|
173
|
+
if (sources) {
|
|
174
|
+
// The subject half of `capability@subject`. `agentType` is the definition's name, or the literal
|
|
175
|
+
// `"delegate"` for the `tools:` form — which is NOT `DELEGATE_SUBJECT`, the `<delegate>` the approval
|
|
176
|
+
// layer keys on. Mapped here rather than at the write site so old ledgers read correctly too.
|
|
177
|
+
// **Stated limit:** a definition genuinely named `delegate` is indistinguishable from the `tools:`
|
|
178
|
+
// form in this field, and their two distinct approvals count as one pair. `DELEGATE_SUBJECT`'s own
|
|
179
|
+
// angle brackets exist to make that collision impossible, and the ledger drops them.
|
|
180
|
+
const subject = parsed.agentType === undefined || parsed.agentType === "delegate" ? DELEGATE_SUBJECT : parsed.agentType;
|
|
181
|
+
for (const [capability, source] of Object.entries(sources)) {
|
|
182
|
+
// `Object.hasOwn`, never `in`: `in` walks the prototype, so a source of `"toString"` or
|
|
183
|
+
// `"valueOf"` passed the check, wrote a STRING into a counter, made `attributed` a string, and
|
|
184
|
+
// deleted the entire measurement from the report while marking an intact ledger corrupt.
|
|
185
|
+
// An unrecognised source is counted as unattributed rather than dropped: a tally that silently
|
|
186
|
+
// ignores what it does not understand reports a smaller sample as a cleaner one.
|
|
187
|
+
if (Object.hasOwn(bySource, source)) {
|
|
188
|
+
bySource[source] += 1;
|
|
189
|
+
distinct[source].add(`${capability}@${subject}`);
|
|
190
|
+
} else unattributed += 1;
|
|
191
|
+
}
|
|
192
|
+
} else if (parsed.approved && parsed.approved.length > 0) {
|
|
193
|
+
unattributed += parsed.approved.length;
|
|
194
|
+
}
|
|
195
|
+
const d = parsed.definitionDigest;
|
|
196
|
+
if (d?.name && d.sha256) {
|
|
197
|
+
const key = `${d.name}\u0000${d.sha256}`;
|
|
198
|
+
const seen = digests.get(key);
|
|
199
|
+
if (seen) seen.spawns += 1;
|
|
200
|
+
else digests.set(key, { name: d.name, source: d.source, sha256: d.sha256, spawns: 1 });
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
corrupt.push({ line: index + 1, text: raw.slice(0, 120) });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
exists: true,
|
|
209
|
+
records,
|
|
210
|
+
corrupt,
|
|
211
|
+
escalationAttempts,
|
|
212
|
+
definitions: [...digests.values()].sort((a, b) => a.name.localeCompare(b.name) || a.sha256.localeCompare(b.sha256)),
|
|
213
|
+
approvals: {
|
|
214
|
+
bySource,
|
|
215
|
+
distinctBySource: {
|
|
216
|
+
prompt: distinct.prompt.size,
|
|
217
|
+
session: distinct.session.size,
|
|
218
|
+
persisted: distinct.persisted.size,
|
|
219
|
+
inherited: distinct.inherited.size,
|
|
220
|
+
},
|
|
221
|
+
unattributed,
|
|
222
|
+
humanDenied,
|
|
223
|
+
humanDeniedPairs: deniedPairs.size,
|
|
224
|
+
},
|
|
225
|
+
ok: corrupt.length === 0,
|
|
226
|
+
};
|
|
227
|
+
}
|