pi-daddy 0.18.0 → 0.19.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 -0
- package/README.md +46 -2
- package/contracts/ledger/v2/README.md +59 -0
- package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
- package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
- package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
- package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
- package/contracts/ledger/v2/ledger-event.schema.json +633 -0
- package/dist/approval-prompt.d.ts +2 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +9 -0
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval.d.ts +4 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +4 -0
- package/dist/approval.js.map +1 -1
- package/dist/capabilities.d.ts +105 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +161 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/catalog.d.ts +15 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +54 -3
- package/dist/catalog.js.map +1 -1
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +5 -7
- package/dist/check-runner.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +7 -1
- package/dist/definitions.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +32 -4
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts.map +1 -1
- package/dist/delegation-approval.js +37 -12
- package/dist/delegation-approval.js.map +1 -1
- package/dist/executor.d.ts +2 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +1 -0
- package/dist/executor.js.map +1 -1
- package/dist/grant-env.d.ts +2 -0
- package/dist/grant-env.d.ts.map +1 -1
- package/dist/grant-env.js +26 -3
- package/dist/grant-env.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +13 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +35 -2
- package/dist/init.js.map +1 -1
- package/dist/lease-helper.d.ts +58 -0
- package/dist/lease-helper.d.ts.map +1 -0
- package/dist/lease-helper.js +94 -0
- package/dist/lease-helper.js.map +1 -0
- package/dist/lease-record.d.ts +15 -3
- package/dist/lease-record.d.ts.map +1 -1
- package/dist/lease-record.js.map +1 -1
- package/dist/ledger-events.d.ts +35 -14
- package/dist/ledger-events.d.ts.map +1 -1
- package/dist/ledger-events.js +41 -0
- package/dist/ledger-events.js.map +1 -1
- package/dist/ledger.d.ts +10 -5
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +13 -3
- package/dist/ledger.js.map +1 -1
- package/dist/propagation.d.ts +16 -0
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +22 -2
- package/dist/propagation.js.map +1 -1
- package/dist/refusals.d.ts +1 -1
- package/dist/refusals.d.ts.map +1 -1
- package/dist/refusals.js +2 -0
- package/dist/refusals.js.map +1 -1
- package/dist/resolve.d.ts +10 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +33 -3
- package/dist/resolve.js.map +1 -1
- package/dist/routing-authority.d.ts +71 -0
- package/dist/routing-authority.d.ts.map +1 -0
- package/dist/routing-authority.js +100 -0
- package/dist/routing-authority.js.map +1 -0
- package/dist/skill-packages.d.ts +11 -5
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +20 -11
- package/dist/skill-packages.js.map +1 -1
- package/dist/workspace-lease.d.ts +15 -3
- package/dist/workspace-lease.d.ts.map +1 -1
- package/dist/workspace-lease.js +81 -24
- package/dist/workspace-lease.js.map +1 -1
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +142 -5
- package/dist/workspace.js.map +1 -1
- package/extensions/delegation.ts +7 -1
- package/extensions/grants-command.ts +11 -1
- package/extensions/grants.ts +6 -1
- package/extensions/init-command.ts +33 -2
- package/extensions/session-report.ts +13 -20
- package/extensions/session.ts +9 -1
- package/extensions/workspace-runtime.ts +34 -4
- package/package.json +8 -3
- package/src/approval-prompt.ts +2 -1
- package/src/approval.ts +4 -2
- package/src/capabilities.ts +173 -4
- package/src/catalog.ts +62 -4
- package/src/check-runner.ts +8 -8
- package/src/cli.ts +24 -1
- package/src/definitions.ts +7 -1
- package/src/delegate.ts +42 -4
- package/src/delegation-approval.ts +37 -12
- package/src/executor.ts +2 -1
- package/src/grant-env.ts +39 -7
- package/src/index.ts +1 -0
- package/src/init.ts +40 -2
- package/src/lease-helper.ts +97 -0
- package/src/lease-record.ts +15 -3
- package/src/ledger-events.ts +66 -22
- package/src/ledger.ts +30 -6
- package/src/propagation.ts +23 -2
- package/src/refusals.ts +2 -0
- package/src/resolve.ts +35 -3
- package/src/routing-authority.ts +121 -0
- package/src/skill-packages.ts +20 -13
- package/src/workspace-lease.ts +84 -24
- package/src/workspace.ts +179 -6
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The lock helper: a separate program, and the parent's side of its pipes.**
|
|
3
|
+
*
|
|
4
|
+
* Split out of `workspace-lease.ts` because `test/file-size.test.ts` refused that file — at 405 lines on the
|
|
5
|
+
* ADR-0035 branch when PR #14 merged into it, and at 435 here once R-152's guards landed. Both branches take
|
|
6
|
+
* the same seam so they converge rather than diverge, and the cap has never been raised: `delegate.ts` was
|
|
7
|
+
* split at 413 the same way (rule: when a guard fails, obey it).
|
|
8
|
+
*
|
|
9
|
+
* The seam is not arbitrary. Everything here concerns the process that HOLDS the kernel lock — the source it
|
|
10
|
+
* runs, the readiness token it prints, and the parent-side handles that keep it referenced.
|
|
11
|
+
* `workspace-lease.ts` keeps the lease lifecycle that talks to it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const LEASE_READY = "PI_DADDY_LEASE_READY";
|
|
15
|
+
// The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
|
|
16
|
+
// the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
|
|
17
|
+
// descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
|
|
18
|
+
// guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
|
|
19
|
+
// WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
|
|
20
|
+
// and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
|
|
21
|
+
// with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
|
|
22
|
+
//
|
|
23
|
+
// **Each attempt is bounded in WALL CLOCK, not just in count (R-146).** `execFile` with no `timeout`
|
|
24
|
+
// never calls back if `herdr` accepts the close and does not answer, so the retry counter never
|
|
25
|
+
// decrements, `giveUp` never runs and no marker is written — the lock is held forever. That was masked
|
|
26
|
+
// while the parent could not exit, because the operator saw a hung `pi` instead; measured with a `herdr`
|
|
27
|
+
// that sleeps, the parent then exited in 82ms and left a silent strand, which is R-102's rejected outcome
|
|
28
|
+
// reached quietly. A bound on retries is not a bound on time.
|
|
29
|
+
export const HELPER_SOURCE = `
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { writeFileSync } from "node:fs";
|
|
32
|
+
let clean=false, target=null, buffered="";
|
|
33
|
+
process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
|
|
34
|
+
process.stdin.setEncoding("utf8");
|
|
35
|
+
process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
|
|
36
|
+
process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=last=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:last&&(last.killed||last.signal==="SIGKILL")?"herdr-close-timeout":"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],{timeout:Number(process.env.PI_DADDY_LEASE_CLOSE_TIMEOUT_MS||15000),killSignal:"SIGKILL"},error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp(error);setTimeout(close,1000)});close();});
|
|
37
|
+
process.stdin.resume();`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `unref` the parent's end of one of the helper's pipes.
|
|
41
|
+
*
|
|
42
|
+
* A spawned pipe is a `net.Socket`, which has `unref`; `ChildProcess.stdin` is typed `Writable`, which does
|
|
43
|
+
* not. Hence the narrow cast. The optional call is **defensive, not load-bearing** — the holder is spawned at
|
|
44
|
+
* exactly one site with three pipes, so none of these is ever `null`, and an earlier version of this comment
|
|
45
|
+
* claimed otherwise by citing a `stdio: "ignore"` caller that does not exist.
|
|
46
|
+
*
|
|
47
|
+
* Only `stdout` and `stderr` are unref'ed. `stdin` was too, until a line-by-line reversion showed the suite
|
|
48
|
+
* stayed green without it — an unforced line inside the fix that exists to be forced, which is exactly the
|
|
49
|
+
* shape R-122 was about.
|
|
50
|
+
*/
|
|
51
|
+
export const unrefStream = (stream: unknown): void => {
|
|
52
|
+
(stream as { unref?: () => void } | null)?.unref?.();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The bounds on the helper's `herdr tab close` attempts live here, with the attempts they bound — moved out of
|
|
57
|
+
* `workspace-lease.ts` when the line ceiling refused it at 402 lines, once both this branch's ADR-0035 work and
|
|
58
|
+
* `main`'s R-152 guards had landed in it. Third time that guard has fired on this file and third time the
|
|
59
|
+
* answer was a seam rather than a bigger cap.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* **A bound that is not a bound throws, and it throws a `RangeError` (R-146, R-152).**
|
|
63
|
+
*
|
|
64
|
+
* Both ends fail, in opposite directions and both silently:
|
|
65
|
+
*
|
|
66
|
+
* - `0` reads as "no limit" and Node agrees — `execFile` treats `timeout: 0` as *no* timeout (measured: the
|
|
67
|
+
* callback for a 3s sleep arrives at 3004ms with no error), reinstating the unbounded hang the bound exists
|
|
68
|
+
* to prevent. Negatives behave identically.
|
|
69
|
+
* - anything above `2^31 - 1` truncates: `setTimeout` warns `TimeoutOverflowWarning … set to 1`, so
|
|
70
|
+
* `Number.MAX_SAFE_INTEGER` — the plausible "effectively no limit" sentinel, given the argument about `0`
|
|
71
|
+
* — SIGKILLs every `herdr tab close` after 1ms, before herdr can act. Measured: callback at 3ms.
|
|
72
|
+
*
|
|
73
|
+
* **Not a `GovernanceRefusal`.** It was one, carrying `WORKSPACE_LEASE_STALE`, which everywhere else in this
|
|
74
|
+
* package means *the lease went stale or was lost* — so an ADR-0034 controller switching on codes (the reason
|
|
75
|
+
* codes exist, R-103) would classify a permanent caller bug as transient and retry a call that can never
|
|
76
|
+
* succeed. A refusal is a governance outcome that gets ledgered; a bad argument is neither.
|
|
77
|
+
*
|
|
78
|
+
* Checked for read leases too, which the first version did not: it sat below the read-lease early return, so a
|
|
79
|
+
* controller smoke-testing its configuration against a read lease got a false all-clear.
|
|
80
|
+
*/
|
|
81
|
+
export const assertCloseBounds = (input: { herdrCloseTimeoutMs?: number; herdrCloseAttempts?: number }): void => {
|
|
82
|
+
const MAX_TIMER = 2_147_483_647;
|
|
83
|
+
for (const [name, value, ceiling] of [
|
|
84
|
+
["herdrCloseTimeoutMs", input.herdrCloseTimeoutMs, MAX_TIMER],
|
|
85
|
+
["herdrCloseAttempts", input.herdrCloseAttempts, Number.MAX_SAFE_INTEGER],
|
|
86
|
+
] as const) {
|
|
87
|
+
if (value === undefined) continue;
|
|
88
|
+
if (!Number.isInteger(value) || value < 1 || value > ceiling) {
|
|
89
|
+
throw new RangeError(
|
|
90
|
+
`${name} must be a whole number between 1 and ${ceiling}, not ${String(value)}. Zero and negatives ` +
|
|
91
|
+
`are not "no limit" but no bound at all, a value past ${MAX_TIMER} truncates to 1ms, and a ` +
|
|
92
|
+
`fractional count is not a count — this bound exists so a hung herdr cannot hold the writer lock ` +
|
|
93
|
+
`forever (R-146).`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
package/src/lease-record.ts
CHANGED
|
@@ -53,13 +53,18 @@ export interface WorkspaceLease {
|
|
|
53
53
|
* Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
|
|
54
54
|
* whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
|
|
55
55
|
*/
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Record that the lease is being KEPT rather than handed back, and answer what actually happened — the
|
|
58
|
+
* caller ledgers this word (R-152). It is not always `retained`: a helper that has already died makes the
|
|
59
|
+
* fact `lost`, and a lease already settled by `release()` keeps the outcome it had.
|
|
60
|
+
*/
|
|
61
|
+
markRetained(reason?: string): Promise<LeaseReleaseOutcome>;
|
|
57
62
|
/** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
|
|
58
63
|
readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
|
-
* What a release actually did.
|
|
67
|
+
* What a release actually did. Six members rather than three, because the first version conflated facts
|
|
63
68
|
* that call for different responses — and one of them is an alarm:
|
|
64
69
|
* `released` the lock went back and THIS owner wrote its own handover;
|
|
65
70
|
* `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
|
|
@@ -77,7 +82,14 @@ export type LeaseReleaseOutcome =
|
|
|
77
82
|
| "released-unrecorded"
|
|
78
83
|
| "released-superseded"
|
|
79
84
|
| "not-held"
|
|
80
|
-
| "lost"
|
|
85
|
+
| "lost"
|
|
86
|
+
/**
|
|
87
|
+
* The lease was RETAINED and is therefore already settled — `release()` after `markRetained()` answers
|
|
88
|
+
* this instead of running the clean handshake (R-146). It was previously expressible only as the
|
|
89
|
+
* `| "retained"` bolted onto two signatures, which is why `release()` could not say it and claimed
|
|
90
|
+
* `released` instead: a clean handover for a lease kept precisely because a pane would not close.
|
|
91
|
+
*/
|
|
92
|
+
| "retained";
|
|
81
93
|
|
|
82
94
|
export function leasePaths(leaseDir: string, root: string) {
|
|
83
95
|
// Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.
|
package/src/ledger-events.ts
CHANGED
|
@@ -9,6 +9,24 @@ import type { ExecutorKind } from "./executor.ts";
|
|
|
9
9
|
import type { CorrelationMetadata } from "./correlation.ts";
|
|
10
10
|
import type { StructuredRefusal } from "./refusals.ts";
|
|
11
11
|
|
|
12
|
+
export const WORKSPACE_ACCESSES = ["read", "write"] as const;
|
|
13
|
+
export type WorkspaceAccess = typeof WORKSPACE_ACCESSES[number];
|
|
14
|
+
|
|
15
|
+
export const WORKSPACE_RECOVERY_VALUES = [false, true, "unknown"] as const;
|
|
16
|
+
export type WorkspaceRecovery = typeof WORKSPACE_RECOVERY_VALUES[number];
|
|
17
|
+
|
|
18
|
+
export const CHILD_LIFECYCLE_STATES = ["starting", "completed", "failed"] as const;
|
|
19
|
+
export type ChildLifecycleState = typeof CHILD_LIFECYCLE_STATES[number];
|
|
20
|
+
|
|
21
|
+
/** The complete Node signal vocabulary accepted by a v2 child-lifecycle event. */
|
|
22
|
+
export const CHILD_PROCESS_SIGNALS = [
|
|
23
|
+
"SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCONT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINT", "SIGIO",
|
|
24
|
+
"SIGIOT", "SIGKILL", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT",
|
|
25
|
+
"SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG",
|
|
26
|
+
"SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK", "SIGLOST", "SIGINFO",
|
|
27
|
+
] as const satisfies readonly NodeJS.Signals[];
|
|
28
|
+
export type ChildProcessSignal = typeof CHILD_PROCESS_SIGNALS[number];
|
|
29
|
+
|
|
12
30
|
/**
|
|
13
31
|
* `released` is a handover this owner performed. FOUR members were added by the 0.18.0 review pass, and
|
|
14
32
|
* they were not all previously recorded the same way — `uncontended` was recorded as an *acquisition*, and
|
|
@@ -20,16 +38,11 @@ import type { StructuredRefusal } from "./refusals.ts";
|
|
|
20
38
|
* `uncontended` a read lease took no kernel lock at all, so counting it as an acquisition
|
|
21
39
|
* overstated how many exclusions the kernel actually performed (R-105).
|
|
22
40
|
*/
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
| "released-unrecorded"
|
|
29
|
-
| "lost"
|
|
30
|
-
| "retained"
|
|
31
|
-
| "timeout"
|
|
32
|
-
| "recovered";
|
|
41
|
+
export const WORKSPACE_LEASE_OUTCOMES = [
|
|
42
|
+
"acquired", "uncontended", "refused", "released", "released-unrecorded", "lost", "retained", "timeout",
|
|
43
|
+
"recovered",
|
|
44
|
+
] as const;
|
|
45
|
+
export type WorkspaceLeaseOutcome = typeof WORKSPACE_LEASE_OUTCOMES[number];
|
|
33
46
|
|
|
34
47
|
export interface WorkspaceLeaseEvent extends LedgerEventBase {
|
|
35
48
|
ledgerVersion: typeof LEDGER_VERSION;
|
|
@@ -37,10 +50,10 @@ export interface WorkspaceLeaseEvent extends LedgerEventBase {
|
|
|
37
50
|
childId: string;
|
|
38
51
|
workspaceId: string;
|
|
39
52
|
root: string;
|
|
40
|
-
access:
|
|
53
|
+
access: WorkspaceAccess;
|
|
41
54
|
outcome: WorkspaceLeaseOutcome;
|
|
42
55
|
/** `"unknown"` when the prior owner's record was unreadable — not evidence of a clean handover. */
|
|
43
|
-
recovered?:
|
|
56
|
+
recovered?: WorkspaceRecovery;
|
|
44
57
|
releaseReason?: string;
|
|
45
58
|
refusal?: StructuredRefusal;
|
|
46
59
|
}
|
|
@@ -49,13 +62,13 @@ export interface ChildLifecycleEvent extends LedgerEventBase {
|
|
|
49
62
|
ledgerVersion: typeof LEDGER_VERSION;
|
|
50
63
|
event: "child_lifecycle";
|
|
51
64
|
childId: string;
|
|
52
|
-
state:
|
|
65
|
+
state: ChildLifecycleState;
|
|
53
66
|
executor: ExecutorKind;
|
|
54
67
|
exitCode?: number | null;
|
|
55
|
-
signal?:
|
|
56
|
-
timedOut?:
|
|
57
|
-
aborted?:
|
|
58
|
-
truncated?:
|
|
68
|
+
signal?: ChildProcessSignal | null;
|
|
69
|
+
timedOut?: true;
|
|
70
|
+
aborted?: true;
|
|
71
|
+
truncated?: true;
|
|
59
72
|
reason?: string;
|
|
60
73
|
}
|
|
61
74
|
|
|
@@ -69,8 +82,14 @@ export interface CheckReceiptLedgerEvent extends LedgerEventBase {
|
|
|
69
82
|
treeSha: string;
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
export type CapabilityDecisionEvent = GrantRecord & {
|
|
86
|
+
ledgerVersion: typeof LEDGER_VERSION;
|
|
87
|
+
event: "capability_decision";
|
|
88
|
+
taskDigest: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
72
91
|
export type RuntimeLedgerEvent =
|
|
73
|
-
|
|
|
92
|
+
| CapabilityDecisionEvent
|
|
74
93
|
| WorkspaceLeaseEvent
|
|
75
94
|
| ChildLifecycleEvent
|
|
76
95
|
| CheckReceiptLedgerEvent;
|
|
@@ -79,9 +98,9 @@ export function buildWorkspaceLeaseEvent(args: {
|
|
|
79
98
|
childId: string;
|
|
80
99
|
workspaceId: string;
|
|
81
100
|
root: string;
|
|
82
|
-
access:
|
|
101
|
+
access: WorkspaceAccess;
|
|
83
102
|
outcome: WorkspaceLeaseOutcome;
|
|
84
|
-
recovered?:
|
|
103
|
+
recovered?: WorkspaceRecovery;
|
|
85
104
|
releaseReason?: string;
|
|
86
105
|
refusal?: StructuredRefusal;
|
|
87
106
|
correlation?: CorrelationMetadata;
|
|
@@ -107,12 +126,37 @@ export function buildWorkspaceLeaseEvent(args: {
|
|
|
107
126
|
};
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
export function buildCheckReceiptLedgerEvent(args: {
|
|
130
|
+
childId: string;
|
|
131
|
+
receiptId: string;
|
|
132
|
+
workspaceId: string;
|
|
133
|
+
checkId: string;
|
|
134
|
+
treeSha: string;
|
|
135
|
+
correlation?: CorrelationMetadata;
|
|
136
|
+
now: Date;
|
|
137
|
+
}): CheckReceiptLedgerEvent {
|
|
138
|
+
if (!/^[a-f0-9]{64}$/i.test(args.receiptId)) {
|
|
139
|
+
throw new TypeError("receiptId must be a SHA-256 hex digest");
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ledgerVersion: LEDGER_VERSION,
|
|
143
|
+
event: "check_receipt",
|
|
144
|
+
ts: args.now.toISOString(),
|
|
145
|
+
childId: args.childId,
|
|
146
|
+
receiptId: args.receiptId,
|
|
147
|
+
workspaceId: args.workspaceId,
|
|
148
|
+
checkId: args.checkId,
|
|
149
|
+
treeSha: args.treeSha,
|
|
150
|
+
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
export function buildChildLifecycleEvent(args: {
|
|
111
155
|
childId: string;
|
|
112
|
-
state:
|
|
156
|
+
state: ChildLifecycleState;
|
|
113
157
|
executor: ExecutorKind;
|
|
114
158
|
exitCode?: number | null;
|
|
115
|
-
signal?:
|
|
159
|
+
signal?: ChildProcessSignal | null;
|
|
116
160
|
timedOut?: boolean;
|
|
117
161
|
aborted?: boolean;
|
|
118
162
|
truncated?: boolean;
|
package/src/ledger.ts
CHANGED
|
@@ -34,11 +34,17 @@ import type { StructuredRefusal } from "./refusals.ts";
|
|
|
34
34
|
import type { RuntimeLedgerEvent } from "./ledger-events.ts";
|
|
35
35
|
|
|
36
36
|
export const LEDGER_VERSION = 2 as const;
|
|
37
|
+
export const LEDGER_EVENT_KINDS = [
|
|
38
|
+
"capability_decision", "workspace_lease", "child_lifecycle", "check_receipt",
|
|
39
|
+
] as const;
|
|
40
|
+
export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
|
|
41
|
+
export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
|
|
42
|
+
export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
|
|
37
43
|
|
|
38
44
|
export interface LedgerEventBase {
|
|
39
45
|
/** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
|
|
40
46
|
ledgerVersion?: typeof LEDGER_VERSION;
|
|
41
|
-
event?:
|
|
47
|
+
event?: LedgerEventKind;
|
|
42
48
|
ts: string;
|
|
43
49
|
childId?: string;
|
|
44
50
|
correlation?: CorrelationMetadata;
|
|
@@ -104,7 +110,7 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
104
110
|
/** Present only when the source was a live prompt, and only when one scope covers the whole set. */
|
|
105
111
|
approvalScope?: ApprovalScope;
|
|
106
112
|
/** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
|
|
107
|
-
humanDenied?:
|
|
113
|
+
humanDenied?: true;
|
|
108
114
|
/**
|
|
109
115
|
* WHY a gate went unsatisfied, when the answer was not a yes.
|
|
110
116
|
*
|
|
@@ -120,9 +126,10 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
120
126
|
* say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
|
|
121
127
|
*
|
|
122
128
|
* **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
|
|
123
|
-
* a task could carry.
|
|
129
|
+
* a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
|
|
130
|
+
* four-member ledger enum because the approval source/scope fields already record a yes.
|
|
124
131
|
*/
|
|
125
|
-
gateOutcome?:
|
|
132
|
+
gateOutcome?: LedgerGateOutcome;
|
|
126
133
|
/**
|
|
127
134
|
* WHICH operator-authored instructions this child was given (ADR-0018).
|
|
128
135
|
*
|
|
@@ -208,6 +215,12 @@ export function buildRecord(args: {
|
|
|
208
215
|
refusal?: StructuredRefusal;
|
|
209
216
|
now: Date;
|
|
210
217
|
}): GrantRecord {
|
|
218
|
+
if (args.taskDigest !== undefined && !/^[a-f0-9]{64}$/i.test(args.taskDigest)) {
|
|
219
|
+
throw new TypeError("taskDigest must be a SHA-256 hex digest");
|
|
220
|
+
}
|
|
221
|
+
if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
|
|
222
|
+
throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
|
|
223
|
+
}
|
|
211
224
|
// R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
|
|
212
225
|
// than accepting it, so a call site cannot supply one that disagrees with the map beside it.
|
|
213
226
|
const sources = args.approvalSources ?? {};
|
|
@@ -217,7 +230,7 @@ export function buildRecord(args: {
|
|
|
217
230
|
return {
|
|
218
231
|
// A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
|
|
219
232
|
// Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
|
|
220
|
-
...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
|
|
233
|
+
...(args.taskDigest !== undefined ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
|
|
221
234
|
ts: args.now.toISOString(),
|
|
222
235
|
parentId: args.parentId,
|
|
223
236
|
childId: args.childId,
|
|
@@ -225,7 +238,7 @@ export function buildRecord(args: {
|
|
|
225
238
|
agentType: args.agentType,
|
|
226
239
|
executor: args.executor,
|
|
227
240
|
...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
|
|
228
|
-
...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
|
|
241
|
+
...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
|
|
229
242
|
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
230
243
|
...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
|
|
231
244
|
requested: args.requested,
|
|
@@ -302,11 +315,22 @@ export function isEscalationAttempt(record: GrantRecord): boolean {
|
|
|
302
315
|
}
|
|
303
316
|
|
|
304
317
|
export {
|
|
318
|
+
CHILD_LIFECYCLE_STATES,
|
|
319
|
+
CHILD_PROCESS_SIGNALS,
|
|
320
|
+
WORKSPACE_ACCESSES,
|
|
321
|
+
WORKSPACE_LEASE_OUTCOMES,
|
|
322
|
+
WORKSPACE_RECOVERY_VALUES,
|
|
323
|
+
buildCheckReceiptLedgerEvent,
|
|
305
324
|
buildChildLifecycleEvent,
|
|
306
325
|
buildWorkspaceLeaseEvent,
|
|
326
|
+
type CapabilityDecisionEvent,
|
|
307
327
|
type ChildLifecycleEvent,
|
|
328
|
+
type ChildLifecycleState,
|
|
329
|
+
type ChildProcessSignal,
|
|
308
330
|
type CheckReceiptLedgerEvent,
|
|
309
331
|
type RuntimeLedgerEvent,
|
|
332
|
+
type WorkspaceAccess,
|
|
310
333
|
type WorkspaceLeaseEvent,
|
|
311
334
|
type WorkspaceLeaseOutcome,
|
|
335
|
+
type WorkspaceRecovery,
|
|
312
336
|
} from "./ledger-events.ts";
|
package/src/propagation.ts
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
|
|
28
28
|
import type { Capability } from "./resolve.ts";
|
|
29
29
|
import { WILDCARD } from "./pi-tools.ts";
|
|
30
|
+
import { WORKSPACE_WILDCARD } from "./resolve.ts";
|
|
30
31
|
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
32
|
+
import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
|
|
31
33
|
|
|
32
34
|
export const ENV_GRANT = "PI_GRANTS_GRANT";
|
|
33
35
|
/**
|
|
@@ -246,11 +248,30 @@ export interface ChildEnvInput {
|
|
|
246
248
|
* empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
|
|
247
249
|
* because a session's first provider request always precedes its first tool call.
|
|
248
250
|
*/
|
|
251
|
+
/**
|
|
252
|
+
* The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down.
|
|
253
|
+
*
|
|
254
|
+
* `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog.
|
|
255
|
+
* `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere
|
|
256
|
+
* the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately
|
|
257
|
+
* NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because
|
|
258
|
+
* definitions are ceilings rather than roots.
|
|
259
|
+
*
|
|
260
|
+
* **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in
|
|
261
|
+
* `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's
|
|
262
|
+
* `PI_GRANTS_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside
|
|
263
|
+
* that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not
|
|
264
|
+
* catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now.
|
|
265
|
+
*/
|
|
266
|
+
export function inheritableGrant(grant: readonly Capability[]): Capability[] {
|
|
267
|
+
return grant.filter((c) => c !== WILDCARD && c !== WORKSPACE_WILDCARD);
|
|
268
|
+
}
|
|
269
|
+
|
|
249
270
|
export function childEnv(input: ChildEnvInput): Record<string, string> {
|
|
250
271
|
if (input.governed === false) return {};
|
|
251
|
-
const inheritable = input.ownGrant
|
|
272
|
+
const inheritable = inheritableGrant(input.ownGrant);
|
|
252
273
|
const env: Record<string, string> = {
|
|
253
|
-
[ENV_GRANT]: inheritable.join(","),
|
|
274
|
+
[ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
|
|
254
275
|
[ENV_DEPTH]: String(input.depth + 1),
|
|
255
276
|
[ENV_MAX_DEPTH]: String(input.maxDepth),
|
|
256
277
|
};
|
package/src/refusals.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const REFUSAL_CODES = [
|
|
2
2
|
"CAPABILITY_ESCALATION",
|
|
3
|
+
"GRANT_ID_MALFORMED",
|
|
3
4
|
"DEFINITION_NOT_AUTHORIZED",
|
|
4
5
|
"UNDECLARED_TOOLS",
|
|
5
6
|
"UNKNOWN_TOOL",
|
|
@@ -27,6 +28,7 @@ export const REFUSAL_CODES = [
|
|
|
27
28
|
"LEDGER_WRITE_FAILED",
|
|
28
29
|
"FANOUT_FAILED",
|
|
29
30
|
"WORKSPACE_NOT_REGISTERED",
|
|
31
|
+
"WORKSPACE_NOT_AUTHORIZED",
|
|
30
32
|
"WORKSPACE_WRITE_CONFLICT",
|
|
31
33
|
"WORKSPACE_LEASE_STALE",
|
|
32
34
|
"CHECK_NOT_CONFIGURED",
|
package/src/resolve.ts
CHANGED
|
@@ -62,6 +62,7 @@ export function expandSubsumed(grant: Capability[]): Capability[] {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
import { WILDCARD } from "./pi-tools.ts";
|
|
65
|
+
import { isWellFormedCapability } from "./capabilities.ts";
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* "Any definition" — ADR-0023, and one of two wildcards this module understands.
|
|
@@ -75,6 +76,16 @@ import { WILDCARD } from "./pi-tools.ts";
|
|
|
75
76
|
* `tool:*` — authority to grant every tool — which made the safe configuration the laborious one.
|
|
76
77
|
*/
|
|
77
78
|
export const AGENT_WILDCARD: Capability = "agent:*";
|
|
79
|
+
/**
|
|
80
|
+
* `workspace:*` covers any `workspace:<id>` — ADR-0035, and the second namespace wildcard.
|
|
81
|
+
*
|
|
82
|
+
* Added as a deliberate edit, which is what the comment in `resolve()` below asks for: there is no
|
|
83
|
+
* generalised `<ns>:*` rule, so a namespace does not acquire a wildcard by existing. Unlike `agent:*` this
|
|
84
|
+
* one is **not inheritable** (`childEnv` strips it) — R-26's rule, because a root that handed
|
|
85
|
+
* `workspace:*` down would make routing attenuation meaningless below the root, which is the exact defect
|
|
86
|
+
* R-131 records.
|
|
87
|
+
*/
|
|
88
|
+
export const WORKSPACE_WILDCARD: Capability = "workspace:*";
|
|
78
89
|
|
|
79
90
|
export interface ResolveInput {
|
|
80
91
|
/** What the delegating agent asked to give the child. */
|
|
@@ -124,7 +135,10 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
124
135
|
const parent =
|
|
125
136
|
input.subsumption === false ? held : new Set(expandSubsumed(input.parentGrant));
|
|
126
137
|
/**
|
|
127
|
-
* `agent:*` covers any `agent:<name>` — ADR-0023
|
|
138
|
+
* `agent:*` covers any `agent:<name>` — ADR-0023. It was the only wildcard rule in this function when
|
|
139
|
+
* that was written; ADR-0035 added a third, so `covered()` now has `tool:*`, `agent:*` and `workspace:*`.
|
|
140
|
+
* There is still no GENERALISED `<ns>:*` rule: each is a deliberate edit, which is the property worth
|
|
141
|
+
* keeping. Corrected because the sentence sat eighty lines above "all THREE wildcards are excluded".
|
|
128
142
|
*
|
|
129
143
|
* `resolve` is otherwise exact-match plus subsumption, deliberately: `tool:*` works not because anything
|
|
130
144
|
* here understands it, but because `deriveOwnGrant` *enumerates* a session's observed tool names beside
|
|
@@ -149,8 +163,17 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
149
163
|
* spellings of one rule, and the enforcing one was wrong.
|
|
150
164
|
*/
|
|
151
165
|
const anyCapability = held.has(WILDCARD);
|
|
166
|
+
const anyWorkspace = held.has(WORKSPACE_WILDCARD);
|
|
167
|
+
// A malformed id is never covered — not by an exact hold, and above all not by a wildcard's PREFIX rule,
|
|
168
|
+
// which is how `agent:x,tool:bash` got admitted and then split into two capabilities in the child.
|
|
169
|
+
// Landing in `denied` is the right outcome rather than a throw: it fails closed AND records an
|
|
170
|
+
// escalation attempt, so the ledger shows the attempt instead of a clean line.
|
|
152
171
|
const covered = (c: Capability): boolean =>
|
|
153
|
-
|
|
172
|
+
isWellFormedCapability(c)
|
|
173
|
+
&& (parent.has(c) || anyCapability
|
|
174
|
+
|| (anyDefinition && c.startsWith("agent:"))
|
|
175
|
+
|| (anyWorkspace && c.startsWith("workspace:")));
|
|
176
|
+
|
|
154
177
|
const ceiling = input.ceiling === undefined ? null : new Set(input.ceiling);
|
|
155
178
|
const gated = new Set(input.gated ?? []);
|
|
156
179
|
const approved = new Set(input.approved ?? []);
|
|
@@ -193,7 +216,16 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
193
216
|
// F9: capabilities covered by a WILDCARD are not "subsumed" — this field means "the grant is broader
|
|
194
217
|
// than its list suggests", which is the `bash`-covers-`grep` warning. A wildcard holder already knows
|
|
195
218
|
// its grant is broad; listing every id under it would bury the signal the field exists to carry.
|
|
196
|
-
|
|
219
|
+
//
|
|
220
|
+
// All THREE wildcards are excluded. `anyWorkspace` was missing, so a caller holding `workspace:*` saw
|
|
221
|
+
// its own `workspace:prod` reported as subsumed while the `agent:*` holder correctly saw nothing — the
|
|
222
|
+
// field contradicting its own rule, and a false "broader than it looks" flag in the ledger and in
|
|
223
|
+
// `/grants`. Every wildcard added to `covered()` above needs a line here; that is now three for three.
|
|
224
|
+
subsumedBy: effective.filter((c) =>
|
|
225
|
+
!held.has(c)
|
|
226
|
+
&& !anyCapability
|
|
227
|
+
&& !(anyDefinition && c.startsWith("agent:"))
|
|
228
|
+
&& !(anyWorkspace && c.startsWith("workspace:"))),
|
|
197
229
|
};
|
|
198
230
|
}
|
|
199
231
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0035's three routing guards, and why each one is shaped the way it is.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
|
|
5
|
+
* same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
|
|
6
|
+
* line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
|
|
7
|
+
* is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
|
|
8
|
+
* alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
|
|
9
|
+
* guards.
|
|
10
|
+
*
|
|
11
|
+
* Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
|
|
12
|
+
* assembling records. `denied` is present exactly when the attempt should count as an escalation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
|
|
16
|
+
import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
17
|
+
import { WILDCARD } from "./pi-tools.ts";
|
|
18
|
+
import type { RefusalCode } from "./refusals.ts";
|
|
19
|
+
|
|
20
|
+
export interface RoutingRefusal {
|
|
21
|
+
code: RefusalCode;
|
|
22
|
+
reason: string;
|
|
23
|
+
/**
|
|
24
|
+
* The capability to record in `denied`, when this refusal IS an escalation attempt.
|
|
25
|
+
*
|
|
26
|
+
* Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
|
|
27
|
+
* with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
|
|
28
|
+
* unparseable. Present for an unauthorised route, which is exactly a bid for authority.
|
|
29
|
+
*/
|
|
30
|
+
denied?: Capability[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
|
|
35
|
+
*
|
|
36
|
+
* Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
|
|
37
|
+
* governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
|
|
38
|
+
* every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
|
|
39
|
+
* in `docs/probes/g36-workspace-attenuation`).
|
|
40
|
+
*
|
|
41
|
+
* **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
|
|
42
|
+
* it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
|
|
43
|
+
* `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
|
|
44
|
+
* `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
|
|
45
|
+
* unreadable. 0.18.1 is what happens when a comma goes unremarked.
|
|
46
|
+
*
|
|
47
|
+
* **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
|
|
48
|
+
* closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
|
|
49
|
+
* every audit query. An empty id is a malformed id, not an absent one.
|
|
50
|
+
*/
|
|
51
|
+
export function checkRoutingAuthority(
|
|
52
|
+
boundWorkspaceId: string | undefined,
|
|
53
|
+
ownGrant: readonly Capability[],
|
|
54
|
+
): RoutingRefusal | null {
|
|
55
|
+
if (boundWorkspaceId === undefined) return null;
|
|
56
|
+
const authorising = workspaceCapability(boundWorkspaceId);
|
|
57
|
+
if (!isSafeWorkspaceId(boundWorkspaceId)) {
|
|
58
|
+
return {
|
|
59
|
+
code: "GRANT_ID_MALFORMED",
|
|
60
|
+
reason:
|
|
61
|
+
`workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
|
|
62
|
+
`become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
|
|
63
|
+
`and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
|
|
67
|
+
// Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
|
|
68
|
+
// attempt, so listing it here points the model at a destination it can never reach.
|
|
69
|
+
const held = ownGrant
|
|
70
|
+
.filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
|
|
71
|
+
.sort();
|
|
72
|
+
return {
|
|
73
|
+
code: "WORKSPACE_NOT_AUTHORIZED",
|
|
74
|
+
// Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
|
|
75
|
+
// route somewhere this session was not granted IS an attempt to exceed the grant.
|
|
76
|
+
denied: [authorising],
|
|
77
|
+
reason:
|
|
78
|
+
`cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
|
|
79
|
+
(held.length > 0
|
|
80
|
+
? `It may route to: ${held.join(", ")}.`
|
|
81
|
+
: `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
|
|
87
|
+
*
|
|
88
|
+
* `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
|
|
89
|
+
* it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
|
|
90
|
+
* as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
|
|
91
|
+
* to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
|
|
92
|
+
* code for "this grant would not actually narrow", which is precisely what routing authority over every
|
|
93
|
+
* registered root does.
|
|
94
|
+
*
|
|
95
|
+
* **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
|
|
96
|
+
* `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
|
|
97
|
+
* `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
|
|
98
|
+
* nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
|
|
99
|
+
* child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
|
|
100
|
+
* `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
|
|
101
|
+
* appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
|
|
102
|
+
*
|
|
103
|
+
* **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
|
|
104
|
+
* wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
|
|
105
|
+
* `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
|
|
106
|
+
* `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
|
|
107
|
+
*/
|
|
108
|
+
export function checkWorkspaceWildcardRequest(
|
|
109
|
+
requested: readonly Capability[],
|
|
110
|
+
ownGrant: readonly Capability[],
|
|
111
|
+
): RoutingRefusal | null {
|
|
112
|
+
if (!requested.includes(WORKSPACE_WILDCARD)) return null;
|
|
113
|
+
if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
|
|
114
|
+
return {
|
|
115
|
+
code: "NARROWING_VIOLATED",
|
|
116
|
+
reason:
|
|
117
|
+
`cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
|
|
118
|
+
`holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
|
|
119
|
+
`Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
|
|
120
|
+
};
|
|
121
|
+
}
|