pi-daddy 0.35.0 → 0.36.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.
@@ -44,6 +44,17 @@ export interface Catalog {
44
44
  all: Capability[];
45
45
  byKind(kind: CapabilityKind): Capability[];
46
46
  has(capability: Capability): boolean;
47
+ /**
48
+ * Why the workspace registry contributed nothing, when one was named and could not be read.
49
+ *
50
+ * The catalog fails SOFT on a bad registry — a malformed one must not stop a session starting — and the
51
+ * refusal used to be discarded by `() => []` at the call below. That is rule 8's silent safe-mode: one
52
+ * malformed entry removed every workspace from `/grants`, from the catalog and from `init`, and produced
53
+ * no message anywhere, so an operator saw an empty list and could not tell it apart from having
54
+ * registered nothing. Failing soft is still right; failing soft and SILENTLY was not. `undefined` means
55
+ * no registry was named, or it read cleanly.
56
+ */
57
+ registryRefusal?: string;
47
58
  }
48
59
 
49
60
  /** Split observed tool names into pi built-ins and extension-provided tools. */
@@ -107,7 +118,7 @@ export function workspaceEntries(registry: WorkspaceRegistryFile, source?: strin
107
118
  }
108
119
 
109
120
  /** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
110
- export function makeCatalog(entries: CatalogEntry[]): Catalog {
121
+ export function makeCatalog(entries: CatalogEntry[], registryRefusal?: string): Catalog {
111
122
  const deduped = new Map<Capability, CatalogEntry>();
112
123
  for (const entry of entries) if (!deduped.has(entry.capability)) deduped.set(entry.capability, entry);
113
124
  const list = [...deduped.values()].sort((a, b) => a.capability.localeCompare(b.capability));
@@ -118,6 +129,7 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
118
129
  all: ids,
119
130
  byKind: (kind) => list.filter((e) => e.kind === kind).map((e) => e.capability),
120
131
  has: (capability) => idSet.has(capability),
132
+ ...(registryRefusal === undefined ? {} : { registryRefusal }),
121
133
  };
122
134
  }
123
135
 
@@ -135,30 +147,39 @@ export async function buildCatalog(input: {
135
147
  // session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
136
148
  // refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
137
149
  // it there would be unsafe; swallowing it here costs a display list.
150
+ //
151
+ // **What the refusal is no longer allowed to do is vanish.** This handler was `() => []`, so the display
152
+ // list was lost AND the reason with it. The reason now rides on the catalog and `/grants` prints it.
138
153
  input.registryPath
139
154
  ? loadWorkspaceRegistry(input.registryPath).then(
140
- (r) => workspaceEntries(r, input.registryPath),
141
- () => [] as CatalogEntry[],
155
+ (r) => ({ entries: workspaceEntries(r, input.registryPath), refusal: undefined as string | undefined }),
156
+ (error: unknown) => ({
157
+ entries: [] as CatalogEntry[],
158
+ refusal: error instanceof Error ? error.message : String(error),
159
+ }),
142
160
  )
143
- : Promise.resolve([] as CatalogEntry[]),
144
- ]);
145
- return makeCatalog([
146
- // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
147
- // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
148
- // every capability looked "unknown" until the first model call, so the preview refused grants that
149
- // enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
150
- // reappearing through a different door.
151
- //
152
- // The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
153
- // as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
154
- // the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
155
- // the grant check — not this catalog — is the authority. Nothing here grants anything.
156
- ...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
157
- ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
158
- ...skills,
159
- ...definitionEntries(definitions),
160
- ...workspaces,
161
+ : Promise.resolve({ entries: [] as CatalogEntry[], refusal: undefined as string | undefined }),
161
162
  ]);
163
+ return makeCatalog(
164
+ [
165
+ // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
166
+ // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
167
+ // every capability looked "unknown" until the first model call, so the preview refused grants that
168
+ // enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
169
+ // reappearing through a different door.
170
+ //
171
+ // The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
172
+ // as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
173
+ // the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
174
+ // the grant check — not this catalog — is the authority. Nothing here grants anything.
175
+ ...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
176
+ ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
177
+ ...skills,
178
+ ...definitionEntries(definitions),
179
+ ...workspaces.entries,
180
+ ],
181
+ workspaces.refusal,
182
+ );
162
183
  }
163
184
 
164
185
  /**
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { createHash } from "node:crypto";
23
- import { readFile } from "node:fs/promises";
23
+ import { readBoundedFile } from "./bounded-read.ts";
24
24
  import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
25
25
  import { CAPABILITY_NAMESPACE_PREFIXES } from "./capabilities.ts";
26
26
  import type { Capability } from "./resolve.ts";
@@ -194,22 +194,52 @@ export function ceilingForDefinition(definition: SkillDefinition): DefinitionCei
194
194
  return { capabilities: [...capabilities].sort(), patterns, undeclared: false };
195
195
  }
196
196
 
197
+ /**
198
+ * A `SKILL.md` is an operator-authored markdown file; anything approaching this is not one.
199
+ *
200
+ * The same order of magnitude as the registry's bound and for the same reason. Measured at `7096f78`: a
201
+ * bare `readFile` pulled an 8 MiB `SKILL.md` into memory in 8ms without complaint, and this loop runs once
202
+ * per discovered skill inside `session_start`.
203
+ */
204
+ export const DEFINITION_MAX_BYTES = 1 << 20;
205
+
206
+ /** A definition read is a local file read; a second is three orders of magnitude of headroom. */
207
+ export const DEFINITION_READ_TIMEOUT_MS = 2_000;
208
+
197
209
  /**
198
210
  * Read definitions from Pi's enabled resources, including installed packages and local overrides.
199
211
  * Resolver precedence and filters are shared with the capability catalog; unregistered npm packages
200
212
  * are not runtime resources until legacy init explicitly scaffolds them.
213
+ *
214
+ * **Bounded, and loud about what it dropped.** This used a bare `readFile` with `catch { continue }`, which
215
+ * is both halves of what rule 8 forbids: unbounded, and silent. `resolveSkillResources` filters by
216
+ * `statSync(...).isFile()`, so a FIFO *named* in the resource list is already dropped — but that check is by
217
+ * NAME and the read that followed was by name too, which is the TOCTOU the registry's own comment block
218
+ * describes swapping a regular file for a FIFO through. `readBoundedFile` makes every check against the held
219
+ * descriptor. `skipped` exists so a caller can say which paths were dropped and why, rather than an operator
220
+ * finding a definition absent from `/grants` with nothing anywhere explaining it.
201
221
  */
