pi-daddy 0.38.0 → 0.39.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 CHANGED
@@ -12,6 +12,40 @@ the record of how the package got here and are worth keeping; they are not worth
12
12
  > the record of how the package arrived at what it does, and because the reasoning behind each one is
13
13
  > usually the clearest statement of why the current behaviour is what it is.
14
14
 
15
+ ## 0.39.0 — the registry's id set is the operator's too
16
+
17
+ 0.38.0 bound what a workspace id MEANS. It said nothing about which ids exist, and the registry is an ordinary
18
+ file whose path a governed child inherits.
19
+
20
+ **Measured, and worse than the gap register claimed.** That entry said a child could add an id but that `init`
21
+ scaffolding ids commented out kept it unusable. With a `workspace:*` root, no operator action is needed at all:
22
+ a child holding `tool:write` appends an entry, the pin stops it resolving in that session and stops nothing
23
+ afterwards, and the NEXT root session mints a pin from the tampered registry while the wildcard admits any id.
24
+ End to end — the catalog listed it, the pin covered it, and it routed into the child's own directory.
25
+
26
+ **The accepted id set now lives beside the grant store**, in pi's agent directory. An id nobody accepted is not
27
+ pinned, and therefore not routable.
28
+
29
+ **What that does not buy, because the first draft of these notes claimed it did.** "Outside the workspace, where
30
+ a governed child cannot reach it" is false. A security review reproduced it end to end with pi's own `write`
31
+ tool from a real depth-1 child holding `tool:write` and no workspace capability: `write` takes an absolute path
32
+ and performs no confinement, the agent directory is inherited, and every path under it is deterministic. The
33
+ same technique widened the operator's STORED GRANT from `tool:read` to `tool:bash`, so the precedent this cited
34
+ does not hold either. What this release actually buys is the narrower closure it was built for — an attacker
35
+ who rewrites only the registry — and a raised cost for the wider one. The risk register now carries the class:
36
+ `tool:write` is much closer to `tool:bash` than this package has been treating it. `/grants
37
+ workspaces` accepts the registry's current ids. A malformed acceptance record accepts NOTHING rather than
38
+ falling back to the registry, so corrupting one byte is not a bypass.
39
+
40
+ **Trust on first use, and said out loud.** A machine that has accepted nothing accepts what the registry holds
41
+ the first time and writes it down, because refusing everything on upgrade would enforce a decision the operator
42
+ already made by writing that file. What matters is that additions afterwards are visible, and session start
43
+ announces the first acceptance rather than installing itself quietly.
44
+
45
+ **What this does not cover.** Acceptance is per registry, so an id accepted once is accepted for every project
46
+ sharing that registry. And it takes effect for the NEXT session: re-settling a live session's pin is exactly
47
+ the re-mint that ADR-0042 spent four review rounds closing.
48
+
15
49
  ## 0.38.0 — a rewritten registry can no longer change what an authorised id means (ADR-0042)
16
50
 
17
51
  **The escalation this closes, measured in `g37-registry-tamper`.** `workspace:<id>` attenuated the NAME, not the
