pi-daddy 0.17.0 → 0.18.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 +154 -80
- package/README.md +54 -25
- package/dist/approval-prompt.d.ts +3 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +1 -1
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval-store.d.ts.map +1 -1
- package/dist/approval-store.js +4 -1
- package/dist/approval-store.js.map +1 -1
- package/dist/approval.d.ts +20 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +26 -6
- package/dist/approval.js.map +1 -1
- package/dist/chain.d.ts +6 -1
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/check-runner.d.ts +61 -0
- package/dist/check-runner.d.ts.map +1 -0
- package/dist/check-runner.js +237 -0
- package/dist/check-runner.js.map +1 -0
- package/dist/correlation.d.ts +90 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +183 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delegate-types.d.ts +140 -0
- package/dist/delegate-types.d.ts.map +1 -0
- package/dist/delegate-types.js +8 -0
- package/dist/delegate-types.js.map +1 -0
- package/dist/delegate.d.ts +4 -128
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +68 -72
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts +36 -0
- package/dist/delegation-approval.d.ts.map +1 -0
- package/dist/delegation-approval.js +67 -0
- package/dist/delegation-approval.js.map +1 -0
- package/dist/git-identity.d.ts +13 -0
- package/dist/git-identity.d.ts.map +1 -0
- package/dist/git-identity.js +44 -0
- package/dist/git-identity.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-record.d.ts +78 -0
- package/dist/lease-record.d.ts.map +1 -0
- package/dist/lease-record.js +52 -0
- package/dist/lease-record.js.map +1 -0
- package/dist/ledger-events.d.ts +87 -0
- package/dist/ledger-events.d.ts.map +1 -0
- package/dist/ledger-events.js +45 -0
- package/dist/ledger-events.js.map +1 -0
- package/dist/ledger-report.d.ts +19 -12
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +81 -1
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +44 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +20 -2
- package/dist/ledger.js.map +1 -1
- package/dist/refusals.d.ts +16 -0
- package/dist/refusals.d.ts.map +1 -0
- package/dist/refusals.js +51 -0
- package/dist/refusals.js.map +1 -0
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +21 -1
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +7 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +41 -12
- package/dist/run-herdr.js.map +1 -1
- package/dist/workspace-lease.d.ts +28 -0
- package/dist/workspace-lease.d.ts.map +1 -0
- package/dist/workspace-lease.js +276 -0
- package/dist/workspace-lease.js.map +1 -0
- package/dist/workspace.d.ts +32 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +79 -0
- package/dist/workspace.js.map +1 -0
- package/extensions/approval-banking.ts +66 -0
- package/extensions/approvals.ts +95 -7
- package/extensions/chain-approval-facts.ts +51 -0
- package/extensions/chain-ledger.ts +48 -0
- package/extensions/delegate-chain.ts +115 -81
- package/extensions/delegation.ts +81 -23
- package/extensions/execute-child.ts +289 -0
- package/extensions/fanout-outcome.ts +97 -0
- package/extensions/run-delegation.ts +130 -120
- package/extensions/session.ts +4 -0
- package/extensions/workspace-runtime.ts +165 -0
- package/package.json +17 -1
- package/src/approval-prompt.ts +4 -2
- package/src/approval-store.ts +4 -1
- package/src/approval.ts +47 -14
- package/src/chain.ts +3 -1
- package/src/check-runner.ts +341 -0
- package/src/correlation.ts +260 -0
- package/src/delegate-types.ts +144 -0
- package/src/delegate.ts +80 -179
- package/src/delegation-approval.ts +99 -0
- package/src/git-identity.ts +52 -0
- package/src/index.ts +50 -0
- package/src/lease-record.ts +119 -0
- package/src/ledger-events.ts +138 -0
- package/src/ledger-report.ts +90 -2
- package/src/ledger.ts +68 -3
- package/src/refusals.ts +66 -0
- package/src/run-child.ts +23 -1
- package/src/run-herdr.ts +41 -11
- package/src/workspace-lease.ts +303 -0
- package/src/workspace.ts +135 -0
package/src/approval.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { WILDCARD } from "./pi-tools.ts";
|
|
13
13
|
import type { Capability, ResolveResult } from "./resolve.ts";
|
|
14
|
+
import { approvalBindingsEqual, type ApprovalBinding } from "./correlation.ts";
|
|
14
15
|
|
|
15
16
|
/** How far a single yes reaches in time. */
|
|
16
17
|
export type ApprovalScope = "once" | "session" | "always";
|
|
@@ -110,6 +111,11 @@ export interface InheritableApproval {
|
|
|
110
111
|
* no file to hash — and, per ADR-0019, is never persisted or offered `always` for the same reason.
|
|
111
112
|
*/
|
|
112
113
|
bodySha256?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Exact delegation binding for controllers that supply correlation/workspace context (ADR-0034).
|
|
116
|
+
* Bound approvals are deliberately not inherited: authority for one task must not become subtree-wide.
|
|
117
|
+
*/
|
|
118
|
+
binding?: ApprovalBinding;
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
/**
|
|
@@ -133,7 +139,7 @@ export function inheritApprovals(approved: InheritableApproval[], grant: Capabil
|
|
|
133
139
|
return [
|
|
134
140
|
...new Set(
|
|
135
141
|
approved
|
|
136
|
-
.filter((a) => a.scope !== "once" && a.capability !== WILDCARD && held.has(a.capability))
|
|
142
|
+
.filter((a) => a.scope !== "once" && a.binding === undefined && a.capability !== WILDCARD && held.has(a.capability))
|
|
137
143
|
// A definition subject MUST carry a pin to cross a boundary (ADR-0022, hardened after F1).
|
|
138
144
|
//
|
|
139
145
|
// `verifyInherited` honours an unpinned entry by decision — `<delegate>` names no file and a
|
|
@@ -234,6 +240,8 @@ export interface ApprovalEntry {
|
|
|
234
240
|
* predates 0.10.0 and cannot be verified, which `entryVerdict` treats as changed: fail closed.
|
|
235
241
|
*/
|
|
236
242
|
bodyAtApproval?: string;
|
|
243
|
+
/** Exact task/grant/workspace/context/parent binding for correlated controller calls (ADR-0034). */
|
|
244
|
+
binding?: ApprovalBinding;
|
|
237
245
|
}
|
|
238
246
|
|
|
239
247
|
export type EntryVerdict =
|
|
@@ -310,9 +318,14 @@ export interface ResolveApprovalsInput {
|
|
|
310
318
|
subject: string;
|
|
311
319
|
/** Approval KEYS approved for this session, in memory only. */
|
|
312
320
|
sessionApprovals: ReadonlySet<string>;
|
|
313
|
-
/**
|
|
321
|
+
/** Exact bindings for correlated session approvals. Kept separate so legacy keys remain compatible. */
|
|
322
|
+
sessionApprovalBindings?: ReadonlyMap<string, ApprovalBinding>;
|
|
323
|
+
/** Persisted entries by key, ALREADY definition/expiry-filtered by the store. */
|
|
314
324
|
persisted: ReadonlyMap<string, ApprovalEntry>;
|
|
315
|
-
/**
|
|
325
|
+
/** When present, only an exact binding may satisfy this gate. */
|
|
326
|
+
expectedBinding?: ApprovalBinding;
|
|
327
|
+
/** Same-key persisted entries dropped specifically because their expiry passed. */
|
|
328
|
+
expiredKeys?: ReadonlySet<string>;
|
|
316
329
|
/**
|
|
317
330
|
* `capability@subject` keys inherited from the delegator (ADR-0014).
|
|
318
331
|
*
|
|
@@ -326,7 +339,12 @@ export interface ResolveApprovalsResult {
|
|
|
326
339
|
approved: Capability[];
|
|
327
340
|
/** Gated capabilities still requiring a live human. */
|
|
328
341
|
needsPrompt: Capability[];
|
|
342
|
+
/** A same-key approval existed but named another task/workspace/context/parent. */
|
|
343
|
+
scopeMismatched: Capability[];
|
|
344
|
+
expired: Capability[];
|
|
329
345
|
sources: Record<Capability, ApprovalSource>;
|
|
346
|
+
scopes: Record<Capability, ApprovalScope>;
|
|
347
|
+
expiresAt: Record<Capability, string>;
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
/**
|
|
@@ -342,23 +360,38 @@ export function resolveApprovals(input: ResolveApprovalsInput): ResolveApprovals
|
|
|
342
360
|
const inherited = input.inherited ?? new Set<string>();
|
|
343
361
|
const approved: Capability[] = [];
|
|
344
362
|
const needsPrompt: Capability[] = [];
|
|
345
|
-
const
|
|
363
|
+
const scopeMismatched: Capability[] = [];
|
|
364
|
+
const expired: Capability[] = [];
|
|
365
|
+
const sources: Record<Capability, ApprovalSource> = {}, scopes: Record<Capability, ApprovalScope> = {};
|
|
366
|
+
const expiresAt: Record<Capability, string> = {};
|
|
346
367
|
|
|
347
368
|
for (const capability of [...new Set(input.gated)].sort()) {
|
|
348
369
|
const key = approvalKey(capability, input.subject);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
const expected = input.expectedBinding;
|
|
371
|
+
const sessionBinding = input.sessionApprovalBindings?.get(key);
|
|
372
|
+
const persisted = input.persisted.get(key);
|
|
373
|
+
const inheritedApplies = expected === undefined && inherited.has(key);
|
|
374
|
+
const legacySessionApplies = expected === undefined && input.sessionApprovals.has(key);
|
|
375
|
+
const boundSessionApplies = expected !== undefined && approvalBindingsEqual(sessionBinding, expected);
|
|
376
|
+
const persistedApplies = persisted !== undefined && (
|
|
377
|
+
expected === undefined ? persisted.binding === undefined : approvalBindingsEqual(persisted.binding, expected)
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
if (inheritedApplies) {
|
|
381
|
+
approved.push(capability); sources[capability] = "inherited"; scopes[capability] = "session";
|
|
382
|
+
} else if (legacySessionApplies || boundSessionApplies) {
|
|
383
|
+
approved.push(capability); sources[capability] = "session"; scopes[capability] = "session";
|
|
384
|
+
} else if (persistedApplies) {
|
|
385
|
+
approved.push(capability); sources[capability] = "persisted"; scopes[capability] = "always";
|
|
386
|
+
expiresAt[capability] = persisted!.expiresAt;
|
|
358
387
|
} else {
|
|
359
388
|
needsPrompt.push(capability);
|
|
389
|
+
if (input.expiredKeys?.has(key)) expired.push(capability);
|
|
390
|
+
else if (expected !== undefined && (sessionBinding !== undefined || persisted !== undefined || input.sessionApprovals.has(key))) {
|
|
391
|
+
scopeMismatched.push(capability);
|
|
392
|
+
}
|
|
360
393
|
}
|
|
361
394
|
}
|
|
362
395
|
|
|
363
|
-
return { approved, needsPrompt, sources };
|
|
396
|
+
return { approved, needsPrompt, scopeMismatched, expired, sources, scopes, expiresAt };
|
|
364
397
|
}
|
package/src/chain.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { randomBytes } from "node:crypto";
|
|
|
29
29
|
* How much of a step's output crosses to the next step.
|
|
30
30
|
*
|
|
31
31
|
* A child may return up to `DEFAULT_MAX_OUTPUT_BYTES` (1 MiB); pasting that into a task would spend most of the
|
|
32
|
-
* next child's context on its predecessor's transcript.
|
|
32
|
+
* next child's context on its predecessor's transcript. 32 KiB is a generous summary and a poor transcript, which
|
|
33
33
|
* is the right side of that line for a handoff.
|
|
34
34
|
*/
|
|
35
35
|
export const HANDOFF_MAX_BYTES = 32 * 1024;
|
|
@@ -156,6 +156,8 @@ export interface ChainStep {
|
|
|
156
156
|
agent?: string;
|
|
157
157
|
tools?: string[];
|
|
158
158
|
model?: string;
|
|
159
|
+
correlation?: import("./correlation.ts").CorrelationMetadata;
|
|
160
|
+
workspace?: { workspace_id: string; access: import("./workspace.ts").WorkspaceAccess };
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
/**
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
5
|
+
import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
|
|
6
|
+
import {
|
|
7
|
+
appendLedgerEvent,
|
|
8
|
+
buildWorkspaceLeaseEvent,
|
|
9
|
+
type CheckReceiptLedgerEvent,
|
|
10
|
+
LEDGER_VERSION,
|
|
11
|
+
} from "./ledger.ts";
|
|
12
|
+
import { computeGitCandidateIdentity } from "./git-identity.ts";
|
|
13
|
+
import { runChild } from "./run-child.ts";
|
|
14
|
+
import { GovernanceRefusal, refusal } from "./refusals.ts";
|
|
15
|
+
import {
|
|
16
|
+
acquireWorkspaceLease,
|
|
17
|
+
defaultWorkspaceLeaseDir,
|
|
18
|
+
type ValidatedWorkspace,
|
|
19
|
+
type WorkspaceAccess,
|
|
20
|
+
leaseAcquisitionOutcome,
|
|
21
|
+
leaseReleaseLedgerOutcome,
|
|
22
|
+
} from "./workspace.ts";
|
|
23
|
+
|
|
24
|
+
export interface CheckDefinition {
|
|
25
|
+
/** Absolute operator-owned executable. PATH lookup is deliberately not part of the contract. */
|
|
26
|
+
executable: string;
|
|
27
|
+
/** Exact argv elements. Never a shell command string. */
|
|
28
|
+
argv: string[];
|
|
29
|
+
/** Fixed non-sensitive environment values. */
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
/** Explicit inherited names. Sensitive names are still removed. */
|
|
32
|
+
inherit_env?: string[];
|
|
33
|
+
timeout_ms?: number;
|
|
34
|
+
max_output_bytes?: number;
|
|
35
|
+
/** Checks are assumed capable of writing unless the operator declares read. */
|
|
36
|
+
workspace_access?: WorkspaceAccess;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CheckRegistry {
|
|
40
|
+
version: 1;
|
|
41
|
+
checks: Record<string, CheckDefinition>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CheckReceipt {
|
|
45
|
+
schema_version: "1.0";
|
|
46
|
+
receipt_id: string;
|
|
47
|
+
check_id: string;
|
|
48
|
+
executable: string;
|
|
49
|
+
executable_sha256: string;
|
|
50
|
+
argv: string[];
|
|
51
|
+
argv_sha256: string;
|
|
52
|
+
cwd: string;
|
|
53
|
+
cwd_sha256: string;
|
|
54
|
+
started_at: string;
|
|
55
|
+
ended_at: string;
|
|
56
|
+
exit_code: number | null;
|
|
57
|
+
signal: NodeJS.Signals | null;
|
|
58
|
+
timed_out: boolean;
|
|
59
|
+
aborted: boolean;
|
|
60
|
+
truncated: boolean;
|
|
61
|
+
output_sha256: string;
|
|
62
|
+
workspace_id: string;
|
|
63
|
+
workspace_access: WorkspaceAccess;
|
|
64
|
+
head_sha: string;
|
|
65
|
+
tree_sha: string;
|
|
66
|
+
correlation?: CorrelationMetadata;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SAFE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
70
|
+
const SENSITIVE_ENV_NAME = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|PRIVATE_KEY|API_KEY)(?:_|$)|^PI_GRANTS_|^(?:AWS|GITHUB|GITLAB|AZURE|GOOGLE)_/i;
|
|
71
|
+
|
|
72
|
+
export function buildCheckEnvironment(
|
|
73
|
+
definition: CheckDefinition,
|
|
74
|
+
inherited: NodeJS.ProcessEnv = process.env,
|
|
75
|
+
): NodeJS.ProcessEnv {
|
|
76
|
+
const env: NodeJS.ProcessEnv = {};
|
|
77
|
+
for (const name of definition.inherit_env ?? []) {
|
|
78
|
+
if (!SAFE_ENV_NAME.test(name) || SENSITIVE_ENV_NAME.test(name)) continue;
|
|
79
|
+
if (inherited[name] !== undefined) env[name] = inherited[name];
|
|
80
|
+
}
|
|
81
|
+
for (const [name, value] of Object.entries(definition.env ?? {})) {
|
|
82
|
+
if (!SAFE_ENV_NAME.test(name) || SENSITIVE_ENV_NAME.test(name)) continue;
|
|
83
|
+
env[name] = value;
|
|
84
|
+
}
|
|
85
|
+
return env;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateDefinition(checkId: string, value: CheckDefinition | undefined): CheckDefinition {
|
|
89
|
+
if (!value) {
|
|
90
|
+
throw new GovernanceRefusal(refusal(
|
|
91
|
+
"CHECK_NOT_CONFIGURED", `check ${JSON.stringify(checkId)} is not present in operator-owned configuration`, { check_id: checkId },
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
if (!isAbsolute(value.executable) || !Array.isArray(value.argv) || value.argv.some((item) => typeof item !== "string")) {
|
|
95
|
+
throw new GovernanceRefusal(refusal(
|
|
96
|
+
"CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} requires an absolute executable and argv string array`,
|
|
97
|
+
{ check_id: checkId },
|
|
98
|
+
));
|
|
99
|
+
}
|
|
100
|
+
for (const bound of [value.timeout_ms, value.max_output_bytes]) {
|
|
101
|
+
if (bound !== undefined && (!Number.isInteger(bound) || bound <= 0)) {
|
|
102
|
+
throw new GovernanceRefusal(refusal(
|
|
103
|
+
"CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} has a non-positive runtime bound`, { check_id: checkId },
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function receiptId(receipt: Omit<CheckReceipt, "receipt_id">): string {
|
|
111
|
+
return createHash("sha256").update(JSON.stringify(receipt), "utf8").digest("hex");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function runNamedCheck(input: {
|
|
115
|
+
checkId: string;
|
|
116
|
+
registry: CheckRegistry;
|
|
117
|
+
workspace: ValidatedWorkspace;
|
|
118
|
+
correlation?: CorrelationMetadata;
|
|
119
|
+
inheritedEnv?: NodeJS.ProcessEnv;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
leaseDir?: string;
|
|
122
|
+
ledgerPath?: string;
|
|
123
|
+
}): Promise<{
|
|
124
|
+
output: string;
|
|
125
|
+
exitCode: number | null;
|
|
126
|
+
signal: NodeJS.Signals | null;
|
|
127
|
+
receipt: CheckReceipt;
|
|
128
|
+
}> {
|
|
129
|
+
if (input.registry?.version !== 1 || !input.registry.checks || typeof input.registry.checks !== "object") {
|
|
130
|
+
throw new GovernanceRefusal(refusal("CHECK_CONFIGURATION_INVALID", "check registry must contain {version:1, checks:{...}}"));
|
|
131
|
+
}
|
|
132
|
+
const definition = validateDefinition(input.checkId, input.registry.checks[input.checkId]);
|
|
133
|
+
let executable: string;
|
|
134
|
+
try {
|
|
135
|
+
executable = await realpath(definition.executable);
|
|
136
|
+
if (!(await stat(executable)).isFile()) throw new Error("not a file");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new GovernanceRefusal(refusal(
|
|
139
|
+
"CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(input.checkId)} executable is not a real file (${String(error)})`,
|
|
140
|
+
{ check_id: input.checkId },
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const argvSha256 = createHash("sha256").update(JSON.stringify(definition.argv), "utf8").digest("hex");
|
|
145
|
+
const cwdSha256 = createHash("sha256").update(input.workspace.root, "utf8").digest("hex");
|
|
146
|
+
const access = definition.workspace_access ?? "write";
|
|
147
|
+
// Evidence checks coordinate exclusively so their pre/post candidate identities cannot overlap another
|
|
148
|
+
// governed writer. `access` still records what the configured executable is intended to do.
|
|
149
|
+
const leaseAccess: WorkspaceAccess = "write";
|
|
150
|
+
const ownerId = `check:${input.checkId}:${randomUUID()}`;
|
|
151
|
+
const correlation = normaliseCorrelation(input.correlation);
|
|
152
|
+
let lease: Awaited<ReturnType<typeof acquireWorkspaceLease>> | undefined;
|
|
153
|
+
let stagedDir: string | undefined;
|
|
154
|
+
/** Failures of best-effort RECORDS, reported alongside whatever actually happened — never instead of it. */
|
|
155
|
+
const notes: string[] = [];
|
|
156
|
+
let releaseReason = "failed";
|
|
157
|
+
try {
|
|
158
|
+
try {
|
|
159
|
+
lease = await acquireWorkspaceLease({
|
|
160
|
+
workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
|
|
161
|
+
ownerId, signal: input.signal,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (input.ledgerPath) await appendLedgerEvent(
|
|
165
|
+
{ path: input.ledgerPath, strict: true },
|
|
166
|
+
buildWorkspaceLeaseEvent({
|
|
167
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
168
|
+
outcome: "refused",
|
|
169
|
+
correlation, now: new Date(),
|
|
170
|
+
...(error instanceof GovernanceRefusal
|
|
171
|
+
? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
|
|
172
|
+
: {}),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
if (input.ledgerPath) {
|
|
178
|
+
try {
|
|
179
|
+
await appendLedgerEvent(
|
|
180
|
+
{ path: input.ledgerPath, strict: true },
|
|
181
|
+
buildWorkspaceLeaseEvent({
|
|
182
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
183
|
+
outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
|
|
184
|
+
correlation, now: new Date(),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
await lease.release("ledger-failed");
|
|
189
|
+
lease = undefined;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const before = await computeGitCandidateIdentity(input.workspace);
|
|
195
|
+
for (const [name, supplied, actual] of [
|
|
196
|
+
["head_sha", correlation?.head_sha, before.headSha], ["tree_sha", correlation?.tree_sha, before.treeSha],
|
|
197
|
+
] as const) {
|
|
198
|
+
if (supplied && supplied !== actual) throw new GovernanceRefusal(refusal(
|
|
199
|
+
"CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} ${name} does not match the validated workspace`,
|
|
200
|
+
{ check_id: input.checkId, supplied, actual },
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
const executableBytes = await readFile(executable);
|
|
204
|
+
const executableSha256 = createHash("sha256").update(executableBytes).digest("hex");
|
|
205
|
+
stagedDir = await mkdtemp(join(tmpdir(), "pi-daddy-check-exec-"));
|
|
206
|
+
const stagedExecutable = join(stagedDir, basename(executable));
|
|
207
|
+
await writeFile(stagedExecutable, executableBytes, { mode: 0o700 });
|
|
208
|
+
await chmod(stagedExecutable, 0o700);
|
|
209
|
+
const leaseAbort = new AbortController();
|
|
210
|
+
let leaseLost = false;
|
|
211
|
+
lease.lost.then(() => { leaseLost = true; leaseAbort.abort(); });
|
|
212
|
+
const checkSignal = input.signal ? AbortSignal.any([input.signal, leaseAbort.signal]) : leaseAbort.signal;
|
|
213
|
+
const started = new Date();
|
|
214
|
+
const result = await runChild({
|
|
215
|
+
command: "setpriv", args: ["--pdeathsig", "KILL", "--", stagedExecutable, ...definition.argv],
|
|
216
|
+
env: buildCheckEnvironment(definition, input.inheritedEnv), cwd: input.workspace.root,
|
|
217
|
+
signal: checkSignal, timeoutMs: definition.timeout_ms, maxOutputBytes: definition.max_output_bytes,
|
|
218
|
+
onSpawn: (pid) => lease!.attachProcess(pid),
|
|
219
|
+
});
|
|
220
|
+
const ended = new Date();
|
|
221
|
+
releaseReason = input.signal?.aborted ? "cancelled" : result.timedOut ? "timeout" : result.aborted ? "failed" : "completed";
|
|
222
|
+
// Every throw below re-stamps this. Without that, a check refused for CHECK_IDENTITY_MISMATCH still
|
|
223
|
+
// wrote `released / completed` from the `finally` — the LAST lease event for that owner — so the trail
|
|
224
|
+
// still read like a clean run and `verifyLedger` still counted a clean release. The R-114 fix added a
|
|
225
|
+
// refusal line beside the misleading one instead of correcting it.
|
|
226
|
+
if (result.spawnError) throw new GovernanceRefusal(refusal(
|
|
227
|
+
"EXECUTOR_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} could not start its constrained executor`,
|
|
228
|
+
{ check_id: input.checkId },
|
|
229
|
+
));
|
|
230
|
+
if (leaseLost) throw new GovernanceRefusal(refusal(
|
|
231
|
+
"WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease while running`,
|
|
232
|
+
{ check_id: input.checkId },
|
|
233
|
+
));
|
|
234
|
+
const after = await computeGitCandidateIdentity(input.workspace);
|
|
235
|
+
if (leaseLost) throw new GovernanceRefusal(refusal(
|
|
236
|
+
"WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`,
|
|
237
|
+
{ check_id: input.checkId },
|
|
238
|
+
));
|
|
239
|
+
if (before.headSha !== after.headSha || before.treeSha !== after.treeSha) {
|
|
240
|
+
throw new GovernanceRefusal(refusal(
|
|
241
|
+
"CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} workspace changed while it ran`,
|
|
242
|
+
{ check_id: input.checkId },
|
|
243
|
+
));
|
|
244
|
+
}
|
|
245
|
+
// The candidate is now measured and the process is dead. End the lease before writing the receipt so
|
|
246
|
+
// lease loss cannot race a blocked ledger append and leave behind evidence from an unprotected run.
|
|
247
|
+
// Record what release actually DID. This asserted `released` unconditionally and threw the outcome
|
|
248
|
+
// away, so the evidence path reproduced the very defect the outcome union was added to expose: a lost
|
|
249
|
+
// or unrecorded handover ledgered as a clean one, with `lease = undefined` stopping the `finally` from
|
|
250
|
+
// ever noticing (R-100/R-103, found while reviewing the fix for R-99).
|
|
251
|
+
const releaseOutcome = await lease.release(releaseReason);
|
|
252
|
+
lease = undefined;
|
|
253
|
+
if (leaseLost) throw new GovernanceRefusal(refusal(
|
|
254
|
+
"WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`,
|
|
255
|
+
{ check_id: input.checkId },
|
|
256
|
+
));
|
|
257
|
+
if (input.ledgerPath) await appendLedgerEvent(
|
|
258
|
+
{ path: input.ledgerPath, strict: true },
|
|
259
|
+
buildWorkspaceLeaseEvent({
|
|
260
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
261
|
+
outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
const body: Omit<CheckReceipt, "receipt_id"> = {
|
|
265
|
+
schema_version: "1.0", check_id: input.checkId, executable, executable_sha256: executableSha256,
|
|
266
|
+
argv: [...definition.argv], argv_sha256: argvSha256, cwd: input.workspace.root, cwd_sha256: cwdSha256,
|
|
267
|
+
started_at: started.toISOString(), ended_at: ended.toISOString(), exit_code: result.code,
|
|
268
|
+
signal: result.signal ?? null, timed_out: result.timedOut, aborted: result.aborted, truncated: result.truncated,
|
|
269
|
+
output_sha256: createHash("sha256").update(result.text, "utf8").digest("hex"),
|
|
270
|
+
workspace_id: input.workspace.workspaceId, workspace_access: access,
|
|
271
|
+
head_sha: before.headSha, tree_sha: before.treeSha, ...(correlation ? { correlation } : {}),
|
|
272
|
+
};
|
|
273
|
+
const receipt: CheckReceipt = { receipt_id: receiptId(body), ...body };
|
|
274
|
+
if (input.ledgerPath) {
|
|
275
|
+
const event: CheckReceiptLedgerEvent = {
|
|
276
|
+
ledgerVersion: LEDGER_VERSION, event: "check_receipt", ts: ended.toISOString(), childId: ownerId,
|
|
277
|
+
receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId, checkId: input.checkId,
|
|
278
|
+
treeSha: receipt.tree_sha, ...(correlation ? { correlation } : {}),
|
|
279
|
+
};
|
|
280
|
+
await appendLedgerEvent({ path: input.ledgerPath, strict: true }, event);
|
|
281
|
+
}
|
|
282
|
+
return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// The run did not complete, so the release must not be recorded as if it had. Without this the
|
|
285
|
+
// `finally` wrote `released / completed` as the LAST lease event for a refused check, so the trail
|
|
286
|
+
// still read like a clean run and `verifyLedger` still counted a clean release — the R-114 fix put a
|
|
287
|
+
// refusal line beside the misleading one instead of correcting it.
|
|
288
|
+
if (releaseReason === "completed") releaseReason = "refused";
|
|
289
|
+
// The single most important negative result this feature can produce — CHECK_IDENTITY_MISMATCH, "the
|
|
290
|
+
// workspace changed while the check ran" — used to leave NO ledger evidence at all, while the
|
|
291
|
+
// `finally` recorded `released, reason: completed`, so the trail read like a clean run (R-114).
|
|
292
|
+
if (input.ledgerPath) {
|
|
293
|
+
await appendLedgerEvent(
|
|
294
|
+
{
|
|
295
|
+
path: input.ledgerPath,
|
|
296
|
+
// Best effort, and loud: this records a failure, so it must not replace the failure. A strict
|
|
297
|
+
// append that threw here would substitute "ledger write failed" for the evidence-integrity
|
|
298
|
+
// refusal that actually happened (R-115).
|
|
299
|
+
strict: false,
|
|
300
|
+
// NOT empty. `onFailure` exists because a non-strict append is a choice not to fail closed and
|
|
301
|
+
// never a choice to be silent — the comment above says "loud", and an empty callback is the
|
|
302
|
+
// definition of swallowing. This guards the evidence record itself, so losing it silently is
|
|
303
|
+
// how a mutated workspace came to leave a trail that read like a clean run.
|
|
304
|
+
onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
|
|
305
|
+
},
|
|
306
|
+
buildWorkspaceLeaseEvent({
|
|
307
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
308
|
+
outcome: "refused",
|
|
309
|
+
refusal: error instanceof GovernanceRefusal
|
|
310
|
+
? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
|
|
311
|
+
: refusal("CHECK_IDENTITY_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} failed (${String(error)})`),
|
|
312
|
+
correlation, now: new Date(),
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
} finally {
|
|
318
|
+
if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
|
|
319
|
+
if (lease) {
|
|
320
|
+
// `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
|
|
321
|
+
// above: a `finally` that throws destroys whatever the caller was already returning or raising.
|
|
322
|
+
const outcome = await lease.release(releaseReason);
|
|
323
|
+
if (input.ledgerPath) await appendLedgerEvent(
|
|
324
|
+
{
|
|
325
|
+
path: input.ledgerPath,
|
|
326
|
+
strict: false,
|
|
327
|
+
onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
|
|
328
|
+
},
|
|
329
|
+
buildWorkspaceLeaseEvent({
|
|
330
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
331
|
+
outcome: outcome === "lost"
|
|
332
|
+
? "lost"
|
|
333
|
+
: outcome === "released-unrecorded"
|
|
334
|
+
? "released-unrecorded"
|
|
335
|
+
: releaseReason === "timeout" ? "timeout" : "released",
|
|
336
|
+
releaseReason, correlation, now: new Date(),
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|