pi-open-agents 0.1.26 → 0.1.28

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/README.md CHANGED
@@ -93,7 +93,7 @@ here — or native in pi itself:
93
93
  |---|---|
94
94
  | `AGENTS.md` adoption — anthropics/claude-code#31005 👍336, openai/codex#12115 👍109 | ✅ Native in pi: `AGENTS.md`/`CLAUDE.md` discovery with `AGENTS.override.md` layering |
95
95
  | Agent Skills standard (`.agents/skills/`) — agentskills/agentskills#15 👍183 | ✅ Native in pi; shared agents from `.agents/` (#7 aligns to the dotagents layout) |
96
- | Tool restrictions that actually reach subagents — anthropics/claude-code#18950 👍70 | ✅ Since v0.1.14, `tools:`/`permission:` restrict the child toolset via `--tools` (full parent inheritance → #8) |
96
+ | Tool restrictions that actually reach subagents — anthropics/claude-code#18950 👍70 | ✅ Since v0.1.14 via `--tools`; full **deny/ask inheritance** (effective-set ceiling + arg patterns) since v0.1.27 |
97
97
  | Model selection per subagent / per call — sst/opencode#6651 👍71 | ✅ Per-agent `model:` + per-call `model:` override in `subagent()` (since v0.1.19) |
98
98
  | Reasoning effort per agent — Kilo-Org/kilocode#13105 👍8 | ✅ `thinking: off…xhigh` per agent, forwarded to the child process |
99
99
  | Delegation guards — Kilo-Org/kilocode#9985 | ✅ `allowedAgents` allowlist + `maxDepth` recursion limit |
@@ -167,6 +167,31 @@ active agent's rules *before it runs* — not just used to filter the toolset:
167
167
  > blocks matching *individual calls* (patterns included), not just statically
168
168
  > hiding whole tools. (#20)
169
169
 
170
+ **Inheritance down the delegation chain (#8).** A subagent can never exceed
171
+ the authority of the agent that delegated to it — in both directions of the
172
+ rule language:
173
+
174
+ - **Tool ceiling:** at delegation the parent's *live* tool set is snapshotted
175
+ (`pi.getActiveTools()`), minus tools it blanket-`deny`s or blanket-`ask`s
176
+ (`ask` is not authority: headless children have no approval bridge). The
177
+ child's own whitelist is intersected with that ceiling and passed via
178
+ `--tools` — an empty intersection becomes `--no-tools`, never "all tools".
179
+ - **Argument patterns:** the parent's `deny`/`ask` rules (patterns included)
180
+ travel to the child's gate and are evaluated *last*, so the child cannot
181
+ widen them (`write: {"**/*.env": deny}` upstream blocks `.env` writes two
182
+ levels deep, whatever the child declares).
183
+ - **Transitive:** the ceiling the child launches with *is* the child's active
184
+ set — a grandchild cannot regain any tool removed at an earlier hop.
185
+
186
+ ### Iterating on agents: `/reload`
187
+
188
+ Edit a `.agent.md` file (or add/remove one) and run pi's built-in **`/reload`**
189
+ — no session restart (#9). Agent definitions are re-discovered, the active
190
+ agent is **kept** (your mid-session switch survives; `--agent` is a
191
+ startup-only default), and a deleted/disabled active agent falls back cleanly
192
+ with a warning. New agents appear in `/agents` and the `subagent` tool list
193
+ immediately.
194
+
170
195
  ### Auto-injected delegation guidance
171
196
 
172
197
  If an agent has the `subagent` tool, the plugin automatically appends a
@@ -359,8 +384,8 @@ six sessions, zero chaos: that's the exact workstation setup documented in
359
384
 
360
385
  ## Roadmap
361
386
 
362
- - **Parent permission inheritance** for subagents — #8
363
- - **Agent hot-reload** (or `/reload-agents`) — #9
387
+ - ~~**Parent permission inheritance** for subagents~~ — ✅ shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#8)
388
+ - ~~**Agent hot-reload**~~ — ✅ works via pi's built-in `/reload`, shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#9)
364
389
  - ~~**Per-call model override** in `subagent()`~~ — ✅ shipped in [0.1.19](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.19) (#10)
365
390
  - **Parallel subagent limit** — #11
366
391
 
@@ -373,7 +398,7 @@ opening an issue or pull request.
373
398
 
374
399
  ```bash
375
400
  npm install
376
- npm test # 117 tests
401
+ npm test # 191 tests
377
402
  npm run typecheck # tsc --noEmit
378
403
  ```
379
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-agents",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ import { setShowAgentDescription, updateBanner } from "./tui/banner.ts";
26
26
  import { registerTuiControls } from "./tui/selector.ts";
27
27
  import { registerAgentTools } from "./tui/tools.ts";
28
28
  import { registerSubagentTool } from "./subagent/tool.ts";
29
- import { allowedAgentNames, isSubagentProcess } from "./subagent/env.ts";
29
+ import { allowedAgentNames, isSubagentProcess, parentInheritedRules } from "./subagent/env.ts";
30
30
  import { registerPermissionGate } from "./permission/gate.ts";
31
31
 
32
32
  // ─── Settings ────────────────────────────────────────────────────────────────
@@ -111,6 +111,8 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
111
111
  if (childName) return manager.getAgent(childName);
112
112
  return manager.getActive();
113
113
  },
114
+ // #8: restrictive rules handed down by the delegating parent (env JSON).
115
+ inheritedRules: () => parentInheritedRules(process.env),
114
116
  });
115
117
 
116
118
  // Subagent child processes are fully configured by the executor via CLI flags
@@ -131,9 +133,11 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
131
133
  return;
132
134
  }