@@ -0,0 +1,19 @@
1
+ export interface AcceptedWorkspaces {
2
+ /** Ids the operator has accepted for this registry. */
3
+ accepted: string[];
4
+ /** True when this call created the record — trust on first use, which the caller must report. */
5
+ firstUse: boolean;
6
+ /** Registry ids that are NOT accepted, so they can be named and refused. */
7
+ unaccepted: string[];
8
+ }
9
+ /**
10
+ * Compare a registry's ids against what has been accepted, creating the record on first use.
11
+ *
12
+ * **A malformed record accepts nothing** rather than falling back to the registry. It is the same direction
13
+ * `grant-store.ts` takes for a malformed grant, and for the same reason: the file is the record of a decision,
14
+ * so an unreadable one means the decision is unknown, not that anything goes.
15
+ */
16
+ export declare function reconcileAcceptedWorkspaces(registryPath: string, registryIds: readonly string[], env?: NodeJS.ProcessEnv): Promise<AcceptedWorkspaces>;
17
+ /** Accept the registry's current ids, replacing what was accepted before. `/grants` drives this. */
18
+ export declare function acceptWorkspaces(registryPath: string, registryIds: readonly string[], env?: NodeJS.ProcessEnv): Promise<string[]>;
19
+ //# sourceMappingURL=workspace-acceptance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-acceptance.d.ts","sourceRoot":"","sources":["../../src/governance/workspace-acceptance.ts"],"names":[],"mappings":"AA8CA,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iGAAiG;IACjG,QAAQ,EAAE,OAAO,CAAC;IAClB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAsBD;;;;;;GAMG;AACH,wBAAsB,2BAA2B,CAC/C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAkB7B;AAED,oGAAoG;AACpG,wBAAsB,gBAAgB,CACpC,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,OAAO,CAAC,MAAM,EAAE,CAAC,CAInB"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Which workspace ids the operator has accepted — the id SET, which the destination pin does not cover.
3
+ *
4
+ * **The escalation this closes, measured 2026-09-22.** ADR-0042 bound what an id MEANS; it says nothing about
5
+ * which ids exist. The registry is an ordinary file and a governed child holding `tool:write` inherits its
6
+ * path, so a child can append an entry of its own. The pin stops that id resolving in the session that added
7
+ * it — there is no pin entry for it — and stops nothing afterwards: the NEXT root session mints a pin from the
8
+ * tampered registry, the catalog lists the new id, and `workspace:*` admits any id at all. Measured end to end:
9
+ * `catalog ['workspace:evil','workspace:good']`, `pinned ['good','evil']`, routed into the child's own
10
+ * directory, with no operator action anywhere.
11
+ *
12
+ * So the id set needs the same treatment the grant got: recorded outside the workspace, beside the grant store
13
+ * and the approvals, in pi's agent directory.
14
+ *
15
+ * **What that does NOT buy, measured rather than assumed.** The first draft of this comment said "where a
16
+ * governed child cannot reach it". That is false, and a security review reproduced it end to end using pi's
17
+ * OWN `write` tool from a real depth-1 child holding `tool:write` and no workspace capability at all: the
18
+ * child inherited `PI_CODING_AGENT_DIR` (in neither `GRANT_ENV_KEYS` nor `GOVERNANCE_ENV_KEYS`), computed
19
+ * this file's path from the public, deterministic naming scheme, wrote a well-formed record accepting its own
20
+ * id, and the next root session pinned it and routed there with nothing reported.
21
+ *
22
+ * **Location is not a boundary for `tool:write`.** pi's `write` resolves a path against the cwd and writes it,
23
+ * with no check that the result is under the cwd — its own schema says "relative or absolute". The same review
24
+ * used it to widen the operator's STORED GRANT from `tool:read` to `tool:bash`, so `grant-store.ts`'s
25
+ * reasoning, which this module cited as precedent, does not hold either. That is pre-existing and is recorded
26
+ * in the risk register as a class rather than as two instances.
27
+ *
28
+ * **What this mechanism does buy, stated exactly.** It closes the escalation against an attacker who rewrites
29
+ * the REGISTRY only — which is the measured attack it was built for — and it raises the cost of the wider one
30
+ * from one write to a known path to two writes to two known paths. It is not a containment boundary and must
31
+ * not be read as one. A malformed record written here is a denial of service on all routing, which fails
32
+ * closed and is the right direction, but is worth knowing about.
33
+ *
34
+ * **Trust on first use, said out loud.** A machine that has never accepted anything accepts what the registry
35
+ * holds the first time and writes it down. The alternative — refuse everything until the operator accepts —
36
+ * would break every existing setup on upgrade to enforce a decision the operator has effectively already made
37
+ * by writing the file. What matters is that ADDITIONS afterwards are visible, and they are. The first
38
+ * acceptance is announced rather than silent, because a security control that installs itself quietly is one
39
+ * nobody knows they have.
40
+ *
41
+ * Keyed by registry path, not by project: accepting an id is a statement about the operator's registry.
42
+ */
43
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
44
+ import { dirname, join } from "node:path";
45
+ import { acceptedWorkspacesPath } from "../kernel/project-paths.js";
46
+ function parse(text) {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(text);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
55
+ return null;
56
+ if (parsed.version !== 1 || typeof parsed.registry !== "string")
57
+ return null;
58
+ if (!Array.isArray(parsed.accepted) || !parsed.accepted.every((id) => typeof id === "string" && id.length > 0))
59
+ return null;
60
+ return { version: 1, registry: parsed.registry, accepted: parsed.accepted };
61
+ }
62
+ /**
63
+ * Compare a registry's ids against what has been accepted, creating the record on first use.
64
+ *
65
+ * **A malformed record accepts nothing** rather than falling back to the registry. It is the same direction
66
+ * `grant-store.ts` takes for a malformed grant, and for the same reason: the file is the record of a decision,
67
+ * so an unreadable one means the decision is unknown, not that anything goes.
68
+ */
69
+ export async function reconcileAcceptedWorkspaces(registryPath, registryIds, env = process.env) {
70
+ const path = acceptedWorkspacesPath(registryPath, env);
71
+ const text = await readFile(path, "utf8").catch(() => undefined);
72
+ if (text === undefined) {
73
+ const accepted = [...registryIds].sort();
74
+ await write(path, { version: 1, registry: registryPath, accepted });
75
+ return { accepted, firstUse: true, unaccepted: [] };
76
+ }
77
+ const stored = parse(text);
78
+ // Malformed: accept nothing, and the caller says so. Not "accept everything", which would make corrupting
79
+ // one byte of this file the way to bypass it.
80
+ const accepted = stored ? stored.accepted : [];
81
+ const allowed = new Set(accepted);
82
+ return {
83
+ accepted,
84
+ firstUse: false,
85
+ unaccepted: [...registryIds].filter((id) => !allowed.has(id)).sort(),
86
+ };
87
+ }
88
+ /** Accept the registry's current ids, replacing what was accepted before. `/grants` drives this. */
89
+ export async function acceptWorkspaces(registryPath, registryIds, env = process.env) {
90
+ const accepted = [...new Set(registryIds)].sort();
91
+ await write(acceptedWorkspacesPath(registryPath, env), { version: 1, registry: registryPath, accepted });
92
+ return accepted;
93
+ }
94
+ /** Atomic, for the reason every other store here is: a half-written record reads as a narrower decision. */
95
+ async function write(path, body) {
96
+ await mkdir(dirname(path), { recursive: true });
97
+ const temp = join(dirname(path), `.${Math.random().toString(36).slice(2)}.tmp`);
98
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
99
+ await rename(temp, path);
100
+ }
101
+ //# sourceMappingURL=workspace-acceptance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-acceptance.js","sourceRoot":"","sources":["../../src/governance/workspace-acceptance.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,8 @@ export declare const USER_DIRS: Readonly<{
26
26
  grants: "grants";