202
- export async function loadDefinitions(cwd: string): Promise<Map<string, SkillDefinition>> {
222
+ export async function loadDefinitions(
223
+ cwd: string,
224
+ skipped?: (path: string, reason: string) => void,
225
+ ): Promise<Map<string, SkillDefinition>> {
203
226
  const definitions = new Map<string, SkillDefinition>();
204
227
  for (const { path } of (await resolveSkillResources(cwd)).skills) {
205
- let text: string;
206
- try {
207
- text = await readFile(path, "utf8");
208
- } catch {
228
+ const read = await readBoundedFile(path, {
229
+ maxBytes: DEFINITION_MAX_BYTES,
230
+ timeoutMs: DEFINITION_READ_TIMEOUT_MS,
231
+ });
232
+ if (!read.ok) {
233
+ skipped?.(path, read.detail);
209
234
  continue;
210
235
  }
211
- const parsed = parseSkillDefinition(path, text);
212
- if (parsed && !definitions.has(parsed.name)) definitions.set(parsed.name, parsed);
236
+ const parsed = parseSkillDefinition(path, read.text);
237
+ if (!parsed) skipped?.(path, `${path} has no readable frontmatter with a description`);
238
+ // Shadowing is legitimate — a project override is SUPPOSED to win over a package's copy — but review
239
+ // pointed out it was the one remaining drop with no word said, in the very function being made loud.
240
+ else if (definitions.has(parsed.name))
241
+ skipped?.(path, `${path} is shadowed by an earlier definition named ${parsed.name}`);
242
+ else definitions.set(parsed.name, parsed);
213
243
  }
214
244
  return definitions;
215
245
  }
@@ -6,9 +6,16 @@
6
6
  */
7
7
 
8
8
  import { readdir, readFile, realpath } from "node:fs/promises";
9
+ import { readBoundedBytes } from "./bounded-read.ts";
9
10
  import { homedir } from "node:os";
10
11
  import { join, resolve, sep } from "node:path";
11
- import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
12
+ import {
13
+ ceilingForDefinition,
14
+ parseSkillDefinition,
15
+ type SkillDefinition,
16
+ DEFINITION_MAX_BYTES,
17
+ DEFINITION_READ_TIMEOUT_MS,
18
+ } from "./definitions.ts";
12
19
  import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
13
20
  import { WILDCARD } from "./pi-tools.ts";
14
21
  import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
@@ -146,12 +153,17 @@ async function readSkill(packageDir: string, entry: string): Promise<DiscoveredS
146
153
  // smoke test one day earlier and not applied here.
147
154
  const realPackageDir = await realpath(packageDir).catch(() => packageDir);
148
155
  for (const path of [join(target, "SKILL.md"), ...(target.endsWith(".md") ? [target] : [])]) {
149
- let bytes: Buffer;
150
- try {
151
- bytes = await readFile(path);
152
- } catch {
153
- continue;
154
- }
156
+ // **Bounded with the SAME limit the runtime loader uses, because the two must agree about which
157
+ // definitions exist.** Review measured the divergence: a 2 MiB `SKILL.md` was refused by
158
+ // `loadDefinitions` and accepted here, so `pi-daddy init` wrote `agent:advice` into the operator's grant
159
+ // for a definition the session could never load, and the eventual `delegate` said only
160
+ // `unknown agent "advice"`. An unreadable entry lands in `unreadable`, which `init` already prints.
161
+ const read = await readBoundedBytes(path, {
162
+ maxBytes: DEFINITION_MAX_BYTES,
163
+ timeoutMs: DEFINITION_READ_TIMEOUT_MS,
164
+ });
165
+ if (!read.ok) continue;
166
+ const bytes = read.bytes;
155
167
  const realPath = await realpath(path).catch(() => path);
156
168
  if (!realPath.startsWith(realPackageDir + sep)) return null;
157
169
 
@@ -255,8 +267,13 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
255
267
  }
256
268
  }
257
269
  for (const resource of resolved.skills) {
258
- const bytes = await readFile(resource.path).catch(() => null);
259
- if (bytes === null) continue;
270
+ // Same bound, same reason: this is the third `SKILL.md` reader and it feeds `planInit`.
271
+ const resourceRead = await readBoundedBytes(resource.path, {
272
+ maxBytes: DEFINITION_MAX_BYTES,
273
+ timeoutMs: DEFINITION_READ_TIMEOUT_MS,
274
+ });
275
+ if (!resourceRead.ok) continue;
276
+ const bytes = resourceRead.bytes;
260
277
  const text = bytes.toString("utf8");
261
278
  const resourceName = skillResourceName(resource.path);
262
279
  if (seenSkills.has(resourceName)) continue;
@@ -1,9 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import { once } from "node:events";
4
- import { mkdir, open, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
5
- import type { FileHandle } from "node:fs/promises";
6
- import { constants } from "node:fs";
4
+ import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
5
+ import { readBoundedFile } from "./bounded-read.ts";
7
6
  import { homedir } from "node:os";
8
7
  import { isAbsolute, join } from "node:path";
9
8
  import { promisify } from "node:util";
@@ -60,111 +59,36 @@ const REGISTRY_READ_TIMEOUT_MS = 2_000;
60
59
  const REGISTRY_MAX_BYTES = 1 << 20;
61
60
 
62
61
  export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegistryFile> {
63
- // **One handle, opened non-blocking, and every check made against THAT handle.** Three defects made this
64
- // the shape rather than `stat`-then-`readFile`:
65
- //
66
- // - `AbortSignal.timeout` cannot rescue a FIFO: a signal is observed between chunks, while a FIFO blocks
67
- // inside `open(2)` before any read begins. `O_NONBLOCK` makes the open itself return instead (measured:
68
- // 1ms), which is what actually bounds this path. R-79's defect class, and R-136's.
69
- // - `stat` by name followed by `readFile` by name is a TOCTOU: swapping a regular file for a FIFO between
70
- // the two hung the loader indefinitely, and the attacker is any process at the same uid — precisely the
71
- // actor the mode check below cannot exclude. Measured, 5 of 6 iterations completing in ~1ms and the
72
- // sixth never returning. `fstat` on a held descriptor has no name to re-resolve.
73
- // - a second reader (the ADR-0035 registry pin, since reverted to its own PR) reimplemented the guards and
74
- // got them wrong. One reader is why that cannot happen again.
75
- let handle: FileHandle;
76
- try {
77
- handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
78
- } catch (error) {
79
- throw new GovernanceRefusal(
80
- refusal("WORKSPACE_NOT_REGISTERED", `workspace registry ${path} could not be opened (${String(error)})`, {
81
- registry_path: path,
82
- }),
83
- );
84
- }
85
- let raw: string;
86
- try {
87
- const info = await handle.stat();
88
- if (!info.isFile()) {
89
- throw new GovernanceRefusal(
90
- refusal(
91
- "WORKSPACE_NOT_REGISTERED",
92
- `workspace registry ${path} is not a regular file — refusing to read it. A FIFO, device or socket ` +
93
- `at ${ENV_WORKSPACE_REGISTRY} would block session start rather than fail, because opening one ` +
94
- `waits for a writer that may never come.`,
95
- { registry_path: path },
96
- ),
97
- );
98
- }
99
- if (info.size > REGISTRY_MAX_BYTES) {
100
- throw new GovernanceRefusal(
101
- refusal(
102
- "WORKSPACE_NOT_REGISTERED",
103
- `workspace registry ${path} is ${info.size} bytes, over the ${REGISTRY_MAX_BYTES} limit — refusing ` +
104
- `rather than reading it into memory at session start.`,
105
- { registry_path: path },
106
- ),
107
- );
108
- }
109
- // **Ownership and mode are NOT checked here, and that is a scope decision (R-137, ADR-0036).** A
110
- // previous revision refused a registry not owned by this user or writable by others. Those guards are
111
- // about *tamper resistance*, which is a different question from the one ADR-0035 raised, and they went in
112
- // mid-review without an ADR — where they promptly acquired a false claim ("nobody ELSE may rewrite it":
113
- // it inspects the file and never its parent directory, and `rename(2)` needs only directory write) and a
114
- // false-positive refusal of `0664`, which `umask 002` produces for every file an operator creates.
115
- //
116
- // What IS checked above is what ADR-0035 made this reader's problem: it began reading the registry at
117
- // SESSION START, so the read must be bounded and must not block. Integrity is R-137, open and measured.
118
- // The deadline is checked BETWEEN chunks, which is all `AbortSignal.timeout` ever did on the previous
119
- // implementation — review measured 74 of 200 one-MiB reads completing in full with `signal.aborted`
120
- // already true, because a signal is never observed *inside* a libuv read request. An explicit check makes
121
- // the bound as real as it can be in-process, and its limit is the same one honestly stated below: a
122
- // stalled `open` or a single wedged read cannot be interrupted from here.
123
- const deadline = Date.now() + REGISTRY_READ_TIMEOUT_MS;
124
- const buffer = Buffer.allocUnsafe(REGISTRY_MAX_BYTES + 1);
125
- let filled = 0;
126
- while (filled < buffer.length) {
127
- if (Date.now() > deadline) {
128
- throw new GovernanceRefusal(
129
- refusal(
130
- "WORKSPACE_NOT_REGISTERED",
131
- `workspace registry ${path} did not finish reading within ${REGISTRY_READ_TIMEOUT_MS}ms — ` +
132
- `refusing rather than waiting, because session start awaits this read.`,
133
- { registry_path: path },
134
- ),
135
- );
136
- }
137
- const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
138
- if (bytesRead === 0) break;
139
- filled += bytesRead;
140
- }
141
- if (filled > REGISTRY_MAX_BYTES) {
142
- throw new GovernanceRefusal(
143
- refusal(
144
- "WORKSPACE_NOT_REGISTERED",
145
- `workspace registry ${path} exceeded the ${REGISTRY_MAX_BYTES} limit while being read — it grew ` +
146
- `after its size was checked. Refusing rather than allocating it.`,
147
- { registry_path: path },
148
- ),
149
- );
150
- }
151
- raw = buffer.subarray(0, filled).toString("utf8");
152
- } catch (error) {
153
- if (error instanceof GovernanceRefusal) throw error;
154
- const timedOut = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
155
- throw new GovernanceRefusal(
156
- refusal(
157
- "WORKSPACE_NOT_REGISTERED",
158
- timedOut
159
- ? `workspace registry ${path} did not return within ${REGISTRY_READ_TIMEOUT_MS}ms — refusing rather ` +
160
- `than waiting, because session start awaits this read.`
161
- : `workspace registry ${path} could not be read (${String(error)})`,
162
- { registry_path: path },
163
- ),
164
- );
165
- } finally {
166
- await handle.close().catch(() => {});
62
+ // The guards this call carries — non-blocking open, every check on the held descriptor, a deadline between
63
+ // chunks, the size bound checked twice — were worked out HERE and now live in `bounded-read.ts`, because a
64
+ // second session-start reader went on using a bare `readFile` rather than copying them. The mapping from a
65
+ // reason to a refusal stays here: only this caller knows that an unreadable registry is a governance
66
+ // refusal naming the file, and its messages are unchanged.
67
+ const read = await readBoundedFile(path, {
68
+ maxBytes: REGISTRY_MAX_BYTES,
69
+ timeoutMs: REGISTRY_READ_TIMEOUT_MS,
70
+ });
71
+ if (!read.ok) {
72
+ const message =
73
+ read.why === "not-a-regular-file"
74
+ ? `workspace registry ${path} is not a regular file — refusing to read it. A FIFO, device or socket ` +
75
+ `at ${ENV_WORKSPACE_REGISTRY} would block session start rather than fail, because opening one ` +
76
+ `waits for a writer that may never come.`
77
+ : read.why === "too-large"
78
+ ? `workspace registry ${path} is ${read.size} bytes, over the ${REGISTRY_MAX_BYTES} limit — ` +
79
+ `refusing rather than reading it into memory at session start.`
80
+ : read.why === "grew-while-reading"
81
+ ? `workspace registry ${path} exceeded the ${REGISTRY_MAX_BYTES} limit while being read — it ` +
82
+ `grew after its size was checked. Refusing rather than allocating it.`
83
+ : read.why === "timed-out"
84
+ ? `workspace registry ${path} did not finish reading within ${REGISTRY_READ_TIMEOUT_MS}ms — ` +
85
+ `refusing rather than waiting, because session start awaits this read.`
86
+ : read.why === "unopenable"
87
+ ? `workspace registry ${path} could not be opened (${read.detail})`
88
+ : `workspace registry ${path} could not be read (${read.detail})`;
89
+ throw new GovernanceRefusal(refusal("WORKSPACE_NOT_REGISTERED", message, { registry_path: path }));
167
90
  }
91
+ const raw = read.text;
168
92
  let parsed: unknown;
169
93
  try {
170
94
  parsed = JSON.parse(raw);
@@ -237,12 +161,22 @@ export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegi
237
161
  * that module — documents itself as "Pure: no filesystem". It is a registry concern; this is where the
238
162
  * registry lives. Moved when `init.ts` crossed the 400-line ceiling, which this project splits for rather
239
163
  * than raising (`delegate.ts` at 413, `grants.ts` at 398).
164
+ *
165
+ * **`onRefusal` exists because `catch { return [] }` was rule 8's silent safe-mode.** A malformed registry
166
+ * made `pi-daddy init` scaffold with no workspace capabilities at all and say nothing about why, which an
167
+ * operator cannot tell apart from having registered none. Failing soft stays — `init` must work without a
168
+ * registry — but the reason is handed to the caller instead of discarded. A caller that passes nothing keeps
169
+ * the old behaviour, which is why this is an optional parameter and not a changed return type.
240
170
  */
241
- export async function registeredWorkspaceIds(registryPath = process.env[ENV_WORKSPACE_REGISTRY]): Promise<string[]> {
171
+ export async function registeredWorkspaceIds(
172
+ registryPath = process.env[ENV_WORKSPACE_REGISTRY],
173
+ onRefusal?: (reason: string) => void,
174
+ ): Promise<string[]> {
242
175
  if (!registryPath) return [];
243
176
  try {
244
177
  return Object.keys((await loadWorkspaceRegistry(registryPath)).workspaces).sort();
245
- } catch {
178
+ } catch (error) {
179
+ onRefusal?.(error instanceof Error ? error.message : String(error));
246
180
  return [];
247
181
  }
248
182
  }