133
135
 
134
- // Check for --agent CLI flag first (highest priority)
136
+ // Check for --agent CLI flag first (highest priority at startup).
137
+ // Skipped on reload: the flag is a startup default — a mid-session agent
138
+ // switch must survive /reload, so the session entry below wins instead.
135
139
  const agentFlag = pi.getFlag("agent");
136
- if (typeof agentFlag === "string" && agentFlag) {
140
+ if (typeof agentFlag === "string" && agentFlag && event.reason !== "reload") {
137
141
  const agent = manager.getAgent(agentFlag);
138
142
  if (agent) {
139
143
  await manager.apply(agentFlag, pi, ctx);
@@ -142,8 +146,9 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
142
146
  return;
143
147
  }
144
148
 
145
- // Try to restore from session state (if resuming)
146
- if (event.reason === "resume" || event.reason === "fork") {
149
+ // Restore from session state (resume, fork, or /reload — all must keep
150
+ // the agent the session was actually using, not re-apply startup defaults)
151
+ if (event.reason === "resume" || event.reason === "fork" || event.reason === "reload") {
147
152
  const entries = ctx.sessionManager.getEntries();
148
153
  const agentEntry = entries
149
154
  .filter((e: { type: string; customType?: string }) =>
@@ -256,8 +261,8 @@ export { matchPattern, isWildcardPattern } from "./permission/matcher.ts";
256
261
  export { evaluate, toolToPermission, getDisabledTools, filterDisabledTools } from "./permission/evaluator.ts";
257
262
 
258
263
  // Subagent re-exports
259
- export { runSubagent, resolveSubagentSession, resolvePiEntryPoint } from "./subagent/executor.ts";
260
- export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode } from "./subagent/executor.ts";
264
+ export { runSubagent, resolveSubagentSession, resolvePiEntryPoint, isVirtualBunPath, resolveCompiledCommand } from "./subagent/executor.ts";
265
+ export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode, HostProcessProbe } from "./subagent/executor.ts";
261
266
  export { resolveSkills } from "./subagent/skills.ts";
262
267
  export type { ResolvedSkill } from "./subagent/skills.ts";
263
268
  export { buildSubagentPrompt } from "./subagent/prompt.ts";
@@ -140,22 +140,32 @@ export interface PermissionGateOptions {
140
140
  resolveAgent: () => AgentDefinition | undefined;
141
141
  /** Session-scoped "always allow" memo (per process). Injectable for tests. */
142
142
  alwaysAllow?: Map<string, boolean>;
143
+ /**
144
+ * Rules inherited from the delegating parent (#8). Appended AFTER the
145
+ * agent's own rules — last-match-wins, so an inherited deny/ask always
146
+ * beats the child's own allow for the same call (authority ceiling).
147
+ */
148
+ inheritedRules?: () => PermissionRule[];
143
149
  }
144
150
 
145
151
  /**
146
152
  * Register the permission gate on the `tool_call` event.
147
153
  *
148
- * Does nothing for agents without permission rules.
154
+ * Does nothing when neither the agent nor the chain contributes rules.
149
155
  */
150
156
  export function registerPermissionGate(pi: ExtensionAPI, options: PermissionGateOptions): void {
151
157
  const alwaysAllow = options.alwaysAllow ?? new Map<string, boolean>();
152
158
 
153
159
  pi.on("tool_call", async (event, ctx: ExtensionContext) => {
154
160
  const agent = options.resolveAgent();
155
- if (!agent?.permission) return undefined;
161
+ const ownRules = agent?.permission ? parsePermissionRules(agent.permission) : [];
162
+ const inherited = options.inheritedRules?.() ?? [];
163
+ if (ownRules.length === 0 && inherited.length === 0) return undefined;
156
164
 
157
165
  const input = (event as { input?: Record<string, unknown> }).input ?? {};
158
- const rules = parsePermissionRules(agent.permission);
166
+ // Parent rules last: last-match-wins makes an inherited deny/ask beat
167
+ // the child's own allow — the authority ceiling cannot be widened (#8).
168
+ const rules = [...ownRules, ...inherited];
159
169
  const decision = decideToolCall(event.toolName, input, rules);
160
170
 
161
171
  if (!decision.gated || decision.action === "allow") return undefined;
@@ -19,6 +19,7 @@ export type RecursionEnv = Partial<
19
19
  | "PI_OPEN_AGENTS_SYSTEM_PROMPT_MODE"
20
20
  | "PI_OPEN_AGENTS_ALLOWED"
21
21
  | "PI_OPEN_AGENTS_SESSION"
22
+ | "PI_OPEN_AGENTS_PARENT_RULES"
22
23
  | "PI_OPEN_AGENTS_DEBUG",
23
24
  string
24
25
  >
@@ -65,6 +66,35 @@ export function isSubagentProcess(env: RecursionEnv): boolean {
65
66
  return depth !== undefined && depth > 0;
66
67
  }
67
68
 
69
+ /**
70
+ * Get the parent's inherited deny/ask rules from env (issue #8).
71
+ *
72
+ * The delegating parent serializes the restrictive subset of its permission
73
+ * rules (deny/ask, pattern-level included) as JSON. The child's permission
74
+ * gate appends them after its own rules (last-match-wins → parent wins).
75
+ * Malformed or absent → no inheritance (empty list).
76
+ */
77
+ export function parentInheritedRules(
78
+ env: RecursionEnv,
79
+ ): Array<{ permission: string; pattern: string; action: "deny" | "ask" }> {
80
+ const raw = env?.PI_OPEN_AGENTS_PARENT_RULES;
81
+ if (!raw) return [];
82
+ try {
83
+ const parsed: unknown = JSON.parse(raw);
84
+ if (!Array.isArray(parsed)) return [];
85
+ return parsed.filter(
86
+ (r): r is { permission: string; pattern: string; action: "deny" | "ask" } =>
87
+ typeof r === "object" &&
88
+ r !== null &&
89
+ typeof (r as any).permission === "string" &&
90
+ typeof (r as any).pattern === "string" &&
91
+ ((r as any).action === "deny" || (r as any).action === "ask"),
92
+ );
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
68
98
  /**
69
99
  * Get the system prompt mode from env.
70
100
  */
@@ -91,6 +121,7 @@ export interface SubagentEnvVars {
91
121
  systemPromptMode: SystemPromptMode;
92
122
  session: "none" | "fork";
93
123
  allowedAgents?: string[];
124
+ parentRules?: string;
94
125
  debug: boolean;
95
126
  }
96
127
 
@@ -111,5 +142,9 @@ export function buildSubagentEnv(vars: SubagentEnvVars): Record<string, string>
111
142
  env.PI_OPEN_AGENTS_ALLOWED = vars.allowedAgents.join(",");
112
143
  }
113
144
 
145
+ if (vars.parentRules) {
146
+ env.PI_OPEN_AGENTS_PARENT_RULES = vars.parentRules;
147
+ }
148
+
114
149
  return env;
115
150
  }
@@ -116,6 +116,17 @@ export interface RunSubagentOptions {
116
116
  session?: SubagentSessionInfo;
117
117
  /** Per-call model override ("provider/model-id"). Takes precedence over agent.model. */
118
118
  modelOverride?: string;
119
+ /**
120
+ * Parent's effective tool authority at delegation time (issue #8).
121
+ * The child's representable toolset is intersected with it; the result
122
+ * travels to the grandchild through pi's own active set (CLI ceiling),
123
+ * so authority can only shrink along the delegation chain.
124
+ * Undefined = no ceiling (unrestricted parent or host without
125
+ * getActiveTools). Empty array = hard zero-tool ceiling.
126
+ */
127
+ parentCeiling?: string[];
128
+ /** Parent's restrictive (deny/ask) permission rules, inherited into the child's gate via env. */
129
+ parentRules?: Array<{ permission: string; pattern: string; action: "deny" | "ask" }>;
119
130
  resolvePi?: () => Promise<PiResolution> | PiResolution;
120
131
  runner?: ProcessRunner;
121
132
  fs?: ExecutorFs;
@@ -185,6 +196,26 @@ function emptyUsage(): AgentUsage {
185
196
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0 };
186
197
  }
187
198
 
199
+ /**
200
+ * Compose the child's `--tools` value under the parent ceiling (#8).
201
+ *
202
+ * - No ceiling → child's own whitelist as-is (undefined when unrestricted).
203
+ * - Ceiling + own whitelist → own ∩ ceiling, preserving own's order.
204
+ * - Ceiling only (child unrestricted) → the ceiling itself.
205
+ * - Empty result → "none": the caller must pass `--no-tools` — omitting
206
+ * `--tools` would mean ALL tools and fail open.
207
+ */
208
+ export function composeChildTools(
209
+ own: string[] | undefined,
210
+ ceiling: string[] | undefined,
211
+ ): string[] | "none" | undefined {
212
+ if (ceiling === undefined) return own && own.length > 0 ? own : undefined;
213
+ const base = own ?? ceiling;
214
+ const allowed = new Set(ceiling);
215
+ const result = base.filter((t) => allowed.has(t));
216
+ return result.length > 0 ? result : "none";
217
+ }
218
+
188
219
  export function subagentSessionDir(
189
220
  cwd: string,
190
221
  agentDir = path.join(os.homedir(), ".pi", "agent"),
@@ -196,9 +227,95 @@ export function subagentSessionDir(
196
227
  return path.join(agentDir, "sessions", safeProject, "subagents");
197
228
  }
198
229
 
230
+ /**
231
+ * Detect Bun's in-memory virtual paths (issue #28).
232
+ *
233
+ * Inside a `bun build --compile` single-file binary, `process.argv[1]` (and on
234
+ * some platforms `process.execPath`) resolves to a compile-time virtual entry
235
+ * such as `/$bunfs/root/pi`. That path does not exist on disk — any
236
+ * `realpathSync`/`lstat` on it throws ENOENT. A real on-disk executable path
237
+ * never contains these markers, so the check has no false positives.
238
+ */
239
+ export function isVirtualBunPath(candidate: string | undefined | null): boolean {
240
+ if (!candidate) return false;
241
+ return (
242
+ candidate.includes("$bunfs") || candidate.includes("~BUN") || candidate.includes("%7EBUN")
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Injectable host-process values for {@link resolvePiEntryPoint}.
248
+ *
249
+ * The production default reads `process.execPath` / `process.argv` /
250
+ * `/proc/self/exe` directly. Tests inject exact strings (including the
251
+ * virtual `/$bunfs/...` paths a compiled host reports) without spawning
252
+ * a real compiled binary.
253
+ */
254
+ export interface HostProcessProbe {
255
+ execPath?: string;
256
+ argv0?: string;
257
+ argv1?: string;
258
+ readProcSelfExe?: () => string;
259
+ }
260
+
261
+ /**
262
+ * Pick the real executable for a compiled single-file host (issue #28).
263
+ *
264
+ * Priority order (most reliable first):
265
+ * 1. `/proc/self/exe` (kernel truth on Linux),
266
+ * 2. `process.execPath` (real in every observed compiled binary),
267
+ * 3. `process.argv[0]` — ONLY when absolute. A compiled Bun binary reports
268
+ * the bare word `"bun"` here, which must never become the spawn command
269
+ * (it would resolve via PATH to the Bun runtime instead of pi).
270
+ *
271
+ * Returns `undefined` when every candidate is missing or virtual — the caller
272
+ * turns that into a descriptive error instead of spawning a `$bunfs` path.
273
+ */
274
+ export function resolveCompiledCommand(probe: {
275
+ execPath?: string;
276
+ argv0?: string;
277
+ procSelfExe?: string | undefined;
278
+ }): string | undefined {
279
+ for (const candidate of [probe.procSelfExe, probe.execPath]) {
280
+ if (candidate && !isVirtualBunPath(candidate)) return candidate;
281
+ }
282
+ const argv0 = probe.argv0;
283
+ if (argv0 && path.isAbsolute(argv0) && !isVirtualBunPath(argv0)) return argv0;
284
+ return undefined;
285
+ }
286
+
199
287
  export function resolvePiEntryPoint(
200
288
  resolveSelf: (specifier: string) => string = (specifier) => import.meta.resolve(specifier),
289
+ hostProbe?: HostProcessProbe,
201
290
  ): PiResolution {
291
+ const execPath = hostProbe?.execPath ?? process.execPath;
292
+ const argv0 = hostProbe?.argv0 ?? process.argv[0];
293
+ const argv1 = hostProbe?.argv1 ?? process.argv[1];
294
+
295
+ // Compiled single-file host (`bun build --compile`): the entry "file" is a
296
+ // virtual `$bunfs` path that does not exist on disk. The binary IS the CLI,
297
+ // so resolve the real executable and return an empty entry point — the args
298
+ // builder omits it and the child receives only real CLI flags.
299
+ if (isVirtualBunPath(execPath) || isVirtualBunPath(argv1)) {
300
+ let procSelfExe: string | undefined;
301
+ try {
302
+ procSelfExe = hostProbe?.readProcSelfExe
303
+ ? hostProbe.readProcSelfExe()
304
+ : realpathSync("/proc/self/exe");
305
+ } catch {
306
+ procSelfExe = undefined;
307
+ }
308
+ const command = resolveCompiledCommand({ execPath, argv0, procSelfExe });
309
+ if (!command) {
310
+ throw new Error(
311
+ "pi-open-agents: running inside a compiled single-file pi binary but the real " +
312
+ "executable path could not be determined (only virtual paths available). " +
313
+ "Subagent delegation is unavailable in this environment.",
314
+ );
315
+ }
316
+ return { command, entryPoint: "" };
317
+ }
318
+
202
319
  let packageEntryPoint: string;
203
320
  try {
204
321
  packageEntryPoint = fileURLToPath(resolveSelf("@earendil-works/pi-coding-agent"));
@@ -208,14 +325,21 @@ export function resolvePiEntryPoint(
208
325
  // resolution which cannot see the host package from the plugin's install
209
326
  // tree (peer dependencies are never installed next to it). Fall back to
210
327
  // the running pi entry itself — the child must run the same pi anyway.
328
+ if (!argv1) {
329
+ throw new Error(
330
+ "pi-open-agents: cannot resolve the pi CLI entry point (host package " +
331
+ "self-resolution failed and process.argv[1] is missing). " +
332
+ "Subagent delegation is unavailable in this environment.",
333
+ );
334
+ }
211
335
  return {
212
- command: process.execPath,
213
- entryPoint: realpathSync(process.argv[1]),
336
+ command: execPath,
337
+ entryPoint: realpathSync(argv1),
214
338
  };
215
339
  }
216
340
  const packageRoot = path.dirname(path.dirname(packageEntryPoint));
217
341
  return {
218
- command: process.execPath,
342
+ command: execPath,
219
343
  entryPoint: path.join(packageRoot, "dist", "cli.js"),
220
344
  };
221
345
  }
@@ -511,8 +635,11 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
511
635
  const modelRegistry = buildModelRegistry(options.agentDir);
512
636
 
513
637
  // ── Build CLI args ──
638
+ // A compiled single-file host IS the CLI: its PiResolution carries an
639
+ // empty entryPoint (issue #28) and the child receives only real CLI
640
+ // flags. Non-compiled hosts keep runtime + script + flags, unchanged.
514
641
  const args = [
515
- pi.entryPoint,
642
+ ...(pi.entryPoint ? [pi.entryPoint] : []),
516
643
  "--mode", "json",
517
644
  "-p",
518
645
  "--no-skills",
@@ -533,10 +660,12 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
533
660
  // Thinking level
534
661
  args.push("--thinking", options.agent.thinking);
535
662
 
536
- // Tools whitelist
537
- const toolsWhitelist = deriveToolsWhitelist(options.agent);
538
- if (toolsWhitelist && toolsWhitelist.length > 0) {
539
- args.push("--tools", toolsWhitelist.join(","));
663
+ // Tools whitelist, intersected with the parent's ceiling (#8)
664
+ const childTools = composeChildTools(deriveToolsWhitelist(options.agent), options.parentCeiling);
665
+ if (childTools === "none") {
666
+ args.push("--no-tools");
667
+ } else if (childTools) {
668
+ args.push("--tools", childTools.join(","));
540
669
  }
541
670
 
542
671
  // System prompt file (append or replace)
@@ -564,6 +693,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
564
693
  systemPromptMode: options.agent.systemPrompt,
565
694
  session: options.session?.effective ?? "none",
566
695
  allowedAgents: availableSubagentsForAgent(options.agent, options.availableAgents),
696
+ parentRules: options.parentRules && options.parentRules.length > 0
697
+ ? JSON.stringify(options.parentRules)
698
+ : undefined,
567
699
  debug: false,
568
700
  };
569
701
  const childEnv: NodeJS.ProcessEnv = {
@@ -21,8 +21,10 @@ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
21
21
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
22
22
  import { Type } from "typebox";
23
23
 
24
- import type { AgentDefinition } from "../types.ts";
24
+ import type { AgentDefinition, PermissionRule } from "../types.ts";
25
25
  import { spawnableAgents } from "../discovery/loader.ts";
26
+ import { matchPermissionKey, toolToPermission } from "../permission/evaluator.ts";
27
+ import { parsePermissionRules } from "../permission/parser.ts";
26
28
  import {
27
29
  availableSubagentsForAgent,
28
30
  resolveSubagentSession,
@@ -37,7 +39,12 @@ import {
37
39
  formatResultLines,
38
40
  formatUsage,
39
41
  } from "./render.ts";
40
- import { allowedAgentNames, isPastMaxDepth, type RecursionEnv } from "./env.ts";
42
+ import {
43
+ allowedAgentNames,
44
+ isPastMaxDepth,
45
+ parentInheritedRules,
46
+ type RecursionEnv,
47
+ } from "./env.ts";
41
48
  import { preview, shortenPath, stringArg } from "../utils/format.ts";
42
49
 
43
50
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -78,6 +85,66 @@ function isValidModelSpec(spec: string): boolean {
78
85
  return slash > 0 && slash < spec.length - 1 && !/\s/.test(spec);
79
86
  }
80
87
 
88
+ // ─── Permission Inheritance (#8) ─────────────────────────────────────────────
89
+
90
+ type InheritedRule = { permission: string; pattern: string; action: "deny" | "ask" };
91
+
92
+ function isRestrictive(
93
+ rule: PermissionRule,
94
+ ): rule is PermissionRule & { action: "deny" | "ask" } {
95
+ return rule.action === "deny" || rule.action === "ask";
96
+ }
97
+
98
+ /**
99
+ * Compute the parent's effective tool ceiling at delegation time (#8).
100
+ *
101
+ * Base = pi's live active set (the authority snapshot the child cannot
102
+ * exceed — and, in a child process, what the child's own delegates will
103
+ * see: the ceiling composes transitively down the chain). Tools the
104
+ * parent's own rules deny or ask over are REMOVED from the ceiling: `ask`
105
+ * is not authority — a headless child has no approval bridge, and an
106
+ * ask-only tool must not become a silently usable one down the chain.
107
+ *
108
+ * Returns undefined when the host exposes no active-set API (then no
109
+ * ceiling is applied and behavior matches pre-#8).
110
+ */
111
+ export function computeDelegationCeiling(
112
+ activeTools: string[] | undefined,
113
+ rules: PermissionRule[],
114
+ ): string[] | undefined {
115
+ if (activeTools === undefined) return undefined;
116
+ const blanket = rules.filter(
117
+ (r) => r.pattern === "*" && (r.action === "deny" || r.action === "ask"),
118
+ );
119
+ return activeTools.filter((toolName) => {
120
+ const category = toolToPermission(toolName);
121
+ return !blanket.some((r) => matchPermissionKey(r.permission, category));
122
+ });
123
+ }
124
+
125
+ /**
126
+ * The restrictive rule set to hand to the child's gate (#8): the parent's
127
+ * own deny/ask rules PLUS rules inherited further up the chain (so
128
+ * patterns survive every hop). Deduped; later (own) entries win on overlap
129
+ * via last-match-wins — both are restrictive, so ordering only affects
130
+ * identical-pattern conflicts.
131
+ */
132
+ export function composeInheritedRules(
133
+ own: PermissionRule[],
134
+ fromEnv: InheritedRule[],
135
+ ): InheritedRule[] {
136
+ const restrictiveOwn: InheritedRule[] = own.filter(isRestrictive);
137
+ const seen = new Set<string>();
138
+ const out: InheritedRule[] = [];
139
+ for (const rule of [...fromEnv, ...restrictiveOwn]) {
140
+ const key = `${rule.permission}\u0000${rule.pattern}\u0000${rule.action}`;
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(rule);
144
+ }
145
+ return out;
146
+ }
147
+
81
148
  function toToolResult(result: AgentResult) {
82
149
  return {
83
150
  content: [{ type: "text" as const, text: result.output || "(no output)" }],
@@ -270,6 +337,15 @@ export function registerSubagentTool(
270
337
  options.agentDir,
271
338
  );
272
339
 
340
+ // Permission inheritance (#8): snapshot the parent's live authority,
341
+ // strip blanket deny/ask tools, and carry restrictive patterns (own +
342
+ // inherited) into the child's gate via env.
343
+ const getActive = (pi as { getActiveTools?: () => string[] }).getActiveTools;
344
+ const activeTools = typeof getActive === "function" ? getActive.call(pi) : undefined;
345
+ const ownRules = activeAgent?.permission ? parsePermissionRules(activeAgent.permission) : [];
346
+ const parentCeiling = computeDelegationCeiling(activeTools, ownRules.filter(isRestrictive));
347
+ const parentRules = composeInheritedRules(ownRules, parentInheritedRules(env));
348
+
273
349
  const result = await runner({
274
350
  agent,
275
351
  task: params.task,
@@ -280,6 +356,8 @@ export function registerSubagentTool(
280
356
  agentDir: options.agentDir,
281
357
  session,
282
358
  modelOverride: params.model,
359
+ parentCeiling,
360
+ parentRules: parentRules.length > 0 ? parentRules : undefined,
283
361
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
284
362
  });
285
363