27
27
  approvals: "approvals";
28
28
  workspaceLeases: "workspace-leases";
29
+ /** ADR-0042 follow-up: which registry ids this operator has accepted. Keyed by REGISTRY, not by project. */
30
+ workspaces: "workspaces";
29
31
  }>;
30
32
  /**
31
33
  * `<slug>-<16 hex>.json`: the basename keeps the directory legible, the hash makes it unambiguous, since two
@@ -34,6 +36,14 @@ export declare const USER_DIRS: Readonly<{
34
36
  export declare function projectFileName(cwd: string): string;
35
37
  export declare const grantStorePath: (cwd: string, env?: NodeJS.ProcessEnv) => string;
36
38
  export declare const approvalsPath: (cwd: string, env?: NodeJS.ProcessEnv) => string;
39
+ /**
40
+ * Where the accepted id set for one registry lives.
41
+ *
42
+ * Keyed by the registry path rather than by `cwd`, because accepting an id is a statement about the
43
+ * OPERATOR'S registry and not about a project: two checkouts sharing a registry share the decision, and
44
+ * re-accepting per project would be a prompt with no new information in it.
45
+ */
46
+ export declare const acceptedWorkspacesPath: (registryPath: string, env?: NodeJS.ProcessEnv) => string;
37
47
  export declare const workspaceLeasesDir: (env?: NodeJS.ProcessEnv) => string;
38
48
  /** Where the same stores lived before ADR-0076 PR 3c. Reported when found, never read (ADR-0020 precedent). */
39
49
  export declare const legacyUserGrantStorePath: (cwd: string, env?: NodeJS.ProcessEnv) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"project-paths.d.ts","sourceRoot":"","sources":["../../src/kernel/project-paths.ts"],"names":[],"mappings":"AAoBA,oEAAoE;AACpE,eAAO,MAAM,cAAc,QAAQ,CAAC;AACpC,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,eAAO,MAAM,aAAa;;;;;EAKxB,CAAC;AAEH,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,WAAuD,CAAC;AACvG,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,WAAwD,CAAC;AACzG,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,WAAqD,CAAC;AACnG,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,WAA+D,CAAC;AAEhH,iHAAiH;AACjH,eAAO,MAAM,yBAAyB,kJAIrC,CAAC;AAEF,wHAAwH;AACxH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,6DAA6D;AAC7D,wBAAgB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAGrE;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAEzE;AAED,eAAO,MAAM,SAAS;;;;EAIpB,CAAC;AAEH;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAInD;AAED,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACF,CAAC;AAClE,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACE,CAAC;AACrE,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,CAAC,UAAU,WAAuD,CAAC;AAElH,+GAA+G;AAC/G,eAAO,MAAM,wBAAwB,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACxB,CAAC;AACtD,eAAO,MAAM,uBAAuB,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACb,CAAC;AAChE,eAAO,MAAM,uBAAuB,GAAI,KAAK,MAAM,WAA4C,CAAC;AAChG,eAAO,MAAM,kBAAkB,GAAI,KAAK,MAAM,WAA0C,CAAC"}
