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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close herdr panes this process opened but never got to close.
|
|
3
|
+
*
|
|
4
|
+
* `runHerdrPane` closes its pane in a `finally`, which covers a thrown error and a timeout — **not the
|
|
5
|
+
* process being killed**. A pi session interrupted mid-fan-out left one pane per in-flight child, and
|
|
6
|
+
* `docs/probes/g16-herdr` records that an orphaned pane is not trivially closable afterwards.
|
|
7
|
+
*
|
|
8
|
+
* **Registered on `exit` only, deliberately — not on SIGINT or SIGTERM.** That is the part worth reading,
|
|
9
|
+
* because the obvious fix is the dangerous one. Adding a signal listener *suppresses Node's default
|
|
10
|
+
* termination*, so a library that adds one takes over an application-level decision it has no standing to
|
|
11
|
+
* make: pi uses SIGINT to interrupt a turn, and a listener here that re-raised would turn "cancel this
|
|
12
|
+
* delegation" into "exit pi". A governance package quietly changing the host's interrupt semantics is a
|
|
13
|
+
* worse defect than the leak it fixes, and it would land on **every** session rather than the opt-in ones.
|
|
14
|
+
*
|
|
15
|
+
* So the coverage is exact and stated rather than implied:
|
|
16
|
+
*
|
|
17
|
+
* - **Covered:** normal exit, `process.exit()`, an uncaught exception that unwinds to the default handler.
|
|
18
|
+
* - **NOT covered:** SIGKILL, and SIGTERM/SIGINT where nothing else in the process has installed a
|
|
19
|
+
* listener. Node terminates without running `exit` handlers in those cases, by design. A pane can still
|
|
20
|
+
* be orphaned there, and `herdr tab close <id>` is the manual remedy.
|
|
21
|
+
*
|
|
22
|
+
* Everything here is **synchronous**, because an `exit` handler is: a promise scheduled there never runs.
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { rmSync } from "node:fs";
|
|
26
|
+
/**
|
|
27
|
+
* Panes opened by THIS process and not yet closed. Keyed by tab id, so a double close is impossible.
|
|
28
|
+
*
|
|
29
|
+
* **That keying rests on tab ids being unique, which was measured rather than assumed** (herdr 0.7.5): ids
|
|
30
|
+
* are `w<workspace>:t<counter>`, allocated by the single server, and **not recycled** — creating a tab,
|
|
31
|
+
* closing it, and creating again yields the next counter value, never the freed one. If that ever changed,
|
|
32
|
+
* concurrent panes would collapse into one entry and an early finisher's `untrackPane` would drop a live
|
|
33
|
+
* sibling. Recorded because the hazard is invisible in the code and the precondition lives in another
|
|
34
|
+
* project.
|
|
35
|
+
*/
|
|
36
|
+
const open = new Map();
|
|
37
|
+
let hookInstalled = false;
|
|
38
|
+
/** A run has opened a pane. Idempotent per tab, and installs the exit hook on first use only. */
|
|
39
|
+
export function trackPane(pane) {
|
|
40
|
+
open.set(pane.tab, pane);
|
|
41
|
+
if (hookInstalled)
|
|
42
|
+
return;
|
|
43
|
+
hookInstalled = true;
|
|
44
|
+
// `once`, and only ever one, so a fan-out of eight children does not install eight handlers and trip
|
|
45
|
+
// Node's MaxListenersExceededWarning — which would be this package printing a warning about itself.
|
|
46
|
+
process.once("exit", () => void reapOpenPanes());
|
|
47
|
+
}
|
|
48
|
+
/** A run closed its own pane the normal way. */
|
|
49
|
+
export function untrackPane(tab) {
|
|
50
|
+
open.delete(tab);
|
|
51
|
+
}
|
|
52
|
+
/** How many panes are currently outstanding — for tests and for `/grants`. */
|
|
53
|
+
export function openPaneCount() {
|
|
54
|
+
return open.size;
|
|
55
|
+
}
|
|
56
|
+
/** Per-command wall clock. `SIGKILL` because `timeout` alone is not a bound — see `TOTAL_BUDGET_MS`. */
|
|
57
|
+
const PER_CALL_MS = 2000;
|
|
58
|
+
/**
|
|
59
|
+
* Total wall clock the whole sweep may add to process exit.
|
|
60
|
+
*
|
|
61
|
+
* **Measured, and the reason this exists.** With eight panes and a hung herdr, a per-call timeout of 5s
|
|
62
|
+
* across two calls per pane is **80 seconds of silent hang at shutdown** — `stdio: "ignore"`, so the process
|
|
63
|
+
* looks wedged with no output. Worse, `timeout` is not a hard bound at all: `spawnSync` sends `SIGTERM` and
|
|
64
|
+
* then waits for the child to actually die, so a process ignoring `SIGTERM` runs to its own completion
|
|
65
|
+
* (measured: a 3s timeout took 59.8s against `trap '' TERM; sleep 60`). Hence `killSignal: "SIGKILL"` **and**
|
|
66
|
+
* a budget across the whole sweep rather than per call.
|
|
67
|
+
*
|
|
68
|
+
* A pane left open because the budget ran out is the failure this whole module downgrades to, and it is the
|
|
69
|
+
* right one: `herdr tab close <id>` is a five-second manual fix, whereas a shell that will not exit is not.
|
|
70
|
+
*/
|
|
71
|
+
const TOTAL_BUDGET_MS = 6000;
|
|
72
|
+
/** Run one herdr command synchronously, swallowing everything: at exit there is nowhere to report. */
|
|
73
|
+
const defaultSyncExec = (args) => {
|
|
74
|
+
execFileSync("herdr", args, { stdio: "ignore", timeout: PER_CALL_MS, killSignal: "SIGKILL" });
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Close every outstanding pane and return the tab ids closed.
|
|
78
|
+
*
|
|
79
|
+
* Exported and parameterised so it can be tested without herdr installed: the exit hook is unreachable from
|
|
80
|
+
* a test (registering a real `exit` handler would run during the test runner's own shutdown), so the hook is
|
|
81
|
+
* one line and *this* is where the behaviour lives.
|
|
82
|
+
*
|
|
83
|
+
* Failures are swallowed per pane rather than per call — one pane herdr will not close must not strand the
|
|
84
|
+
* other seven, and nothing at exit has anywhere to report to anyway.
|
|
85
|
+
*/
|
|
86
|
+
export function reapOpenPanes(syncExec = defaultSyncExec, now = Date.now) {
|
|
87
|
+
const closed = [];
|
|
88
|
+
const deadline = now() + TOTAL_BUDGET_MS;
|
|
89
|
+
for (const pane of [...open.values()]) {
|
|
90
|
+
// Checked BEFORE each pane rather than after, so the budget bounds what we start, not what we finish.
|
|
91
|
+
// Panes left behind stay in the map; there is no later sweep, and saying so is the honest position —
|
|
92
|
+
// `openPaneCount()` is non-zero afterwards precisely so a caller could report it if it ever wanted to.
|
|
93
|
+
if (now() >= deadline)
|
|
94
|
+
break;
|
|
95
|
+
try {
|
|
96
|
+
syncExec(["agent", "stop", pane.name]);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* the agent may already be gone; the tab is what matters */
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
syncExec(["tab", "close", pane.tab]);
|
|
103
|
+
closed.push(pane.tab);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* an orphan we could not close: `herdr tab close` is the manual remedy, as documented above */
|
|
107
|
+
}
|
|
108
|
+
if (pane.promptDir) {
|
|
109
|
+
try {
|
|
110
|
+
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* /tmp litter, not correctness */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
open.delete(pane.tab);
|
|
117
|
+
}
|
|
118
|
+
return closed;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=pane-reaper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pane-reaper.js","sourceRoot":"","sources":["../src/pane-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAWjC;;;;;;;;;GASG;AACH,MAAM,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;AACzC,IAAI,aAAa,GAAG,KAAK,CAAC;AAE1B,iGAAiG;AACjG,MAAM,UAAU,SAAS,CAAC,IAAc;IACtC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACzB,IAAI,aAAa;QAAE,OAAO;IAC1B,aAAa,GAAG,IAAI,CAAC;IACrB,qGAAqG;IACrG,oGAAoG;IACpG,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,aAAa,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,gDAAgD;AAChD,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AACnB,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa;IAC3B,OAAO,IAAI,CAAC,IAAI,CAAC;AACnB,CAAC;AAED,wGAAwG;AACxG,MAAM,WAAW,GAAG,IAAI,CAAC;AAEzB;;;;;;;;;;;;GAYG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B,sGAAsG;AACtG,MAAM,eAAe,GAAG,CAAC,IAAc,EAAQ,EAAE;IAC/C,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC,CAAC;AAChG,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,WAAqC,eAAe,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG;IAChG,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,QAAQ,GAAG,GAAG,EAAE,GAAG,eAAe,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;QACtC,sGAAsG;QACtG,qGAAqG;QACrG,uGAAuG;QACvG,IAAI,GAAG,EAAE,IAAI,QAAQ;YAAE,MAAM;QAC7B,IAAI,CAAC;YACH,QAAQ,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,4DAA4D;QAC9D,CAAC;QACD,IAAI,CAAC;YACH,QAAQ,CAAC,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YACrC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACP,+FAA+F;QACjG,CAAC;QACD,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,IAAI,CAAC;gBACH,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3D,CAAC;YAAC,MAAM,CAAC;gBACP,kCAAkC;YACpC,CAAC;QACH,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facts about pi itself: its built-in tool names, and the capability that means "all of them".
|
|
3
|
+
*
|
|
4
|
+
* These lived in `agent-types.ts` until ADR-0016 deleted that module along with the rest of the
|
|
5
|
+
* `@tintinweb/pi-subagents` port. They are not about agent types at all — `PI_BUILTIN_TOOLS` is how the
|
|
6
|
+
* catalog tells a built-in from an extension tool, and `WILDCARD` is a capability id — so they outlive
|
|
7
|
+
* the port and belong somewhere that does not name a package we no longer depend on.
|
|
8
|
+
*
|
|
9
|
+
* **`PI_BUILTIN_TOOLS` is a pinned observation, and it has already drifted once** (R-31): pi 0.84.1
|
|
10
|
+
* exposes a `parallel` tool that the 0.83.0 list did not contain, which made it classify as an
|
|
11
|
+
* *extension* capability. The list is used for classification only — never for enforcement, which is
|
|
12
|
+
* `--tools`' job — so drift misfiles a capability rather than granting one. Keep it current anyway; a
|
|
13
|
+
* misfiled capability is a confusing catalog.
|
|
14
|
+
*/
|
|
15
|
+
import type { Capability } from "./resolve.ts";
|
|
16
|
+
/** Capability held only by a delegator authorised to hand out everything. */
|
|
17
|
+
export declare const WILDCARD: Capability;
|
|
18
|
+
/** pi's built-in tools, observed against 0.84.1. `parallel` was added since 0.83.0 — see R-31. */
|
|
19
|
+
export declare const PI_BUILTIN_TOOLS: readonly ["bash", "edit", "edit-diff", "find", "grep", "ls", "parallel", "read", "write"];
|
|
20
|
+
//# sourceMappingURL=pi-tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pi-tools.d.ts","sourceRoot":"","sources":["../src/pi-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,6EAA6E;AAC7E,eAAO,MAAM,QAAQ,EAAE,UAAqB,CAAC;AAG7C,kGAAkG;AAClG,eAAO,MAAM,gBAAgB,2FAEnB,CAAC"}
|
package/dist/pi-tools.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facts about pi itself: its built-in tool names, and the capability that means "all of them".
|
|
3
|
+
*
|
|
4
|
+
* These lived in `agent-types.ts` until ADR-0016 deleted that module along with the rest of the
|
|
5
|
+
* `@tintinweb/pi-subagents` port. They are not about agent types at all — `PI_BUILTIN_TOOLS` is how the
|
|
6
|
+
* catalog tells a built-in from an extension tool, and `WILDCARD` is a capability id — so they outlive
|
|
7
|
+
* the port and belong somewhere that does not name a package we no longer depend on.
|
|
8
|
+
*
|
|
9
|
+
* **`PI_BUILTIN_TOOLS` is a pinned observation, and it has already drifted once** (R-31): pi 0.84.1
|
|
10
|
+
* exposes a `parallel` tool that the 0.83.0 list did not contain, which made it classify as an
|
|
11
|
+
* *extension* capability. The list is used for classification only — never for enforcement, which is
|
|
12
|
+
* `--tools`' job — so drift misfiles a capability rather than granting one. Keep it current anyway; a
|
|
13
|
+
* misfiled capability is a confusing catalog.
|
|
14
|
+
*/
|
|
15
|
+
/** Capability held only by a delegator authorised to hand out everything. */
|
|
16
|
+
export const WILDCARD = "tool:*";
|
|
17
|
+
/** pi's built-in tools, observed against 0.84.1. `parallel` was added since 0.83.0 — see R-31. */
|
|
18
|
+
export const PI_BUILTIN_TOOLS = [
|
|
19
|
+
"bash", "edit", "edit-diff", "find", "grep", "ls", "parallel", "read", "write",
|
|
20
|
+
];
|
|
21
|
+
//# sourceMappingURL=pi-tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pi-tools.js","sourceRoot":"","sources":["../src/pi-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,QAAQ,GAAe,QAAQ,CAAC;AAG7C,kGAAkG;AAClG,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO;CACtE,CAAC"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race-free grant propagation.
|
|
3
|
+
*
|
|
4
|
+
* The first implementation wrote each child's computed grant into `process.env` inside the `tool_call`
|
|
5
|
+
* handler. The environment is process-global, so two concurrent spawns could read each other's values —
|
|
6
|
+
* a real hole in a security control.
|
|
7
|
+
*
|
|
8
|
+
* The fix removes the need for a per-child channel entirely, rather than trying to build one:
|
|
9
|
+
*
|
|
10
|
+
* 1. Everything pushed to children is a property of the PARENT, not of the individual spawn — the
|
|
11
|
+
* parent's own grant, the child depth (parent depth + 1), and the configured bounds. Those are
|
|
12
|
+
* identical for every sibling, so concurrent spawns cannot disagree and there is nothing to race.
|
|
13
|
+
* `ENV_APPROVED` (approvals a human granted at or above this level) is safe on the same global
|
|
14
|
+
* channel for the same reason: it is intersected with the parent's own grant before being written,
|
|
15
|
+
* which is itself a parent-level fact, so it too is identical for every sibling.
|
|
16
|
+
* 2. Each child derives ITS OWN grant on arrival: `inheritedParentGrant ∩ ownObservedTools`, where the
|
|
17
|
+
* observed set comes from the `tools` array of its first provider request — authoritative, because
|
|
18
|
+
* it is literally what pi sent the model.
|
|
19
|
+
*
|
|
20
|
+
* The environment is therefore written ONCE per session, before any spawn can occur (the first provider
|
|
21
|
+
* request precedes any tool call), and never mutated per spawn.
|
|
22
|
+
*
|
|
23
|
+
* The invariant still holds transitively: own = observed ∩ inheritedParent ⊆ inheritedParent, so no
|
|
24
|
+
* descendant can exceed the root. It is also defence in depth — even if a spawn slipped past the
|
|
25
|
+
* interceptor and pi handed the child more than the parent held, the intersection clamps it back.
|
|
26
|
+
*/
|
|
27
|
+
import type { Capability } from "./resolve.ts";
|
|
28
|
+
import { type InheritableApproval } from "./approval.ts";
|
|
29
|
+
export declare const ENV_GRANT = "PI_GRANTS_GRANT";
|
|
30
|
+
/**
|
|
31
|
+
* Total descendants this session may create in its whole subtree (`src/fanout.ts`).
|
|
32
|
+
*
|
|
33
|
+
* In `GRANT_ENV_KEYS` and therefore stripped from a child's environment and re-supplied only by the spawn
|
|
34
|
+
* plan — like depth, and for the same reason: it is capability state that must ATTENUATE downward, not an
|
|
35
|
+
* operator preference that should inherit. `PI_GRANTS_CHILD_TIMEOUT` is deliberately the other kind.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ENV_FANOUT = "PI_GRANTS_FANOUT";
|
|
38
|
+
/**
|
|
39
|
+
* This session's ledger id, so a child's records name their real parent (review finding F8).
|
|
40
|
+
*
|
|
41
|
+
* Without it every level restarts at `d0` and the ledger cannot be joined into a tree across processes.
|
|
42
|
+
*/
|
|
43
|
+
export declare const ENV_PARENT_ID = "PI_GRANTS_PARENT_ID";
|
|
44
|
+
export declare const ENV_DEPTH = "PI_GRANTS_DEPTH";
|
|
45
|
+
export declare const ENV_MAX_DEPTH = "PI_GRANTS_MAX_DEPTH";
|
|
46
|
+
export declare const ENV_GATED = "PI_GRANTS_GATED";
|
|
47
|
+
export declare const ENV_LEDGER = "PI_GRANTS_LEDGER";
|
|
48
|
+
export declare const ENV_APPROVED = "PI_GRANTS_APPROVED";
|
|
49
|
+
/**
|
|
50
|
+
* Every variable this package uses to push governance state at a child.
|
|
51
|
+
*
|
|
52
|
+
* Named as a set so `mergeChildEnv` can guarantee that none of them survives from the parent's own
|
|
53
|
+
* environment into a child's — a governance variable a child inherits by accident is one nobody decided
|
|
54
|
+
* to give it.
|
|
55
|
+
*/
|
|
56
|
+
export declare const GRANT_ENV_KEYS: readonly ["PI_GRANTS_GRANT", "PI_GRANTS_DEPTH", "PI_GRANTS_MAX_DEPTH", "PI_GRANTS_GATED", "PI_GRANTS_LEDGER", "PI_GRANTS_APPROVED", "PI_GRANTS_FANOUT", "PI_GRANTS_PARENT_ID"];
|
|
57
|
+
export declare const parseList: (raw: string | undefined) => Capability[];
|
|
58
|
+
/**
|
|
59
|
+
* Derive this session's own grant from what it inherited and what pi actually gave it.
|
|
60
|
+
*
|
|
61
|
+
* `observedTools` is the bare tool-name list from the session's own provider payload, or null when it
|
|
62
|
+
* has not been observed yet — in which case the inherited grant is used unchanged (it is already an
|
|
63
|
+
* upper bound, so this is safe, just less tight).
|
|
64
|
+
*
|
|
65
|
+
* A wildcard holder stays a wildcard holder: an enumerated observation must not silently downgrade an
|
|
66
|
+
* explicitly unlimited grant, or a root session would lose the authority it was configured with.
|
|
67
|
+
*
|
|
68
|
+
* **R-36 / ADR-0017 step 1: only tool-shaped capabilities are filtered.** The observation is a list of
|
|
69
|
+
* TOOLS, so it is evidence about `tool:` and `ext:` and about nothing else. Until this was fixed, a child
|
|
70
|
+
* inheriting `tool:read, skill:review` held only `tool:read` from its first provider request onward — it
|
|
71
|
+
* still *had* the skill (it arrives as `--skill`), but could not re-grant it and `/grants` stopped listing
|
|
72
|
+
* it. Silently, and in the narrowing direction, which is why it survived: nothing fails when a grant
|
|
73
|
+
* quietly shrinks. It also made ADR-0017's `agent:` prerequisite unsatisfiable below the root.
|
|
74
|
+
*/
|
|
75
|
+
export declare function deriveOwnGrant(inheritedParentGrant: Capability[], observedTools: string[] | null): Capability[];
|
|
76
|
+
/**
|
|
77
|
+
* Parse a bound from the environment, distinguishing **absent** from **malformed**.
|
|
78
|
+
*
|
|
79
|
+
* G7 / A-S4 + B-I4. `Number.parseInt` is the wrong tool for reading configuration: it accepts a numeric
|
|
80
|
+
* prefix (`parseInt("2abc")` is `2`), returns `NaN` for anything else, and `NaN` silently passes every
|
|
81
|
+
* comparison as false — so a malformed `PI_GRANTS_MAX_DEPTH` did not tighten the limit, it removed it.
|
|
82
|
+
*
|
|
83
|
+
* The three-way return is the point. `undefined` means "not configured, use the documented default";
|
|
84
|
+
* `null` means "configured wrongly", which callers must treat as a failure rather than a default,
|
|
85
|
+
* because a value someone tried to set and mistyped is not the same as one they never set.
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseBound(raw: string | undefined): number | null | undefined;
|
|
88
|
+
export interface DepthConfig {
|
|
89
|
+
depth: number;
|
|
90
|
+
maxDepth: number;
|
|
91
|
+
/** Names of the variables that were set but unreadable, for an operator-facing warning. */
|
|
92
|
+
malformed: string[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve this session's depth bounds, failing closed on anything malformed.
|
|
96
|
+
*
|
|
97
|
+
* **Malformed input disables spawning entirely (`maxDepth: 0`)** rather than falling back to a default,
|
|
98
|
+
* and that applies to a bad `PI_GRANTS_DEPTH` just as much as a bad `PI_GRANTS_MAX_DEPTH`. The old
|
|
99
|
+
* `|| 0` guard on depth failed open in a subtler way than the missing guard on maxDepth: a session that
|
|
100
|
+
* could not read its own depth was treated as a **root**, which is the most permissive answer available
|
|
101
|
+
* and precisely the value an attacker would choose. If we do not know how deep we are, we must not spawn.
|
|
102
|
+
*/
|
|
103
|
+
export declare function depthConfig(depthRaw: string | undefined, maxDepthRaw: string | undefined): DepthConfig;
|
|
104
|
+
/** The documented default child-depth bound when `PI_GRANTS_MAX_DEPTH` is not set. */
|
|
105
|
+
export declare const DEFAULT_MAX_DEPTH = 2;
|
|
106
|
+
/**
|
|
107
|
+
* Gated by default in a governed session (ADR-0012).
|
|
108
|
+
*
|
|
109
|
+
* `bash` is not one capability among others; it is an execution primitive. A child holding it can run
|
|
110
|
+
* `env -u PI_GRANTS_GRANT pi …` and obtain a completely **ungoverned** descendant — measured, not
|
|
111
|
+
* theorised (`docs/probes/g5-bash-escape`). Handing that down silently is the thing worth changing.
|
|
112
|
+
*
|
|
113
|
+
* Subsumption-aware gating (also ADR-0012) means this single entry covers `write`, `edit`, `read`,
|
|
114
|
+
* `grep`, `find` and `ls` as well, since `bash` confers all of them.
|
|
115
|
+
*/
|
|
116
|
+
export declare const DEFAULT_GATED: Capability[];
|
|
117
|
+
/**
|
|
118
|
+
* Read the gate list, distinguishing **absent** from **explicitly empty**.
|
|
119
|
+
*
|
|
120
|
+
* `parseList` alone cannot: it maps both `undefined` and `""` to `[]`. That distinction is the operator's
|
|
121
|
+
* only way to turn the default off — without it, someone who wants no gates would have to stop governing
|
|
122
|
+
* altogether, which is strictly worse than the thing they were trying to avoid.
|
|
123
|
+
*/
|
|
124
|
+
export declare function gatedFromEnv(raw: string | undefined): Capability[];
|
|
125
|
+
export interface ChildEnvInput {
|
|
126
|
+
/** This session's own grant — becomes the child's inherited parent grant. */
|
|
127
|
+
ownGrant: Capability[];
|
|
128
|
+
/** This session's depth; children are one deeper. */
|
|
129
|
+
depth: number;
|
|
130
|
+
maxDepth: number;
|
|
131
|
+
gated: Capability[];
|
|
132
|
+
/**
|
|
133
|
+
* Gated capabilities a human approved at or above this level.
|
|
134
|
+
*
|
|
135
|
+
* Safe to push on the GLOBAL channel because it is intersected with THIS session's own grant, which is
|
|
136
|
+
* a parent-level fact — identical for every sibling, so there is nothing to race on. Each child then
|
|
137
|
+
* re-intersects with its own grant on arrival, exactly as it does for the grant itself.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Approvals eligible to cross the boundary (ADR-0014): capability, subject and scope. `once` is
|
|
141
|
+
* dropped by `inheritApprovals`, and the subject is preserved so it cannot satisfy another one.
|
|
142
|
+
*/
|
|
143
|
+
approved?: InheritableApproval[];
|
|
144
|
+
ledgerPath?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Whether THIS session is governed — i.e. `PI_GRANTS_GRANT` was set for it.
|
|
147
|
+
*
|
|
148
|
+
* G7 / B-I8. Governance is opt-in: with the variable unset the README promises "nothing is blocked".
|
|
149
|
+
* That was true of the session itself and false of its children, because this function still exported
|
|
150
|
+
* a grant, a depth and a bound, so an ungoverned parent silently started governing its descendants —
|
|
151
|
+
* and the grant it exported was its own observed tool surface, which is a real restriction arrived at
|
|
152
|
+
* by accident. An ungoverned session must be transparent, not a source of policy.
|
|
153
|
+
*
|
|
154
|
+
* Defaults to `true` so that every existing caller keeps publishing; only the extension, which alone
|
|
155
|
+
* knows whether the variable was set, passes `false`.
|
|
156
|
+
*/
|
|
157
|
+
governed?: boolean;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The environment a child should inherit. Constant across all of this session's children by
|
|
161
|
+
* construction, which is what makes concurrent spawning safe.
|
|
162
|
+
*
|
|
163
|
+
* **The wildcard is never inherited.** A root may HOLD `tool:*` — that is authority to grant anything —
|
|
164
|
+
* but handing it down would let every descendant reacquire the full catalog, which makes attenuation
|
|
165
|
+
* meaningless below the root. Children therefore inherit the ENUMERATED grant only.
|
|
166
|
+
*
|
|
167
|
+
* Consequence, deliberate: a wildcard root that has not yet observed its own tools hands children an
|
|
168
|
+
* empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
|
|
169
|
+
* because a session's first provider request always precedes its first tool call.
|
|
170
|
+
*/
|
|
171
|
+
export declare function childEnv(input: ChildEnvInput): Record<string, string>;
|
|
172
|
+
/**
|
|
173
|
+
* The environment for a child this process spawns itself: the parent's environment with every governance
|
|
174
|
+
* variable stripped, then the per-child plan applied.
|
|
175
|
+
*
|
|
176
|
+
* Spreading `{ ...process.env, ...plan.env }` is not enough. `plan.env` omits keys that do not apply to
|
|
177
|
+
* this child (an empty approval set, no gated list), and an omitted key does not overwrite — so the
|
|
178
|
+
* parent's own value survives into the child. Stripping first makes the plan the ONLY source of every
|
|
179
|
+
* governance variable, which is what `delegate.ts`'s "nothing is written to the shared `process.env`"
|
|
180
|
+
* claim actually requires. Consumers re-clamp anyway; this is the defence in depth behind that.
|
|
181
|
+
*/
|
|
182
|
+
export declare function mergeChildEnv(parentEnv: NodeJS.ProcessEnv, planEnv: Record<string, string>): NodeJS.ProcessEnv;
|
|
183
|
+
/** Extract bare tool names from a provider payload's tool array, tolerating provider shape differences. */
|
|
184
|
+
export declare function observeToolNames(payload: unknown): string[] | null;
|
|
185
|
+
//# sourceMappingURL=propagation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"propagation.d.ts","sourceRoot":"","sources":["../src/propagation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,EAAoB,KAAK,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAE3E,eAAO,MAAM,SAAS,oBAAoB,CAAC;AAC3C;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,qBAAqB,CAAC;AAC7C;;;;GAIG;AACH,eAAO,MAAM,aAAa,wBAAwB,CAAC;AACnD,eAAO,MAAM,SAAS,oBAAoB,CAAC;AAC3C,eAAO,MAAM,aAAa,wBAAwB,CAAC;AACnD,eAAO,MAAM,SAAS,oBAAoB,CAAC;AAC3C,eAAO,MAAM,UAAU,qBAAqB,CAAC;AAC7C,eAAO,MAAM,YAAY,uBAAuB,CAAC;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,gLASjB,CAAC;AAEX,eAAO,MAAM,SAAS,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UAAU,EACW,CAAC;AAkB1E;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAC5B,oBAAoB,EAAE,UAAU,EAAE,EAClC,aAAa,EAAE,MAAM,EAAE,GAAG,IAAI,GAC7B,UAAU,EAAE,CAgBd;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAO7E;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,CAStG;AAED,sFAAsF;AACtF,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC;;;;;;;;;GASG;AACH,eAAO,MAAM,aAAa,EAAE,UAAU,EAAkB,CAAC;AAEzD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,EAAE,CAGlE;AAED,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB;;;;;;OAMG;IACH;;;OAGG;IACH,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAiBrE;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,CAAC,UAAU,EAC5B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,MAAM,CAAC,UAAU,CAInB;AAED,2GAA2G;AAC3G,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,GAAG,IAAI,CAoBlE"}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race-free grant propagation.
|
|
3
|
+
*
|
|
4
|
+
* The first implementation wrote each child's computed grant into `process.env` inside the `tool_call`
|
|
5
|
+
* handler. The environment is process-global, so two concurrent spawns could read each other's values —
|
|
6
|
+
* a real hole in a security control.
|
|
7
|
+
*
|
|
8
|
+
* The fix removes the need for a per-child channel entirely, rather than trying to build one:
|
|
9
|
+
*
|
|
10
|
+
* 1. Everything pushed to children is a property of the PARENT, not of the individual spawn — the
|
|
11
|
+
* parent's own grant, the child depth (parent depth + 1), and the configured bounds. Those are
|
|
12
|
+
* identical for every sibling, so concurrent spawns cannot disagree and there is nothing to race.
|
|
13
|
+
* `ENV_APPROVED` (approvals a human granted at or above this level) is safe on the same global
|
|
14
|
+
* channel for the same reason: it is intersected with the parent's own grant before being written,
|
|
15
|
+
* which is itself a parent-level fact, so it too is identical for every sibling.
|
|
16
|
+
* 2. Each child derives ITS OWN grant on arrival: `inheritedParentGrant ∩ ownObservedTools`, where the
|
|
17
|
+
* observed set comes from the `tools` array of its first provider request — authoritative, because
|
|
18
|
+
* it is literally what pi sent the model.
|
|
19
|
+
*
|
|
20
|
+
* The environment is therefore written ONCE per session, before any spawn can occur (the first provider
|
|
21
|
+
* request precedes any tool call), and never mutated per spawn.
|
|
22
|
+
*
|
|
23
|
+
* The invariant still holds transitively: own = observed ∩ inheritedParent ⊆ inheritedParent, so no
|
|
24
|
+
* descendant can exceed the root. It is also defence in depth — even if a spawn slipped past the
|
|
25
|
+
* interceptor and pi handed the child more than the parent held, the intersection clamps it back.
|
|
26
|
+
*/
|
|
27
|
+
import { WILDCARD } from "./pi-tools.js";
|
|
28
|
+
import { inheritApprovals } from "./approval.js";
|
|
29
|
+
export const ENV_GRANT = "PI_GRANTS_GRANT";
|
|
30
|
+
/**
|
|
31
|
+
* Total descendants this session may create in its whole subtree (`src/fanout.ts`).
|
|
32
|
+
*
|
|
33
|
+
* In `GRANT_ENV_KEYS` and therefore stripped from a child's environment and re-supplied only by the spawn
|
|
34
|
+
* plan — like depth, and for the same reason: it is capability state that must ATTENUATE downward, not an
|
|
35
|
+
* operator preference that should inherit. `PI_GRANTS_CHILD_TIMEOUT` is deliberately the other kind.
|
|
36
|
+
*/
|
|
37
|
+
export const ENV_FANOUT = "PI_GRANTS_FANOUT";
|
|
38
|
+
/**
|
|
39
|
+
* This session's ledger id, so a child's records name their real parent (review finding F8).
|
|
40
|
+
*
|
|
41
|
+
* Without it every level restarts at `d0` and the ledger cannot be joined into a tree across processes.
|
|
42
|
+
*/
|
|
43
|
+
export const ENV_PARENT_ID = "PI_GRANTS_PARENT_ID";
|
|
44
|
+
export const ENV_DEPTH = "PI_GRANTS_DEPTH";
|
|
45
|
+
export const ENV_MAX_DEPTH = "PI_GRANTS_MAX_DEPTH";
|
|
46
|
+
export const ENV_GATED = "PI_GRANTS_GATED";
|
|
47
|
+
export const ENV_LEDGER = "PI_GRANTS_LEDGER";
|
|
48
|
+
export const ENV_APPROVED = "PI_GRANTS_APPROVED";
|
|
49
|
+
/**
|
|
50
|
+
* Every variable this package uses to push governance state at a child.
|
|
51
|
+
*
|
|
52
|
+
* Named as a set so `mergeChildEnv` can guarantee that none of them survives from the parent's own
|
|
53
|
+
* environment into a child's — a governance variable a child inherits by accident is one nobody decided
|
|
54
|
+
* to give it.
|
|
55
|
+
*/
|
|
56
|
+
export const GRANT_ENV_KEYS = [
|
|
57
|
+
ENV_GRANT,
|
|
58
|
+
ENV_DEPTH,
|
|
59
|
+
ENV_MAX_DEPTH,
|
|
60
|
+
ENV_GATED,
|
|
61
|
+
ENV_LEDGER,
|
|
62
|
+
ENV_APPROVED,
|
|
63
|
+
ENV_FANOUT,
|
|
64
|
+
ENV_PARENT_ID,
|
|
65
|
+
];
|
|
66
|
+
export const parseList = (raw) => (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
67
|
+
/** Does a bare pi tool name correspond to this capability? `read` matches `tool:read` and `ext:pkg/read`. */
|
|
68
|
+
function matchesToolName(capability, toolName) {
|
|
69
|
+
if (capability === `tool:${toolName}`)
|
|
70
|
+
return true;
|
|
71
|
+
return capability.startsWith("ext:") && capability.slice(capability.lastIndexOf("/") + 1) === toolName;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Is this capability something an observed tool array can speak about at all? (R-36, ADR-0017 step 1.)
|
|
75
|
+
*
|
|
76
|
+
* `tool:` and `ext:` name tools, so a tool array that omits one is evidence the session does not have it.
|
|
77
|
+
* `skill:` and `agent:` name a loadable instruction file and a spawnable definition — neither is ever a
|
|
78
|
+
* tool, so an observation says **nothing** about them and must not be read as evidence of absence.
|
|
79
|
+
*/
|
|
80
|
+
const isToolCapability = (capability) => capability.startsWith("tool:") || capability.startsWith("ext:");
|
|
81
|
+
/**
|
|
82
|
+
* Derive this session's own grant from what it inherited and what pi actually gave it.
|
|
83
|
+
*
|
|
84
|
+
* `observedTools` is the bare tool-name list from the session's own provider payload, or null when it
|
|
85
|
+
* has not been observed yet — in which case the inherited grant is used unchanged (it is already an
|
|
86
|
+
* upper bound, so this is safe, just less tight).
|
|
87
|
+
*
|
|
88
|
+
* A wildcard holder stays a wildcard holder: an enumerated observation must not silently downgrade an
|
|
89
|
+
* explicitly unlimited grant, or a root session would lose the authority it was configured with.
|
|
90
|
+
*
|
|
91
|
+
* **R-36 / ADR-0017 step 1: only tool-shaped capabilities are filtered.** The observation is a list of
|
|
92
|
+
* TOOLS, so it is evidence about `tool:` and `ext:` and about nothing else. Until this was fixed, a child
|
|
93
|
+
* inheriting `tool:read, skill:review` held only `tool:read` from its first provider request onward — it
|
|
94
|
+
* still *had* the skill (it arrives as `--skill`), but could not re-grant it and `/grants` stopped listing
|
|
95
|
+
* it. Silently, and in the narrowing direction, which is why it survived: nothing fails when a grant
|
|
96
|
+
* quietly shrinks. It also made ADR-0017's `agent:` prerequisite unsatisfiable below the root.
|
|
97
|
+
*/
|
|
98
|
+
export function deriveOwnGrant(inheritedParentGrant, observedTools) {
|
|
99
|
+
if (observedTools === null)
|
|
100
|
+
return [...inheritedParentGrant];
|
|
101
|
+
// Capabilities an observation cannot speak about ride through both branches untouched.
|
|
102
|
+
const nonTool = inheritedParentGrant.filter((c) => !isToolCapability(c) && c !== WILDCARD);
|
|
103
|
+
if (inheritedParentGrant.includes(WILDCARD)) {
|
|
104
|
+
// Keep the wildcard, and additionally enumerate what was observed so descendants can be checked
|
|
105
|
+
// against concrete names too.
|
|
106
|
+
const enumerated = observedTools.map((t) => `tool:${t}`);
|
|
107
|
+
return [...new Set([WILDCARD, ...enumerated, ...nonTool])].sort();
|
|
108
|
+
}
|
|
109
|
+
return [
|
|
110
|
+
...inheritedParentGrant.filter((c) => isToolCapability(c) && observedTools.some((t) => matchesToolName(c, t))),
|
|
111
|
+
...nonTool,
|
|
112
|
+
].sort();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Parse a bound from the environment, distinguishing **absent** from **malformed**.
|
|
116
|
+
*
|
|
117
|
+
* G7 / A-S4 + B-I4. `Number.parseInt` is the wrong tool for reading configuration: it accepts a numeric
|
|
118
|
+
* prefix (`parseInt("2abc")` is `2`), returns `NaN` for anything else, and `NaN` silently passes every
|
|
119
|
+
* comparison as false — so a malformed `PI_GRANTS_MAX_DEPTH` did not tighten the limit, it removed it.
|
|
120
|
+
*
|
|
121
|
+
* The three-way return is the point. `undefined` means "not configured, use the documented default";
|
|
122
|
+
* `null` means "configured wrongly", which callers must treat as a failure rather than a default,
|
|
123
|
+
* because a value someone tried to set and mistyped is not the same as one they never set.
|
|
124
|
+
*/
|
|
125
|
+
export function parseBound(raw) {
|
|
126
|
+
if (raw === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const trimmed = raw.trim();
|
|
129
|
+
// Exact non-negative decimal integers only: no signs, no fractions, no 0x, no numeric prefixes.
|
|
130
|
+
if (!/^\d+$/.test(trimmed))
|
|
131
|
+
return null;
|
|
132
|
+
const value = Number(trimmed);
|
|
133
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve this session's depth bounds, failing closed on anything malformed.
|
|
137
|
+
*
|
|
138
|
+
* **Malformed input disables spawning entirely (`maxDepth: 0`)** rather than falling back to a default,
|
|
139
|
+
* and that applies to a bad `PI_GRANTS_DEPTH` just as much as a bad `PI_GRANTS_MAX_DEPTH`. The old
|
|
140
|
+
* `|| 0` guard on depth failed open in a subtler way than the missing guard on maxDepth: a session that
|
|
141
|
+
* could not read its own depth was treated as a **root**, which is the most permissive answer available
|
|
142
|
+
* and precisely the value an attacker would choose. If we do not know how deep we are, we must not spawn.
|
|
143
|
+
*/
|
|
144
|
+
export function depthConfig(depthRaw, maxDepthRaw) {
|
|
145
|
+
const depth = parseBound(depthRaw);
|
|
146
|
+
const maxDepth = parseBound(maxDepthRaw);
|
|
147
|
+
const malformed = [];
|
|
148
|
+
if (depth === null)
|
|
149
|
+
malformed.push(ENV_DEPTH);
|
|
150
|
+
if (maxDepth === null)
|
|
151
|
+
malformed.push(ENV_MAX_DEPTH);
|
|
152
|
+
if (malformed.length > 0)
|
|
153
|
+
return { depth: depth ?? 0, maxDepth: 0, malformed };
|
|
154
|
+
return { depth: depth ?? 0, maxDepth: maxDepth ?? DEFAULT_MAX_DEPTH, malformed };
|
|
155
|
+
}
|
|
156
|
+
/** The documented default child-depth bound when `PI_GRANTS_MAX_DEPTH` is not set. */
|
|
157
|
+
export const DEFAULT_MAX_DEPTH = 2;
|
|
158
|
+
/**
|
|
159
|
+
* Gated by default in a governed session (ADR-0012).
|
|
160
|
+
*
|
|
161
|
+
* `bash` is not one capability among others; it is an execution primitive. A child holding it can run
|
|
162
|
+
* `env -u PI_GRANTS_GRANT pi …` and obtain a completely **ungoverned** descendant — measured, not
|
|
163
|
+
* theorised (`docs/probes/g5-bash-escape`). Handing that down silently is the thing worth changing.
|
|
164
|
+
*
|
|
165
|
+
* Subsumption-aware gating (also ADR-0012) means this single entry covers `write`, `edit`, `read`,
|
|
166
|
+
* `grep`, `find` and `ls` as well, since `bash` confers all of them.
|
|
167
|
+
*/
|
|
168
|
+
export const DEFAULT_GATED = ["tool:bash"];
|
|
169
|
+
/**
|
|
170
|
+
* Read the gate list, distinguishing **absent** from **explicitly empty**.
|
|
171
|
+
*
|
|
172
|
+
* `parseList` alone cannot: it maps both `undefined` and `""` to `[]`. That distinction is the operator's
|
|
173
|
+
* only way to turn the default off — without it, someone who wants no gates would have to stop governing
|
|
174
|
+
* altogether, which is strictly worse than the thing they were trying to avoid.
|
|
175
|
+
*/
|
|
176
|
+
export function gatedFromEnv(raw) {
|
|
177
|
+
if (raw === undefined)
|
|
178
|
+
return [...DEFAULT_GATED];
|
|
179
|
+
return parseList(raw);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The environment a child should inherit. Constant across all of this session's children by
|
|
183
|
+
* construction, which is what makes concurrent spawning safe.
|
|
184
|
+
*
|
|
185
|
+
* **The wildcard is never inherited.** A root may HOLD `tool:*` — that is authority to grant anything —
|
|
186
|
+
* but handing it down would let every descendant reacquire the full catalog, which makes attenuation
|
|
187
|
+
* meaningless below the root. Children therefore inherit the ENUMERATED grant only.
|
|
188
|
+
*
|
|
189
|
+
* Consequence, deliberate: a wildcard root that has not yet observed its own tools hands children an
|
|
190
|
+
* empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
|
|
191
|
+
* because a session's first provider request always precedes its first tool call.
|
|
192
|
+
*/
|
|
193
|
+
export function childEnv(input) {
|
|
194
|
+
if (input.governed === false)
|
|
195
|
+
return {};
|
|
196
|
+
const inheritable = input.ownGrant.filter((c) => c !== WILDCARD);
|
|
197
|
+
const env = {
|
|
198
|
+
[ENV_GRANT]: inheritable.join(","),
|
|
199
|
+
[ENV_DEPTH]: String(input.depth + 1),
|
|
200
|
+
[ENV_MAX_DEPTH]: String(input.maxDepth),
|
|
201
|
+
};
|
|
202
|
+
if (input.gated.length > 0)
|
|
203
|
+
env[ENV_GATED] = input.gated.join(",");
|
|
204
|
+
// ALWAYS written, empty string included. This is the one value that changes during a session (a human
|
|
205
|
+
// approves something, or the session's own grant narrows on observation), and the interceptor path
|
|
206
|
+
// publishes it by ASSIGNING into the process-global `process.env`. Omitting it when empty would leave
|
|
207
|
+
// whatever was there before — the parent's own, unclamped `PI_GRANTS_APPROVED` — visible to every child.
|
|
208
|
+
// `parseList("")` is `[]`, so an empty value reads back exactly as an absent one.
|
|
209
|
+
env[ENV_APPROVED] = inheritApprovals(input.approved ?? [], inheritable).join(",");
|
|
210
|
+
if (input.ledgerPath)
|
|
211
|
+
env[ENV_LEDGER] = input.ledgerPath;
|
|
212
|
+
return env;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The environment for a child this process spawns itself: the parent's environment with every governance
|
|
216
|
+
* variable stripped, then the per-child plan applied.
|
|
217
|
+
*
|
|
218
|
+
* Spreading `{ ...process.env, ...plan.env }` is not enough. `plan.env` omits keys that do not apply to
|
|
219
|
+
* this child (an empty approval set, no gated list), and an omitted key does not overwrite — so the
|
|
220
|
+
* parent's own value survives into the child. Stripping first makes the plan the ONLY source of every
|
|
221
|
+
* governance variable, which is what `delegate.ts`'s "nothing is written to the shared `process.env`"
|
|
222
|
+
* claim actually requires. Consumers re-clamp anyway; this is the defence in depth behind that.
|
|
223
|
+
*/
|
|
224
|
+
export function mergeChildEnv(parentEnv, planEnv) {
|
|
225
|
+
const merged = { ...parentEnv };
|
|
226
|
+
for (const key of GRANT_ENV_KEYS)
|
|
227
|
+
delete merged[key];
|
|
228
|
+
return { ...merged, ...planEnv };
|
|
229
|
+
}
|
|
230
|
+
/** Extract bare tool names from a provider payload's tool array, tolerating provider shape differences. */
|
|
231
|
+
export function observeToolNames(payload) {
|
|
232
|
+
if (!payload || typeof payload !== "object")
|
|
233
|
+
return null;
|
|
234
|
+
const record = payload;
|
|
235
|
+
for (const key of ["tools", "functions"]) {
|
|
236
|
+
const candidate = record[key];
|
|
237
|
+
if (!Array.isArray(candidate))
|
|
238
|
+
continue;
|
|
239
|
+
const names = candidate
|
|
240
|
+
.map((entry) => {
|
|
241
|
+
if (!entry || typeof entry !== "object")
|
|
242
|
+
return undefined;
|
|
243
|
+
const e = entry;
|
|
244
|
+
// Anthropic/OpenAI tool objects, and OpenAI's nested `function.name` form.
|
|
245
|
+
const nested = e.function;
|
|
246
|
+
const name = e.name ?? nested?.name;
|
|
247
|
+
return typeof name === "string" ? name : undefined;
|
|
248
|
+
})
|
|
249
|
+
.filter((n) => Boolean(n));
|
|
250
|
+
// An empty tools array is a real observation (the session has no tools), not a failure to observe.
|
|
251
|
+
return names;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=propagation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"propagation.js","sourceRoot":"","sources":["../src/propagation.ts"],"names":[],"mappings":"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"}
|