1
+ {"version":3,"file":"project-paths.d.ts","sourceRoot":"","sources":["../../src/kernel/project-paths.ts"],"names":[],"mappings":"AAoBA,oEAAoE;AACpE,eAAO,MAAM,cAAc,QAAQ,CAAC;AACpC,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,eAAO,MAAM,aAAa;;;;;EAKxB,CAAC;AAEH,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,WAAuD,CAAC;AACvG,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,WAAwD,CAAC;AACzG,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,WAAqD,CAAC;AACnG,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,WAA+D,CAAC;AAEhH,iHAAiH;AACjH,eAAO,MAAM,yBAAyB,kJAIrC,CAAC;AAEF,wHAAwH;AACxH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,6DAA6D;AAC7D,wBAAgB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAGrE;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAEzE;AAED,eAAO,MAAM,SAAS;;;;IAIpB,4GAA4G;;EAE5G,CAAC;AAEH;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAInD;AAED,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACF,CAAC;AAClE,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACE,CAAC;AACrE;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GAAI,cAAc,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACN,CAAC;AAE/E,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,CAAC,UAAU,WAAuD,CAAC;AAElH,+GAA+G;AAC/G,eAAO,MAAM,wBAAwB,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACxB,CAAC;AACtD,eAAO,MAAM,uBAAuB,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC,UAAU,WACb,CAAC;AAChE,eAAO,MAAM,uBAAuB,GAAI,KAAK,MAAM,WAA4C,CAAC;AAChG,eAAO,MAAM,kBAAkB,GAAI,KAAK,MAAM,WAA0C,CAAC"}
@@ -60,6 +60,8 @@ export const USER_DIRS = Object.freeze({
60
60
  grants: "grants",
61
61
  approvals: "approvals",
62
62
  workspaceLeases: "workspace-leases",
63
+ /** ADR-0042 follow-up: which registry ids this operator has accepted. Keyed by REGISTRY, not by project. */
64
+ workspaces: "workspaces",
63
65
  });
64
66
  /**
65
67
  * `<slug>-<16 hex>.json`: the basename keeps the directory legible, the hash makes it unambiguous, since two
@@ -72,6 +74,14 @@ export function projectFileName(cwd) {
72
74
  }
73
75
  export const grantStorePath = (cwd, env) => join(userStateDir(env), USER_DIRS.grants, projectFileName(cwd));
74
76
  export const approvalsPath = (cwd, env) => join(userStateDir(env), USER_DIRS.approvals, projectFileName(cwd));
77
+ /**
78
+ * Where the accepted id set for one registry lives.
79
+ *
80
+ * Keyed by the registry path rather than by `cwd`, because accepting an id is a statement about the
81
+ * OPERATOR'S registry and not about a project: two checkouts sharing a registry share the decision, and
82
+ * re-accepting per project would be a prompt with no new information in it.
83
+ */
84
+ export const acceptedWorkspacesPath = (registryPath, env) => join(userStateDir(env), USER_DIRS.workspaces, projectFileName(registryPath));
75
85
  export const workspaceLeasesDir = (env) => join(userStateDir(env), USER_DIRS.workspaceLeases);
76
86
  /** Where the same stores lived before ADR-0076 PR 3c. Reported when found, never read (ADR-0020 precedent). */
77
87
  export const legacyUserGrantStorePath = (cwd, env) => join(agentDir(env), "grants", projectFileName(cwd));
@@ -1 +1 @@
1
- {"version":3,"file":"project-paths.js","sourceRoot":"","sources":["../../src/kernel/project-paths.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"project-paths.js","sourceRoot":"","sources":["../../src/kernel/project-paths.ts"],"names":[],"mappings":"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"}
@@ -36,6 +36,10 @@ export interface GrantsCommandContext {
36
36
  advisor: { decider: string; refusal?: string };
37
37
  /** ADR-0042: which ids this session pinned, so a routing refusal is discoverable before it happens. */
38
38
  workspacePin?: ReadonlyMap<string, string>;
39
+ /** Registry ids this machine has never accepted: present, not routable, and worth naming (rule 8). */
40
+ unacceptedWorkspaces?: readonly string[];
41
+ /** Accept the registry's current ids. Injected, so this diagnostic does not become a governance path. */
42
+ acceptWorkspaces?: () => Promise<string[]>;
39
43
  observed: boolean;
40
44
  depth: number;
41
45
  maxDepth: number;
@@ -79,7 +83,7 @@ export interface GrantsCommandContext {
79
83
  const PREVIEW_LIMIT = 12;
80
84
 
81
85
  /** The verbs `/grants` answers to. Anything else is refused rather than silently treated as no verb. */
82
- const KNOWN_SUBCOMMANDS: readonly string[] = ["init", "dashboard", "ledger", "approvals", "revoke"];
86
+ const KNOWN_SUBCOMMANDS: readonly string[] = ["init", "dashboard", "ledger", "approvals", "revoke", "workspaces"];
83
87
 
84
88
  export const grantsCommand = {
85
89
  description:
@@ -95,6 +99,7 @@ export const grantsCommand = {
95
99
  executor,
96
100
  advisor,
97
101
  workspacePin,
102
+ unacceptedWorkspaces,
98
103
  observed,
99
104
  depth,
100
105
  maxDepth,
@@ -122,6 +127,23 @@ export const grantsCommand = {
122
127
  )
123
128
  return;
124
129
 
130
+ if (sub === "workspaces") {
131
+ // The one verb that WRITES, and it takes its ability to do so from the caller — the same shape
132
+ // `/grants init` uses, so this screen cannot become a governance path by accident.
133
+ const accept = ctx.grants.acceptWorkspaces as (() => Promise<string[]>) | undefined;
134
+ if (!accept) {
135
+ ctx.ui.notify("grants: no workspace registry is configured, so there is nothing to accept.", "warning");
136
+ return;
137
+ }
138
+ const accepted = await accept();
139
+ ctx.ui.notify(
140
+ `grants: accepted ${accepted.length} workspace id(s) — ${accepted.join(", ") || "(none)"}. ` +
141
+ `Ids added to the registry after this are refused until accepted again.`,
142
+ "info",
143
+ );
144
+ return;
145
+ }
146
+
125
147
  if (sub === "ledger") {
126
148
  // The detector, made reachable. `verifyLedger` exists because nothing in this package had ever read
127
149
  // a ledger back, so a torn line was indistinguishable from a spawn that never happened — and a
@@ -383,10 +405,24 @@ export const grantsCommand = {
383
405
  `${catalog.byKind("workspace").length} workspace`,
384
406
  ...(catalog.byKind("workspace").length > 0
385
407
  ? [
386
- ` routable ${catalog.byKind("workspace").join(", ")} — held ones only are usable (ADR-0035)`,
408
+ // **Marked here rather than left to a join.** The catalog loads the UNNARROWED registry, so it
409
+ // lists an id nobody accepted; session start names it separately, and review pointed out that a
410
+ // reader has to put the two together. An id that cannot be routed to should not read as routable.
411
+ ` routable ${catalog
412
+ .byKind("workspace")
413
+ .map((id) =>
414
+ (unacceptedWorkspaces ?? []).includes(id.slice("workspace:".length)) ? `${id} (NOT ACCEPTED)` : id,
415
+ )
416
+ .join(", ")} — held ones only are usable (ADR-0035)`,
387
417
  // ADR-0042 made a destination pin a PRECONDITION for routing, and it had no operator surface at
388
418
  // all: not here, not at session start, not in the README. An operator refused for want of a pin
389
419
  // could not discover that the mechanism existed, let alone which ids it covered.
420
+ ...(unacceptedWorkspaces && unacceptedWorkspaces.length > 0
421
+ ? [
422
+ ` unaccepted ${unacceptedWorkspaces.join(", ")} — in the registry, never accepted here, ` +
423
+ `so not routable. /grants workspaces to review and accept.`,
424
+ ]
425
+ : []),
390
426
  ` pinned ${
391
427
  workspacePin === undefined
392
428
  ? "(none — no workspace is routable this session)"
@@ -36,6 +36,8 @@ import { grantsCommand } from "./grants-command.ts";
36
36
  import { runInit } from "./init-command.ts";
37
37
  import { planWithApprovals } from "./run-delegation.ts";
38
38
  import { createGrantsSession, loadProjectDefinitions, type GrantsSession } from "./session.ts";
39
+ import { acceptWorkspaces } from "../src/governance/workspace-acceptance.ts";
40
+ import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts";
39
41
  import { bindReloadLifecycle } from "./reload-environment.ts";
40
42
  import { reconcileActiveDelegationTools } from "./delegation-activation.ts";
41
43
  import { resolveExecutor } from "./executor-session.ts";
@@ -379,6 +381,21 @@ export default function (pi: ExtensionAPI) {
379
381
  ...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
380
382
  },
381
383
  ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
384
+ ...(session.workspaceAcceptance ? { unacceptedWorkspaces: session.workspaceAcceptance.unaccepted } : {}),
385
+ // Injected rather than imported, so `/grants` takes its one writing ability visibly from here.
386
+ ...(process.env[ENV_WORKSPACE_REGISTRY]
387
+ ? {
388
+ acceptWorkspaces: async () => {
389
+ const registryPath = process.env[ENV_WORKSPACE_REGISTRY]!;
390
+ const registry = await loadWorkspaceRegistry(registryPath);
391
+ const accepted = await acceptWorkspaces(registryPath, Object.keys(registry.workspaces));
392
+ // The acceptance takes effect for the NEXT session, not this one: this session's pin is
393
+ // settled and re-settling it here would be the re-mint that ADR-0042's own review spent
394
+ // four rounds closing. Said plainly by the caller rather than implied.
395
+ return accepted;
396
+ },
397
+ }
398
+ : {}),
382
399
  catalog: session.catalog,
383
400
  definitions: session.definitions,
384
401
  sessionApprovals: session.sessionApprovals,
@@ -69,6 +69,15 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
69
69
  ctx.ui.notify(`grants: a definition was not loaded — ${reason}`, "warning");
70
70
  // A registered workspace that could not be pinned is not routable this session, and the routing refusal an
71
71
  // operator would otherwise meet names neither the directory nor the reason.
72
+ // Trust on first use, announced rather than silent: a control that installs itself quietly is one nobody
73
+ // knows they have, and the whole value of this record is that ADDITIONS are visible afterwards.
74
+ if (session.workspaceAcceptance?.firstUse && session.workspaceAcceptance.accepted.length > 0)
75
+ ctx.ui.notify(
76
+ `grants: accepted ${session.workspaceAcceptance.accepted.length} workspace id(s) from this registry for ` +
77
+ `the first time (${session.workspaceAcceptance.accepted.join(", ")}). Ids added to the registry from ` +
78
+ `now on are refused until you accept them with /grants workspaces.`,
79
+ "info",
80
+ );
72
81
  for (const skipped of session.workspaceSkips)
73
82
  ctx.ui.notify(
74
83
  `grants: workspace ${skipped} — it is registered but not routable this session (ADR-0042 destination pin)`,
@@ -65,6 +65,7 @@ import {
65
65
  ENV_WORKSPACE_PIN,
66
66
  } from "../src/kernel/workspace-pin.ts";
67
67
  import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts";
68
+ import { reconcileAcceptedWorkspaces } from "../src/governance/workspace-acceptance.ts";
68
69
 
69
70
  /**
70
71
  * Run governed children in herdr panes instead of captured child processes.
@@ -219,6 +220,8 @@ export interface GrantsSession extends NativeSessionHost {
219
220
  pinSettled: boolean;
220
221
  /** Registered workspaces this session could not pin, and why — reported at session start (rule 8). */
221
222
  workspaceSkips: string[];
223
+ /** Which registry ids this machine has accepted, and which it has not. Reported, and drives `/grants`. */
224
+ workspaceAcceptance?: { accepted: string[]; firstUse: boolean; unaccepted: string[] };
222
225
  catalog: Catalog;
223
226
  /**
224
227
  * The in-flight catalog build, so `delegate` can wait for it instead of racing it.
@@ -351,7 +354,19 @@ async function settleWorkspacePin(session: GrantsSession): Promise<WorkspacePins
351
354
  if (!registryPath) return new Map();
352
355
  try {
353
356
  const registry = await loadWorkspaceRegistry(registryPath);
354
- return await establishWorkspacePin(registry, realpath, (id, reason) =>
357
+ // **The id SET, which the destination pin does not cover.** ADR-0042 bound what an id means; a child
358
+ // holding `tool:write` inherits the registry path and can append an id of its own, and the NEXT root
359
+ // session mints a pin for it. Measured: a child-created id reached the catalog, the pin and a real route
360
+ // with no operator action. So an id nobody accepted is not pinned, and therefore not routable.
361
+ const acceptance = await reconcileAcceptedWorkspaces(registryPath, Object.keys(registry.workspaces));
362
+ session.workspaceAcceptance = acceptance;
363
+ for (const id of acceptance.unaccepted)
364
+ session.workspaceSkips.push(`${id} — it is in the registry but this machine has never accepted it`);
365
+ const accepted = new Set(acceptance.accepted);
366
+ const narrowed = {
367
+ workspaces: Object.fromEntries(Object.entries(registry.workspaces).filter(([id]) => accepted.has(id))),
368
+ };
369
+ return await establishWorkspacePin(narrowed, realpath, (id, reason) =>
355
370
  session.workspaceSkips.push(`${id} — ${reason}`),
356
371
  );
357
372
  } catch {
@@ -435,6 +450,7 @@ export function createGrantsSession(
435
450
  workspacePin: undefined,
436
451
  pinSettled: false,
437
452
  workspaceSkips: [],
453
+ workspaceAcceptance: undefined,
438
454
  // ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one
439
455
  // (no PI_DADDY_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always
440
456
  // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Which workspace ids the operator has accepted — the id SET, which the destination pin does not cover.
3
+ *
4
+ * **The escalation this closes, measured 2026-09-22.** ADR-0042 bound what an id MEANS; it says nothing about
5
+ * which ids exist. The registry is an ordinary file and a governed child holding `tool:write` inherits its
6
+ * path, so a child can append an entry of its own. The pin stops that id resolving in the session that added
7
+ * it — there is no pin entry for it — and stops nothing afterwards: the NEXT root session mints a pin from the
8
+ * tampered registry, the catalog lists the new id, and `workspace:*` admits any id at all. Measured end to end:
9
+ * `catalog ['workspace:evil','workspace:good']`, `pinned ['good','evil']`, routed into the child's own
10
+ * directory, with no operator action anywhere.
11
+ *
12
+ * So the id set needs the same treatment the grant got: recorded outside the workspace, beside the grant store
13
+ * and the approvals, in pi's agent directory.
14
+ *
15
+ * **What that does NOT buy, measured rather than assumed.** The first draft of this comment said "where a
16
+ * governed child cannot reach it". That is false, and a security review reproduced it end to end using pi's
17
+ * OWN `write` tool from a real depth-1 child holding `tool:write` and no workspace capability at all: the
18
+ * child inherited `PI_CODING_AGENT_DIR` (in neither `GRANT_ENV_KEYS` nor `GOVERNANCE_ENV_KEYS`), computed
19
+ * this file's path from the public, deterministic naming scheme, wrote a well-formed record accepting its own
20
+ * id, and the next root session pinned it and routed there with nothing reported.
21
+ *
22
+ * **Location is not a boundary for `tool:write`.** pi's `write` resolves a path against the cwd and writes it,
23
+ * with no check that the result is under the cwd — its own schema says "relative or absolute". The same review
24
+ * used it to widen the operator's STORED GRANT from `tool:read` to `tool:bash`, so `grant-store.ts`'s
25
+ * reasoning, which this module cited as precedent, does not hold either. That is pre-existing and is recorded
26
+ * in the risk register as a class rather than as two instances.
27
+ *
28
+ * **What this mechanism does buy, stated exactly.** It closes the escalation against an attacker who rewrites
29
+ * the REGISTRY only — which is the measured attack it was built for — and it raises the cost of the wider one
30
+ * from one write to a known path to two writes to two known paths. It is not a containment boundary and must
31
+ * not be read as one. A malformed record written here is a denial of service on all routing, which fails
32
+ * closed and is the right direction, but is worth knowing about.
33
+ *
34
+ * **Trust on first use, said out loud.** A machine that has never accepted anything accepts what the registry
35
+ * holds the first time and writes it down. The alternative — refuse everything until the operator accepts —
36
+ * would break every existing setup on upgrade to enforce a decision the operator has effectively already made
37
+ * by writing the file. What matters is that ADDITIONS afterwards are visible, and they are. The first
38
+ * acceptance is announced rather than silent, because a security control that installs itself quietly is one
39
+ * nobody knows they have.
40
+ *
41
+ * Keyed by registry path, not by project: accepting an id is a statement about the operator's registry.
42
+ */
43
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
44
+ import { dirname, join } from "node:path";
45
+ import { acceptedWorkspacesPath } from "../kernel/project-paths.ts";
46
+
47
+ export interface AcceptedWorkspaces {
48
+ /** Ids the operator has accepted for this registry. */
49
+ accepted: string[];
50
+ /** True when this call created the record — trust on first use, which the caller must report. */
51
+ firstUse: boolean;
52
+ /** Registry ids that are NOT accepted, so they can be named and refused. */
53
+ unaccepted: string[];
54
+ }
55
+
56
+ interface StoredAcceptance {
57
+ version: 1;
58
+ registry: string;
59
+ accepted: string[];
60
+ }
61
+
62
+ function parse(text: string): StoredAcceptance | null {
63
+ let parsed: Partial<StoredAcceptance>;
64
+ try {
65
+ parsed = JSON.parse(text) as Partial<StoredAcceptance>;
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
70
+ if (parsed.version !== 1 || typeof parsed.registry !== "string") return null;
71
+ if (!Array.isArray(parsed.accepted) || !parsed.accepted.every((id) => typeof id === "string" && id.length > 0))
72
+ return null;
73
+ return { version: 1, registry: parsed.registry, accepted: parsed.accepted };
74
+ }
75
+
76
+ /**
77
+ * Compare a registry's ids against what has been accepted, creating the record on first use.
78
+ *
79
+ * **A malformed record accepts nothing** rather than falling back to the registry. It is the same direction
80
+ * `grant-store.ts` takes for a malformed grant, and for the same reason: the file is the record of a decision,
81
+ * so an unreadable one means the decision is unknown, not that anything goes.
82
+ */
83
+ export async function reconcileAcceptedWorkspaces(
84
+ registryPath: string,
85
+ registryIds: readonly string[],
86
+ env: NodeJS.ProcessEnv = process.env,
87
+ ): Promise<AcceptedWorkspaces> {
88
+ const path = acceptedWorkspacesPath(registryPath, env);
89
+ const text = await readFile(path, "utf8").catch(() => undefined);
90
+ if (text === undefined) {
91
+ const accepted = [...registryIds].sort();
92
+ await write(path, { version: 1, registry: registryPath, accepted });
93
+ return { accepted, firstUse: true, unaccepted: [] };
94
+ }
95
+ const stored = parse(text);
96
+ // Malformed: accept nothing, and the caller says so. Not "accept everything", which would make corrupting
97
+ // one byte of this file the way to bypass it.
98
+ const accepted = stored ? stored.accepted : [];
99
+ const allowed = new Set(accepted);
100
+ return {
101
+ accepted,
102
+ firstUse: false,
103
+ unaccepted: [...registryIds].filter((id) => !allowed.has(id)).sort(),
104
+ };
105
+ }
106
+
107
+ /** Accept the registry's current ids, replacing what was accepted before. `/grants` drives this. */
108
+ export async function acceptWorkspaces(
109
+ registryPath: string,
110
+ registryIds: readonly string[],
111
+ env: NodeJS.ProcessEnv = process.env,
112
+ ): Promise<string[]> {
113
+ const accepted = [...new Set(registryIds)].sort();
114
+ await write(acceptedWorkspacesPath(registryPath, env), { version: 1, registry: registryPath, accepted });
115
+ return accepted;
116
+ }
117
+
118
+ /** Atomic, for the reason every other store here is: a half-written record reads as a narrower decision. */
119
+ async function write(path: string, body: StoredAcceptance): Promise<void> {
120
+ await mkdir(dirname(path), { recursive: true });
121
+ const temp = join(dirname(path), `.${Math.random().toString(36).slice(2)}.tmp`);
122
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
123
+ await rename(temp, path);
124
+ }
@@ -70,6 +70,8 @@ export const USER_DIRS = Object.freeze({
70
70
  grants: "grants",
71
71
  approvals: "approvals",
72
72
  workspaceLeases: "workspace-leases",
73
+ /** ADR-0042 follow-up: which registry ids this operator has accepted. Keyed by REGISTRY, not by project. */
74
+ workspaces: "workspaces",
73
75
  });
74
76
 
75
77
  /**
@@ -86,6 +88,16 @@ export const grantStorePath = (cwd: string, env?: NodeJS.ProcessEnv) =>
86
88
  join(userStateDir(env), USER_DIRS.grants, projectFileName(cwd));
87
89
  export const approvalsPath = (cwd: string, env?: NodeJS.ProcessEnv) =>
88
90
  join(userStateDir(env), USER_DIRS.approvals, projectFileName(cwd));
91
+ /**
92
+ * Where the accepted id set for one registry lives.
93
+ *
94
+ * Keyed by the registry path rather than by `cwd`, because accepting an id is a statement about the
95
+ * OPERATOR'S registry and not about a project: two checkouts sharing a registry share the decision, and
96
+ * re-accepting per project would be a prompt with no new information in it.
97
+ */
98
+ export const acceptedWorkspacesPath = (registryPath: string, env?: NodeJS.ProcessEnv) =>
99
+ join(userStateDir(env), USER_DIRS.workspaces, projectFileName(registryPath));
100
+
89
101
  export const workspaceLeasesDir = (env?: NodeJS.ProcessEnv) => join(userStateDir(env), USER_DIRS.workspaceLeases);
90
102
 
91
103
  /** Where the same stores lived before ADR-0076 PR 3c. Reported when found, never read (ADR-0020 precedent). */