@signalridge/pi-subagents 1.11.0 → 1.11.1

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
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.1
4
+ ### Patch Changes
5
+
6
+ - 3364cc0: Gate project agent definitions, own settings, and custom tool descriptions on the host's fail-closed project trust decision. Reject denied definition provenance before child package resolution, including fallbacks and global aliases into denied roots; refresh configuration authority on session replacement and reload.
7
+ - 3364cc0: Use patched Vitest 4.1.11 for development and regression testing without moving to a new major version. Refresh compatible transitive dependencies to include fixed source-map and brace-expansion releases.
8
+ - 3364cc0: Inherit the parent's public Pi project-trust decision before child resource resolution instead of accepting the SDK's trusted default. Unknown trust and different configuration roots deny project settings, extension factories, skills (including named preloading), and MCP configuration without prompting or granting trust. Explicit project resource paths cannot bypass denial. Preserve global resources, child policy restrictions, and trusted parent configuration during worktree execution and nested delegation; bind built-in MCP configuration discovery to that same trusted configuration root.
9
+ - 3364cc0: Preserve denied parent resource provenance when a child changes its configuration root. Reject explicit parent extensions and filter configured/discovered extensions and skills using both denied roots, including canonical aliases, before extension execution or skill injection. Named skill preloading also rejects denied sources behind global-root ancestor aliases. Genuine global resources and trusted parent configuration during worktree execution remain available; this resource trust boundary is not an OS sandbox or containment of arbitrary global extension behavior.
10
+ - 3364cc0: Fingerprint tracked gate inputs without textconv or external diff helpers, and retain full binary-safe changes so transformed or abbreviated diffs cannot reuse a stale passing verdict.
11
+ - 3364cc0: Declare `@sinclair/typebox` as a host-provided peer dependency with a `*` range instead of a runtime dependency, avoiding duplicate runtime modules and Pi extension-loader warnings.
12
+ - 3364cc0: Reject native global settings and MCP configuration aliases into retained denied project roots before SDK parsing or package resolution. Walk raw path components and symlink targets without collapsing dot segments, so a traversed denied directory cannot be hidden by a later `..` or an outward relay. Preserve genuine global configuration and aliases into approved parent roots without duplicating host configuration schemas.
13
+ - 3364cc0: Capture configuration trust before nested agent discovery and invocation defaults, and preserve denied ancestor roots across nested delegation. Reject global aliases into denied parent or child configuration roots before definition admission, model setup, or resource resolution while retaining authorized global definitions and standalone discovery defaults.
14
+ - 3364cc0: Adopt Pi 1.0.4's host-provided dependency contract: declare imported Pi and TypeBox modules as `*` peers rather than installing duplicate runtime modules, and align Pi development pins with the repository's tested 1.0.4 baseline. The wildcard identifies host module ownership; it does not promise compatibility with every Pi version.
15
+ - 3364cc0: Close configuration-admission gaps: apply model scope only from admitted settings at the captured configuration root, retain ancestor denials, and reject resource aliases that traverse denied project paths before escaping outside. Refresh provider-facing Agent descriptions and configuration-derived parameter descriptions through Pi's public tool-registration API when preparing prompts and continuations, preserving inactive tools. Disable gate-result caching while untracked files exist and prevent external diff commands during fingerprint inspection.
16
+ - 3364cc0: Keep Goal terminal tools directly declared but unavailable to nested execution, where termination is not propagated. Verify direct termination and settlement-gated queue advancement against the installed Pi 1.0.4 host and codemode.
17
+
18
+ Translate Pi exec results into the existing gate executor contract, failing killed commands even when their code is zero and enabling successful git fingerprint inspection. Exercise real-SDK exposure, nested approval, policy, and navigation regressions on Pi 1.0.4 instead of skipping them.
19
+ - Updated dependencies [3364cc0]
20
+ - Updated dependencies [3364cc0]
21
+ - Updated dependencies [3364cc0]
22
+ - Updated dependencies [3364cc0]
23
+ - @signalridge/pi-subagents-protocol@1.5.1
24
+ - @signalridge/pi-ui@1.3.3
25
+
3
26
  ## 1.11.0
4
27
  ### Minor Changes
5
28
 
package/README.md CHANGED
@@ -46,6 +46,8 @@ From the repository root:
46
46
  pi -e ./packages/pi-subagents
47
47
  ```
48
48
 
49
+ **Verified host baseline:** Pi 1.0.4. Wildcard peer dependency ranges and retained capability fallbacks do not establish support for older host versions.
50
+
49
51
  ## Lint policy
50
52
 
51
53
  Formatter checks remain disabled for this package, while legacy `noExplicitAny`, control-character regex, and empty-interface rules remain off. Signalridge additions follow the package AGENTS rules and the strict `--error-on-warnings` lint gate.
@@ -278,6 +280,16 @@ Nested children don't occupy `maxConcurrent` slots — their parent already hold
278
280
 
279
281
  Because a subagent session never activates this extension (that is what keeps a child from building a second agent manager, and it is why nested tools are injected directly instead), a subagent also gets none of the extension's other surfaces: no `/agents` command, no cross-extension RPC handlers, no `subagents:ready` event.
280
282
 
283
+ ### Child project trust
284
+
285
+ Root startup and reload also gate this package's project `subagents.json`, `.pi/agents`, `.agents/agents`, and custom tool description on the public project-trust decision. Unknown or throwing trust checks skip project configuration before parsing; global definitions/settings and embedded defaults remain available. Global aliases into denied roots are not treated as global authority. The runner rejects a denied project-owned definition (including the actual fallback definition) before package resolution, even when it requests an extension outside the project or in a global package cache.
286
+
287
+ On Pi 1.0.4, children capture the parent's public `isProjectTrusted()` decision and configuration root before resolving child resources. Missing or failed trust checks mean denial; children never prompt for or grant project trust. The child passes its derived trust explicitly to `SettingsManager`, governing project settings merging, resource/package discovery, and project MCP configuration. Denied project settings and MCP configuration are not merged. Before the SDK reads native global `settings.json` or `mcp.json`, the child checks their lexical and canonical sources against every retained denied root. A global file or agent-directory alias into a denied root fails child admission, even with extensions disabled; genuine global files and aliases into an approved parent remain usable.
288
+
289
+ Trust belongs to the **configuration root**, not the tool execution directory. Execution in a worktree with `configCwd` pinned to the trusted parent retains that parent's resources (including MCP configuration), also for nested children. A different configuration root, including a newly copied worktree, is not implicitly trusted. Resource checks retain **all denied ancestor roots**, including the captured parent root whose trust was denied and the child's untrusted `configCwd`. Lexical and canonical path checks apply to explicit, configured, and discovered extension paths before their factories execute, and to discovered/preloaded skills before instruction injection. Changing `cwd`/`configCwd`, naming an explicit parent extension, or using a symlink/package alias cannot re-admit the denied source. Named skill preloading also checks global-root ancestor aliases against all retained denied roots.
290
+
291
+ Genuine user/global resources outside denied roots remain available subject to the child's normal policy. This is a resource-source and instruction-loading boundary, **not an OS sandbox**: arbitrary allowed global extension code can perform its own I/O or register MCP servers; project trust does not contain that code's behavior. `isolated`, `extensions: false`, named extension selection, and skill restrictions still narrow access independently. Project context files and appended prompts remain suppressed in child resource loading; append-mode agents receive only the parent's already-built system prompt.
292
+
281
293
  ### Tool & extension scoping
282
294
 
283
295
  `extensions:` decides **which extensions load**, `tools:` decides **which tools surface to the LLM**. They compose:
@@ -310,11 +322,11 @@ A few rules the examples don't make obvious:
310
322
  - Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
311
323
  - Extensions that register tools **lazily** work too. MCP-backed extensions typically can't enumerate their tools until their servers connect, so they register from `session_start` or `before_agent_start` rather than at load. Subagent scoping is re-derived as tools appear, including under `ext:` selectors. Scoping does not override Pi's own exposure: `deferred`/`codemode` tools and `defaultActive: false` tools are not activated for the model, and tools deliberately deactivated during a session stay inactive on later turns. Pi may still allow nested `ctx.executeTool()` calls to deferred/codemode tools, but the child's selector and `ask_tools:` approval gate both model-issued and nested calls.
312
324
  - A configured `defaultToolTimeoutMs` bounds the public `tool_call` policy and each selected extension handler using one deadline per call, including nested calls and handlers registered later with `pi.on`. On expiry the call is blocked; a Promise race returns control but **cannot cancel side effects** of a misbehaving hook that keeps running. Interactive `ask_tools:` confirmation is human decision time, not tool execution time: the deadline starts after approval. The separate execution timeout can still abort a tool whose `execute()` hangs.
313
- - On Pi 0.99+, the SDK's public codemode, tool-search, and MCP factories are included as built-in extensions in children. `extensions: false` omits them; `extensions: [mcp]` selects MCP; `exclude_extensions: mcp` removes it; and `tools: ext:codemode`, `ext:tool-search`, or `ext:mcp/<tool>` narrows their tools like any other extension. Earlier supported Pi hosts use their own available extensions, without importing these newer factories. For restrictive `extensions:` or `exclude_extensions:` policies, the child uses Pi's public package resolver to select discovered paths **before** loading factories. Thus an excluded extension cannot register a conflicting `/mcp` command and displace a selected replaceable built-in before filtering. Resolution can perform configured package installs, as Pi's normal loader does, and a selected extension that fails to load is reported rather than silently dropped.
325
+ - When the SDK exports public codemode, tool-search, and MCP factories (as on the verified Pi 1.0.4 baseline), they are included as built-in extensions in children. `extensions: false` omits them; `extensions: [mcp]` selects MCP; `exclude_extensions: mcp` removes it; and `tools: ext:codemode`, `ext:tool-search`, or `ext:mcp/<tool>` narrows their tools like any other extension. Capability checks omit factories that the host does not export; this fallback is not an older-host support guarantee. For restrictive `extensions:` or `exclude_extensions:` policies, the child uses Pi's public package resolver to select discovered paths **before** loading factories. Thus an excluded extension cannot register a conflicting `/mcp` command and displace a selected replaceable built-in before filtering. Resolution can perform configured package installs, as Pi's normal loader does, and a selected extension that fails to load is reported rather than silently dropped.
314
326
  - An installed **package** extension matches by its package short name (`@scope/pi-subagents` → `[pi-subagents]`), in addition to its path-derived name (a package whose entry is `src/index.ts` also answers to `[src]`). Prefer the package name — the path-derived one is incidental.
315
327
  - Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
316
328
  - `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
317
- - `exclude_extensions:` is **not a sandbox**: on Pi 0.99+ the restrictive prefilter prevents excluded discovered factories from loading into the child, but resolving configured packages can still install code. On earlier hosts, the post-load filter can run an excluded factory before suppressing its tools and bound lifecycle hooks; a factory that subscribes directly to the shared `pi.events` bus may remain live. Don't rely on extension selection to contain untrusted packages.
329
+ - `exclude_extensions:` is **not a sandbox**: when the built-in factory capability enables preselection, the restrictive prefilter prevents excluded discovered factories from loading into the child, but resolving configured packages can still install code. The retained fallback without that capability filters post-load and can run an excluded factory before suppressing its tools and bound lifecycle hooks; a factory that subscribes directly to the shared `pi.events` bus may remain live. Don't rely on extension selection to contain untrusted packages.
318
330
  - Array and string forms are equivalent: `[a, b]` == `"a, b"`.
319
331
 
320
332
  ## Tools
@@ -804,7 +816,7 @@ Workflow-owned orchestration uses the `subagents:rpc:spawn-managed` channel. Its
804
816
  }
805
817
  ```
806
818
 
807
- The manager validates and resolves the tier, agent configuration, queue, tool, session, and worktree policy against its own Agent-tier catalogue and model scope. The resolved tier and its snapshot are retained on the managed invocation/tombstone. `spawnKey` is idempotent within a root manager; the same normalized request returns the existing agent id and a conflicting request is rejected. A named managed `thread` re-enters one sequential session only while its effective model, thinking, toolset, denylist, isolation, and agent policy fingerprint remain unchanged — including the model and thinking its tier currently resolves to, so switching the session model interrupts a thread whose tier inherits it; a policy change or concurrent call is rejected rather than silently reusing the old session. Managed agents use the normal Agent execution path, queue, FleetView, activity, transcript, compaction, and lifecycle events. Only the automatic main-session completion nudge is suppressed for an owner-scoped record. Managed requests must carry an attempt-scoped owner, and `stop-owned`/`quiesce-owned` fail closed when exact node/generation metadata is missing. During branch replacement, timed-out records are detached and late callbacks are suppressed. Pre-tree bus notifications carry the host navigation attempt's signal so workflow consumers can distinguish retries after a veto; older hosts without the signal remain supported. Active or unsettled child work vetoes a tree-navigation attempt rather than being aborted by that attempt. Pi has no post-veto, pre-leaf commit hook, so work started after preflight during asynchronous summarization can be quarantined at commit but cannot be guaranteed to finish.
819
+ The manager validates and resolves the tier, agent configuration, queue, tool, session, and worktree policy against its own Agent-tier catalogue and model scope. The resolved tier and its snapshot are retained on the managed invocation/tombstone. `spawnKey` is idempotent within a root manager; the same normalized request returns the existing agent id and a conflicting request is rejected. A named managed `thread` re-enters one sequential session only while its effective model, thinking, toolset, denylist, isolation, and agent policy fingerprint remain unchanged — including the model and thinking its tier currently resolves to, so switching the session model interrupts a thread whose tier inherits it; a policy change or concurrent call is rejected rather than silently reusing the old session. Managed agents use the normal Agent execution path, queue, FleetView, activity, transcript, compaction, and lifecycle events. Only the automatic main-session completion nudge is suppressed for an owner-scoped record. Managed requests must carry an attempt-scoped owner, and `stop-owned`/`quiesce-owned` fail closed when exact node/generation metadata is missing. During branch replacement, timed-out records are detached and late callbacks are suppressed. Pre-tree bus notifications carry the host navigation attempt's signal so workflow consumers can distinguish retries after a veto; the event payload retains a fallback for an absent signal, without establishing support for older hosts. Active or unsettled child work vetoes a tree-navigation attempt rather than being aborted by that attempt. Pi has no post-veto, pre-leaf commit hook, so work started after preflight during asynchronous summarization can be quarantined at commit but cannot be guaranteed to finish.
808
820
 
809
821
  ### Spawn
810
822
 
@@ -900,7 +912,7 @@ skills: api-conventions, error-handling
900
912
  ---
901
913
  ```
902
914
 
903
- **Discovery roots** (checked in this order, first match wins):
915
+ **Discovery roots** (checked in this order, first match wins). Both project roots are skipped when the child's configuration project is untrusted or trust is unknown; global named skills still preload unless their lexical or canonical source belongs to either the captured denied parent root or the untrusted child configuration root. Skill names are not explicit paths and cannot bypass that decision:
904
916
 
905
917
  | Scope | Path | Source |
906
918
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -25,15 +25,15 @@
25
25
  "autonomous"
26
26
  ],
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-ai": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
29
- "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
30
- "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1"
28
+ "@earendil-works/pi-ai": "*",
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "@earendil-works/pi-tui": "*",
31
+ "@sinclair/typebox": "*"
31
32
  },
32
33
  "dependencies": {
33
- "@sinclair/typebox": "^0.34.50",
34
34
  "croner": "^10.0.1",
35
- "@signalridge/pi-subagents-protocol": "^1.5.0",
36
- "@signalridge/pi-ui": "^1.3.2",
35
+ "@signalridge/pi-subagents-protocol": "^1.5.1",
36
+ "@signalridge/pi-ui": "^1.3.3",
37
37
  "minimatch": "^10.2.6",
38
38
  "nanoid": "^5.1.16"
39
39
  },
@@ -53,7 +53,7 @@
53
53
  "@biomejs/biome": "^2.4.14",
54
54
  "@types/node": "^25.5.0",
55
55
  "typescript": "^6.0.0",
56
- "vitest": "^4.0.18"
56
+ "vitest": "^4.1.11"
57
57
  },
58
58
  "pi": {
59
59
  "extensions": [
@@ -40,6 +40,7 @@ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resol
40
40
  import { parentModelSessionOptions } from "./model-runtime-bridge.js";
41
41
  import { checkModelScope } from "./model-scope.js";
42
42
  import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
43
+ import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
43
44
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
44
45
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
45
46
  import { preloadSkills } from "./skill-loader.js";
@@ -331,8 +332,10 @@ export function installExtensionToolScope(session: AgentSession, inScope: () =>
331
332
  * the agent's whole result down with it.
332
333
  */
333
334
  async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI): Promise<string> {
334
- const exec: GateExec = (file, args, execOptions) =>
335
- pi.exec(file, args, execOptions as Parameters<ExtensionAPI["exec"]>[2]);
335
+ const exec: GateExec = async (file, args, execOptions) => {
336
+ const result = await pi.exec(file, args, execOptions);
337
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: result.killed ? 1 : result.code };
338
+ };
336
339
  try {
337
340
  const fingerprint = await workspaceFingerprint(cwd, exec);
338
341
  const verdict = await runGate({ command, cwd, exec, ...(fingerprint ? { fingerprint } : {}) });
@@ -622,9 +625,9 @@ export interface RunOptions {
622
625
  *
623
626
  * WARNING for future callers: if you pass `cwd` pointing at a directory the
624
627
  * user didn't open, you almost certainly must pass `configCwd` too —
625
- * omitting it makes the target's `.pi` extensions execute in this process.
626
- * (Worktree isolation is the one intentional exception: its copy IS the
627
- * parent's repo, so config resolving inside it is correct.)
628
+ * a different configuration root does not inherit the parent's trust.
629
+ * Worktree execution with configCwd pointing at the parent retains trust;
630
+ * configuration discovered inside a new worktree is not implicitly trusted.
628
631
  */
629
632
  configCwd?: string;
630
633
  /** Called on tool start/end with activity info. */
@@ -898,8 +901,33 @@ export async function runAgent(
898
901
  // an already-cancelled caller. This matters for tool-call signals: the
899
902
  // caller may be cancelled before this async function gets its first turn.
900
903
  throwIfAborted(options.signal);
904
+ const parentProjectTrust = captureProjectTrust(ctx);
905
+ // Configuration provenance is established synchronously, before definition
906
+ // admission, model setup, callbacks, or any await. Execution cwd is not trust.
907
+ const effectiveCwd = options.cwd ?? ctx.cwd;
908
+ const configCwd = options.configCwd ?? effectiveCwd;
909
+ const projectTrust = childProjectTrust(parentProjectTrust, configCwd);
910
+ const projectTrusted = projectTrust.trusted;
911
+ const deniedProjectResource = (path: string): boolean =>
912
+ projectTrust.deniedRoots.some(root => isProjectResource(path, root));
913
+ const agentDir = getAgentDir();
914
+ // Native global configuration is resolved before resource-path filtering.
915
+ // Reject aliases into denied roots before the SDK parses their declarations;
916
+ // do not copy or reinterpret the host's settings/MCP schemas here.
917
+ for (const filename of ["settings.json", "mcp.json"]) {
918
+ if (deniedProjectResource(join(agentDir, filename))) {
919
+ throw new Error(`Project trust denied for global configuration source "${filename}".`);
920
+ }
921
+ }
901
922
 
902
923
  const loadedConfig = getConfig(type);
924
+ // The request's provenance matters, not just the requested extension path:
925
+ // a denied definition can request packages anywhere (including npm caches).
926
+ // getConfig carries the actual fallback definition's provenance as well.
927
+ if ((!parentProjectTrust.trusted && loadedConfig.source === "project") ||
928
+ (loadedConfig.sourcePath && deniedProjectResource(loadedConfig.sourcePath))) {
929
+ throw new Error(`Project trust denied for agent definition "${type}".`);
930
+ }
903
931
  const internalOverride = options[INTERNAL_AGENT_CONFIG_OVERRIDE];
904
932
  const loadedAgentConfig = getAgentConfig(type);
905
933
  const config = internalOverride
@@ -982,12 +1010,6 @@ export async function runAgent(
982
1010
  options.onAgentTierResolved?.(agentTierSnapshot, tierModelLabel);
983
1011
  }
984
1012
 
985
- // Resolve working directory: worktree override > parent cwd
986
- const effectiveCwd = options.cwd ?? ctx.cwd;
987
- // Filesystem work happens in effectiveCwd; config discovery in configCwd.
988
- // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
989
- const configCwd = options.configCwd ?? effectiveCwd;
990
-
991
1013
  const env = await detectEnv(options.pi, effectiveCwd);
992
1014
  throwIfAborted(options.signal);
993
1015
 
@@ -1010,7 +1032,7 @@ export async function runAgent(
1010
1032
 
1011
1033
  // Skill preloading: when skills is string[], preload their content into prompt
1012
1034
  if (Array.isArray(skills)) {
1013
- const loaded = preloadSkills(skills, configCwd);
1035
+ const loaded = preloadSkills(skills, configCwd, { projectTrusted, deniedResource: deniedProjectResource });
1014
1036
  if (loaded.length > 0) {
1015
1037
  extras.skillBlocks = loaded;
1016
1038
  }
@@ -1060,7 +1082,7 @@ export async function runAgent(
1060
1082
  // skills later, so the loader override must enforce this policy on every update.
1061
1083
  const noSkills = skills === false || Array.isArray(skills);
1062
1084
 
1063
- const agentDir = getAgentDir();
1085
+ const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1064
1086
 
1065
1087
  // Extension loading:
1066
1088
  // - true → all default-discovered extensions
@@ -1089,11 +1111,11 @@ export async function runAgent(
1089
1111
  : undefined;
1090
1112
  const keepNames = extensionsSpec?.names ?? new Set<string>();
1091
1113
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
1092
- // Plain canonical names only (case-insensitive). On older hosts filtering is
1093
- // post-factory; this is not a sandbox for extensions that already loaded.
1114
+ // Plain canonical names only (case-insensitive). Without preselection,
1115
+ // filtering is post-factory, not a sandbox for extensions that already loaded.
1094
1116
  const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
1095
1117
  const hasExcludes = excludeNames.size > 0;
1096
- // The override filters on older hosts, validates preselection on new hosts,
1118
+ // The override filters without preselection, validates preselection when used,
1097
1119
  // and ALWAYS puts our tool policy first. Pi passes one mutable
1098
1120
  // tool_call event through handlers in extension order; if an earlier extension
1099
1121
  // changes event.toolName, a later gate would inspect a different name from the
@@ -1177,9 +1199,9 @@ export async function runAgent(
1177
1199
  });
1178
1200
  },
1179
1201
  }];
1180
- // Pi 0.99 exposes these as public SDK factories. Earlier supported hosts do
1181
- // not export them, so capability-check the namespace instead of importing a
1182
- // missing named export (which would prevent the whole extension from loading).
1202
+ // Public SDK factories are verified on Pi 1.0.4. Capability-check the
1203
+ // namespace rather than importing a potentially missing named export; this
1204
+ // retained fallback does not establish support for older host versions.
1183
1205
  if ("createCodemodeExtension" in PiCodingAgent && typeof PiCodingAgent.createCodemodeExtension === "function") {
1184
1206
  extensionFactories.push({ name: "codemode", builtin: true, replaceable: true, factory: PiCodingAgent.createCodemodeExtension() });
1185
1207
  }
@@ -1187,21 +1209,26 @@ export async function runAgent(
1187
1209
  extensionFactories.push({ name: "tool-search", builtin: true, replaceable: true, factory: PiCodingAgent.createToolSearchExtension() });
1188
1210
  }
1189
1211
  if ("createMcpExtension" in PiCodingAgent && typeof PiCodingAgent.createMcpExtension === "function") {
1190
- extensionFactories.push({ name: "mcp", builtin: true, replaceable: true, factory: PiCodingAgent.createMcpExtension() });
1212
+ extensionFactories.push({ name: "mcp", builtin: true, replaceable: true,
1213
+ factory: configurationMcpFactory(PiCodingAgent.createMcpExtension(), configCwd, settingsManager) });
1191
1214
  }
1192
1215
 
1193
- const settingsManager = SettingsManager.create(configCwd, agentDir);
1216
+ for (const path of extensionsSpec?.paths ?? []) {
1217
+ if (deniedProjectResource(path)) {
1218
+ throw new Error(`Project extension source "${path}" requested by agent "${type}" is not trusted.`);
1219
+ }
1220
+ }
1194
1221
  let additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
1195
1222
  let settingsBeforeLoad: string | undefined;
1196
1223
  // Pi 0.99 resolves replacement conflicts BEFORE extensionsOverride. A rogue
1197
1224
  // discovered extension can register /mcp, replace builtin:mcp, and only then
1198
1225
  // get filtered out by our allowlist. Resolve configured resources through the
1199
1226
  // public package API first and feed only the selected paths into a loader with
1200
- // discovery off. Older Pi hosts have no replaceable built-ins; keep their
1201
- // existing post-load filter instead of relying on new host behavior.
1227
+ // discovery off. Without the built-in factory capability, retain the
1228
+ // post-load filtering fallback rather than assuming replacement behavior.
1202
1229
  const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
1203
1230
  .map((factory) => factory.name);
1204
- const prefilter = !noExtensions && (!loadAll || hasExcludes) && builtinNames.length > 0;
1231
+ const prefilter = !noExtensions && (!loadAll || hasExcludes || !projectTrusted) && builtinNames.length > 0;
1205
1232
  if (prefilter) {
1206
1233
  await settingsManager.reload();
1207
1234
  throwIfAborted(options.signal);
@@ -1219,8 +1246,13 @@ export async function runAgent(
1219
1246
  }
1220
1247
  }
1221
1248
  settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
1222
- const enabledConfigured = configured.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
1249
+ const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path)).map((resource) => resource.path);
1223
1250
  const enabledExplicit = explicit.extensions.filter((resource) => resource.enabled);
1251
+ for (const resource of enabledExplicit) {
1252
+ if (deniedProjectResource(resource.path)) {
1253
+ throw new Error(`Project extension "${resource.path}" requested by agent "${type}" is not trusted.`);
1254
+ }
1255
+ }
1224
1256
  discoveredNames = new Set([...enabledConfigured, ...enabledExplicit.map((resource) => resource.path)].flatMap(extensionCanonicalNames));
1225
1257
  const selectedExplicit = enabledExplicit.filter((resource) =>
1226
1258
  !extensionCanonicalNames(resource.path).some((name) => excludeNames.has(name)));
@@ -1258,7 +1290,11 @@ export async function runAgent(
1258
1290
  extensionsOverride,
1259
1291
  extensionFactories,
1260
1292
  noSkills,
1261
- skillsOverride: noSkills ? () => ({ skills: [], diagnostics: [] }) : undefined,
1293
+ skillsOverride: noSkills
1294
+ ? () => ({ skills: [], diagnostics: [] })
1295
+ : !projectTrusted
1296
+ ? (base) => ({ ...base, skills: base.skills.filter((skill) => !deniedProjectResource(skill.filePath)) })
1297
+ : undefined,
1262
1298
  noPromptTemplates: true,
1263
1299
  noThemes: true,
1264
1300
  noContextFiles: true,
@@ -1305,7 +1341,7 @@ export async function runAgent(
1305
1341
  // already raised above instead of silently losing a requested capability.
1306
1342
  // Two remaining misconfigurations to catch:
1307
1343
  // - `extensions: [foo]` but no extension named foo was discovered (typo;
1308
- // on older hosts path entries also fold their canonical name into
1344
+ // without preselection path entries also fold their canonical name into
1309
1345
  // `keepNames`, so a failed path load is warned here).
1310
1346
  // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
1311
1347
  // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
@@ -1372,7 +1408,8 @@ export async function runAgent(
1372
1408
  const { configuredModel, source } = agentTierSnapshot;
1373
1409
  const scopeVerdict = checkModelScope({
1374
1410
  model,
1375
- cwd: ctx.cwd,
1411
+ cwd: projectTrust.cwd,
1412
+ authority: projectTrust,
1376
1413
  modelRegistry: ctx.modelRegistry,
1377
1414
  // scopeModels polices a tier chosen per dispatch, by the host model or by
1378
1415
  // a workflow script alike; a tier from the agent file or the configured
@@ -1411,6 +1448,7 @@ export async function runAgent(
1411
1448
  maxSubagentDepth: effectiveMaxDepth,
1412
1449
  allowedSubagents: agentConfig.allowedSubagents,
1413
1450
  configCwd,
1451
+ projectTrust,
1414
1452
  })
1415
1453
  : [];
1416
1454
  // `contact_supervisor` is injected separately from the nested tools and under
@@ -302,11 +302,15 @@ export function getConfig(type: string): {
302
302
  excludeExtensions?: string[];
303
303
  skills: true | string[] | false;
304
304
  promptMode: "replace" | "append";
305
+ source?: AgentConfig["source"];
306
+ sourcePath?: string;
305
307
  } {
306
308
  const key = resolveKey(type);
307
309
  const config = key ? agents.get(key) : undefined;
308
310
  if (config && config.enabled !== false) {
309
311
  return {
312
+ source: config.source,
313
+ sourcePath: config.sourcePath,
310
314
  displayName: config.displayName ?? config.name,
311
315
  color: config.color,
312
316
  description: config.description,
@@ -322,6 +326,8 @@ export function getConfig(type: string): {
322
326
  const gp = agents.get("general-purpose");
323
327
  if (gp && gp.enabled !== false) {
324
328
  return {
329
+ source: gp.source,
330
+ sourcePath: gp.sourcePath,
325
331
  displayName: gp.displayName ?? gp.name,
326
332
  color: gp.color,
327
333
  description: gp.description,
@@ -8,6 +8,7 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { isValidAgentTierKey } from "./agent-tiers.js";
9
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
10
  import { DEFAULT_AGENTS } from "./default-agents.js";
11
+ import { isProjectResource } from "./project-trust.js";
11
12
  import type { AgentConfig, MemoryScope } from "./types.js";
12
13
  import { sanitizeDisplayText } from "./ui/safe-text.js";
13
14
 
@@ -129,7 +130,8 @@ function createWarningSink(root: string): WarningSink {
129
130
  };
130
131
  }
131
132
 
132
- export function loadCustomAgents(cwd: string, strict = false): Map<string, AgentConfig> {
133
+ /** Standalone callers retain discovery defaults; runtime callers must pass captured trust. */
134
+ export function loadCustomAgents(cwd: string, strict = false, projectTrusted = true, deniedRoots: readonly string[] = []): Map<string, AgentConfig> {
133
135
  const discoveryRoot = normalizeDiscoveryRoot(cwd);
134
136
  const warningRoot = warningIdentity(cwd);
135
137
  const globalDir = join(getAgentDir(), "agents");
@@ -140,9 +142,12 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
140
142
  const agents = new Map<string, AgentConfig>();
141
143
  const priorities = new Map<string, number>();
142
144
  const skipped: SkippedAgent[] = [];
143
- loadFromDir(globalDir, agents, "global", strict, warn, 0, priorities, skipped); // lowest priority
144
- loadFromDir(workspaceProjectDir, agents, "project", strict, warn, 1, priorities, skipped); // shared workspace
145
- loadFromDir(projectDir, agents, "project", strict, warn, 2, priorities, skipped); // highest priority (overwrites)
145
+ const denied = projectTrusted ? deniedRoots : [...deniedRoots, discoveryRoot];
146
+ loadFromDir(globalDir, agents, "global", strict, warn, 0, priorities, skipped, denied);
147
+ if (projectTrusted) {
148
+ loadFromDir(workspaceProjectDir, agents, "project", strict, warn, 1, priorities, skipped, denied);
149
+ loadFromDir(projectDir, agents, "project", strict, warn, 2, priorities, skipped, denied);
150
+ }
146
151
 
147
152
  // Only report a fallback when the skipped file was higher priority than the
148
153
  // definition that survived the complete discovery pass. A valid higher
@@ -164,7 +169,9 @@ function loadFromDir(
164
169
  priority: number,
165
170
  priorities: Map<string, number>,
166
171
  skipped: SkippedAgent[],
172
+ deniedRoots: readonly string[],
167
173
  ): void {
174
+ if (deniedRoots.some(root => isProjectResource(dir, root))) return;
168
175
  if (!existsSync(dir)) return;
169
176
 
170
177
  let files: string[];
@@ -184,6 +191,7 @@ function loadFromDir(
184
191
  const filenameType = basename(file, ".md");
185
192
 
186
193
  const path = join(dir, file);
194
+ if (deniedRoots.some(root => isProjectResource(path, root))) continue;
187
195
 
188
196
  const parsed = readAgentFile(path, strict, warn);
189
197
  if (!parsed) {
@@ -3,7 +3,7 @@
3
3
  * + project-local `<cwd>/.pi/settings.json`, project wins) and resolves
4
4
  * entries to concrete `provider/modelId` keys for scope validation.
5
5
  *
6
- * **Project overrides global**, mirroring pi's own `SettingsManager`
6
+ * **Admitted project settings override global**, mirroring pi's own `SettingsManager`
7
7
  * deep-merge behavior and matching the precedence we use for our own
8
8
  * `subagents.json` settings (see `src/settings.ts:loadSettings`). If
9
9
  * project file has `enabledModels` set, it wholly replaces global's
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
26
  import { minimatch } from "minimatch";
27
27
  import type { ModelEntry } from "./model-resolver.js";
28
+ import { isProjectResource, type ProjectTrust } from "./project-trust.js";
28
29
 
29
30
  /** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
30
31
  export interface ModelRegistryRef {
@@ -61,11 +62,17 @@ function readField(path: string): string[] | undefined {
61
62
  * Read enabledModels from pi's settings — project-local overrides global.
62
63
  * Mirrors pi's SettingsManager deep-merge for the `enabledModels` field
63
64
  * (and matches our own loadSettings precedence in src/settings.ts).
64
- * Returns undefined when neither file has the field.
65
+ * Runtime callers pass captured configuration authority to exclude denied
66
+ * project sources and global aliases; standalone callers keep project defaults.
67
+ * Returns undefined when neither admitted file has the field.
65
68
  */
66
- export function readEnabledModels(cwd: string): string[] | undefined {
67
- const [project, global] = settingsPaths(cwd);
68
- return readField(project) ?? readField(global);
69
+ export function readEnabledModels(cwd: string, authority?: ProjectTrust): string[] | undefined {
70
+ const [project, global] = settingsPaths(authority?.cwd ?? cwd);
71
+ // Standalone readers retain project precedence. Runtime readers must pass
72
+ // captured configuration authority, never infer it from execution cwd.
73
+ const denied = (path: string): boolean => authority?.deniedRoots.some(root => isProjectResource(path, root)) ?? false;
74
+ return (authority?.trusted !== false && !denied(project) ? readField(project) : undefined)
75
+ ?? (!denied(global) ? readField(global) : undefined);
69
76
  }
70
77
 
71
78
  /**
package/src/gate.ts CHANGED
Binary file
package/src/index.ts CHANGED
@@ -165,6 +165,7 @@ import {
165
165
  streamToOutputFile,
166
166
  writeInitialEntry,
167
167
  } from "./output-file.js";
168
+ import { captureProjectTrust, isProjectResource } from "./project-trust.js";
168
169
  import { SubagentScheduler } from "./schedule.js";
169
170
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
170
171
  import {
@@ -582,16 +583,18 @@ export default function (pi: ExtensionAPI): void {
582
583
  if (inert) return;
583
584
  if (!rootRuntime) {
584
585
  try {
585
- const startupSettings = loadSettings(ctx.cwd);
586
+ const trust = captureProjectTrust(ctx);
587
+ const startupSettings = loadSettings(trust.cwd, trust.trusted);
586
588
  const startupAgents = loadCustomAgents(
587
- ctx.cwd,
589
+ trust.cwd,
588
590
  startupSettings.strictAgentFiles === true,
591
+ trust.trusted,
589
592
  );
590
593
  rootRuntime = activateRootRuntime(
591
594
  pi,
592
595
  startupSettings,
593
596
  startupAgents,
594
- ctx.cwd,
597
+ ctx,
595
598
  unsubscribeRootContext,
596
599
  );
597
600
  } catch (error: unknown) {
@@ -611,10 +614,12 @@ function activateRootRuntime(
611
614
  pi: ExtensionAPI,
612
615
  startupSettings: SubagentsSettings,
613
616
  startupAgents: Map<string, AgentConfig>,
614
- initialCwd: string,
617
+ initialContext: ExtensionContext,
615
618
  unsubscribeRootContext: () => void,
616
619
  ): RootRuntime {
617
- let sessionCwd = initialCwd;
620
+ let configurationOwner = initialContext;
621
+ let activeTrust = captureProjectTrust(configurationOwner);
622
+ let sessionCwd = activeTrust.cwd;
618
623
  let strictAgentFiles = startupSettings.strictAgentFiles === true;
619
624
 
620
625
  // ---- Register custom notification renderer ----
@@ -668,7 +673,12 @@ function activateRootRuntime(
668
673
 
669
674
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
670
675
  const reloadCustomAgents = (strict = false) => {
671
- const userAgents = loadCustomAgents(sessionCwd, strict);
676
+ const trust = captureProjectTrust(configurationOwner);
677
+ const changed = trust.cwd !== activeTrust.cwd || trust.trusted !== activeTrust.trusted;
678
+ activeTrust = trust;
679
+ sessionCwd = trust.cwd;
680
+ if (changed) applyRuntimeSettings(loadSettings(trust.cwd, trust.trusted));
681
+ const userAgents = loadCustomAgents(trust.cwd, strict, trust.trusted);
672
682
  registerAgents(userAgents);
673
683
  };
674
684
 
@@ -1255,7 +1265,15 @@ function activateRootRuntime(
1255
1265
  manager.resetManagedSpawns();
1256
1266
  }
1257
1267
  sessionGeneration++;
1258
- sessionCwd = ctx.cwd;
1268
+ const replacingConfiguration = currentCtx !== undefined;
1269
+ configurationOwner = ctx;
1270
+ const trust = captureProjectTrust(ctx);
1271
+ sessionCwd = trust.cwd;
1272
+ if (replacingConfiguration) {
1273
+ activeTrust = trust;
1274
+ applyRuntimeSettings(loadSettings(trust.cwd, trust.trusted));
1275
+ }
1276
+ reloadCustomAgents();
1259
1277
  boundSessionId = ctx.sessionManager?.getSessionId?.();
1260
1278
  currentCtx = ctx;
1261
1279
  await claimRpcRegistry(ctx, event.reason === "reload");
@@ -1386,6 +1404,8 @@ function activateRootRuntime(
1386
1404
  // even when subagents' session_tree handler runs first.
1387
1405
  pi.events.emit("subagents:session_tree_committed", { event });
1388
1406
  currentCtx = ctx;
1407
+ configurationOwner = ctx;
1408
+ reloadCustomAgents();
1389
1409
  scheduler.stop();
1390
1410
  manager.clearCompleted(true);
1391
1411
  manager.resetManagedSpawns();
@@ -1860,8 +1880,8 @@ function activateRootRuntime(
1860
1880
 
1861
1881
  // ---- Agent tool description mode ----
1862
1882
  // "full" (default) keeps the rich Claude Code-style description; "compact"
1863
- // swaps in a ~75% smaller one for small/local models (#91). Read once at
1864
- // tool registration — flipping it applies on the next pi session.
1883
+ // swaps in a ~75% smaller one for small/local models (#91). The description
1884
+ // getter rechecks configuration authority so replacements cannot retain denied text.
1865
1885
  let toolDescriptionMode: ToolDescriptionMode = "full";
1866
1886
  function getToolDescriptionMode(): ToolDescriptionMode {
1867
1887
  return toolDescriptionMode;
@@ -1961,40 +1981,47 @@ function activateRootRuntime(
1961
1981
 
1962
1982
  // Apply the already-validated session settings and emit the same lifecycle
1963
1983
  // event that the normal startup path exposes. Do not re-read process.cwd().
1964
- applySettings(startupSettings, {
1965
- setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1966
- setDefaultMaxTurns,
1967
- setGraceTurns,
1968
- setDefaultMaxTokens,
1969
- setDefaultMaxToolCalls,
1970
- setDefaultToolTimeout: (ms) => setDefaultToolTimeoutMs(ms),
1971
- setDefaultJoinMode,
1972
- setDefaultModel,
1973
- setSchedulingEnabled,
1974
- setScopeModels: setScopeModelsEnabled,
1975
- setStrictAgentFiles: (enabled) => {
1976
- strictAgentFiles = enabled;
1977
- },
1978
- setDisableDefaultAgents,
1979
- setToolDescriptionMode,
1980
- setFleetView: setFleetViewEnabled,
1981
- setOutputTranscript: setOutputTranscriptDefault,
1982
- setRememberAgents: (enabled) => manager.setRememberAgents(enabled),
1983
- setAgentMentions: (enabled) => {
1984
- agentMentionMode = enabled;
1985
- },
1986
- setSupervisorQuestions: (enabled) => {
1987
- supervisorQuestionsEnabled = enabled;
1988
- manager.setSupervisorQuestions(enabled);
1989
- },
1990
- setMaxSubagentDepth,
1991
- setMaxSubagentSpawnsPerBranch: (n) =>
1992
- manager.setMaxSubagentSpawnsPerBranch(n),
1993
- setFallbackSubagent,
1994
- setWorktreeIsolation: setWorktreeIsolationEnabled,
1995
- setAgentTiers: setAgentTiersSettings,
1996
- });
1997
- pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1984
+ const baselineSettings: SubagentsSettings = { ...snapshotSettings(), strictAgentFiles: false, agentTiers: {} };
1985
+ function applyRuntimeSettings(settings: SubagentsSettings): void {
1986
+ // Clear optional policies as well: absence must not retain the old project's values.
1987
+ setDefaultModel(settings.defaultModel ?? baselineSettings.defaultModel);
1988
+ setFallbackSubagent(settings.fallbackSubagent ?? baselineSettings.fallbackSubagent);
1989
+ applySettings({ ...baselineSettings, ...settings }, {
1990
+ setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1991
+ setDefaultMaxTurns,
1992
+ setGraceTurns,
1993
+ setDefaultMaxTokens,
1994
+ setDefaultMaxToolCalls,
1995
+ setDefaultToolTimeout: (ms) => setDefaultToolTimeoutMs(ms),
1996
+ setDefaultJoinMode,
1997
+ setDefaultModel,
1998
+ setSchedulingEnabled,
1999
+ setScopeModels: setScopeModelsEnabled,
2000
+ setStrictAgentFiles: (enabled) => {
2001
+ strictAgentFiles = enabled;
2002
+ },
2003
+ setDisableDefaultAgents,
2004
+ setToolDescriptionMode,
2005
+ setFleetView: setFleetViewEnabled,
2006
+ setOutputTranscript: setOutputTranscriptDefault,
2007
+ setRememberAgents: (enabled) => manager.setRememberAgents(enabled),
2008
+ setAgentMentions: (enabled) => {
2009
+ agentMentionMode = enabled;
2010
+ },
2011
+ setSupervisorQuestions: (enabled) => {
2012
+ supervisorQuestionsEnabled = enabled;
2013
+ manager.setSupervisorQuestions(enabled);
2014
+ },
2015
+ setMaxSubagentDepth,
2016
+ setMaxSubagentSpawnsPerBranch: (n) =>
2017
+ manager.setMaxSubagentSpawnsPerBranch(n),
2018
+ setFallbackSubagent,
2019
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
2020
+ setAgentTiers: setAgentTiersSettings,
2021
+ });
2022
+ pi.events.emit("subagents:settings_loaded", { settings });
2023
+ }
2024
+ applyRuntimeSettings(startupSettings);
1998
2025
 
1999
2026
  // A tier that nothing defines is a typo, and the resolver would only reach it
2000
2027
  // on the first spawn that needs it — possibly minutes in, mid-task. Report it
@@ -2201,7 +2228,7 @@ function activateRootRuntime(
2201
2228
  // In `model` mode an off-screen mention-only turn rewrites the typed task
2202
2229
  // without copying parent history or its request-local context hooks.
2203
2230
  // Nothing reaches the chat, and it starts an ordinary top-level agent.
2204
- const registeredAgentTool = agentToolRef;
2231
+ const registeredAgentTool = agentToolRef ? freshAgentToolDefinition() : undefined;
2205
2232
  if (getAgentMentionMode() === "model" && registeredAgentTool) {
2206
2233
  // The hidden turn is itself a provider call. Refuse before saying
2207
2234
  // "Starting" or spending that call when its inherited parent model is
@@ -2294,15 +2321,13 @@ function activateRootRuntime(
2294
2321
  // The catalogue is injected into the description rather than left to a lookup
2295
2322
  // tool: the host has to pick a tier on its first call, and a tool it must
2296
2323
  // remember to call first is a tool it will skip.
2297
- const tierListText = buildAgentTierListText();
2298
- const compactTierListText = buildCompactAgentTierListText();
2299
- const tierSection = tierListText ? `\n\n${tierListText}` : "";
2300
- const compactTierSection = compactTierListText
2301
- ? `\n\n${compactTierListText}`
2302
- : "";
2324
+ const tierSection = (compact = false): string => {
2325
+ const text = compact ? buildCompactAgentTierListText() : buildAgentTierListText();
2326
+ return text ? `\n\n${text}` : "";
2327
+ };
2303
2328
 
2304
- const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
2305
- ${buildCompactTypeListText()}${compactTierSection}
2329
+ const compactAgentToolDescription = () => `Launch an autonomous agent for complex, multi-step tasks. Agent types:
2330
+ ${buildCompactTypeListText()}${tierSection(true)}
2306
2331
 
2307
2332
  Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
2308
2333
 
@@ -2314,12 +2339,12 @@ Notes:
2314
2339
  - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.
2315
2340
  - Raw inherit_context is unavailable; put only an explicitly sanitized summary in the task prompt.`;
2316
2341
 
2317
- const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
2342
+ const fullAgentToolDescription = () => `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
2318
2343
 
2319
2344
  Available agent types and the tools they have access to:
2320
2345
  ${buildTypeListText()}
2321
2346
 
2322
- Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection}
2347
+ Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection()}
2323
2348
 
2324
2349
  When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
2325
2350
 
@@ -2367,8 +2392,8 @@ Terse command-style prompts produce shallow, generic work.
2367
2392
  // Both carry their own leading blank line, so a template that drops the
2368
2393
  // placeholder inline renders byte-identically to the built-in description
2369
2394
  // whether or not any tier is configured.
2370
- tierList: () => tierSection,
2371
- compactTierList: () => compactTierSection,
2395
+ tierList: () => tierSection(),
2396
+ compactTierList: () => tierSection(true),
2372
2397
  defaultTier: getDefaultAgentTierText,
2373
2398
  agentDir: getAgentDir,
2374
2399
  scheduleGuideline: () => scheduleGuideline,
@@ -2384,10 +2409,12 @@ Terse command-style prompts produce shallow, generic work.
2384
2409
  };
2385
2410
 
2386
2411
  const loadCustomToolDescription = (): string | undefined => {
2412
+ const trust = captureProjectTrust(configurationOwner);
2387
2413
  for (const path of [
2388
- join(sessionCwd, ".pi", "agent-tool-description.md"),
2414
+ ...(trust.trusted ? [join(trust.cwd, ".pi", "agent-tool-description.md")] : []),
2389
2415
  join(getAgentDir(), "agent-tool-description.md"),
2390
2416
  ]) {
2417
+ if (!trust.trusted && isProjectResource(path, trust.cwd)) continue;
2391
2418
  try {
2392
2419
  if (!existsSync(path)) continue;
2393
2420
  const text = readFileSync(path, "utf-8").trim();
@@ -2402,9 +2429,11 @@ Terse command-style prompts produce shallow, generic work.
2402
2429
  return undefined;
2403
2430
  };
2404
2431
 
2405
- const agentToolDescription = (() => {
2432
+ const agentToolDescription = () => {
2433
+ const trust = captureProjectTrust(configurationOwner);
2434
+ if (trust.cwd !== activeTrust.cwd || trust.trusted !== activeTrust.trusted) reloadCustomAgents();
2406
2435
  const mode = getToolDescriptionMode();
2407
- if (mode === "compact") return compactAgentToolDescription;
2436
+ if (mode === "compact") return compactAgentToolDescription();
2408
2437
  if (mode === "custom") {
2409
2438
  const custom = loadCustomToolDescription();
2410
2439
  if (custom) return custom;
@@ -2412,16 +2441,18 @@ Terse command-style prompts produce shallow, generic work.
2412
2441
  '[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"',
2413
2442
  );
2414
2443
  }
2415
- return fullAgentToolDescription;
2416
- })();
2444
+ return fullAgentToolDescription();
2445
+ };
2417
2446
 
2418
2447
  // Captured so the mention clone can hand the copy the REAL registered tool:
2419
2448
  // its handler closes over this activation, which is what makes a clone-driven
2420
2449
  // spawn an ordinary top-level agent rather than something the fork owns.
2450
+ const agentTypeParameterDescription = () =>
2451
+ `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`;
2421
2452
  const agentTool = defineTool({
2422
2453
  name: SUBAGENT_TOOL_NAMES.AGENT,
2423
2454
  label: "Agent",
2424
- description: agentToolDescription,
2455
+ get description() { return agentToolDescription(); },
2425
2456
  promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
2426
2457
  promptGuidelines: [
2427
2458
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
@@ -2438,7 +2469,7 @@ Terse command-style prompts produce shallow, generic work.
2438
2469
  "A short (3-5 word) description of the task (shown in UI).",
2439
2470
  }),
2440
2471
  subagent_type: Type.String({
2441
- description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
2472
+ description: agentTypeParameterDescription(),
2442
2473
  }),
2443
2474
  tier: Type.Optional(
2444
2475
  Type.String({
@@ -2720,7 +2751,8 @@ Terse command-style prompts produce shallow, generic work.
2720
2751
  if (!resolvedConfig.agentTierSelected) {
2721
2752
  const scopeVerdict = checkModelScope({
2722
2753
  model,
2723
- cwd: ctx.cwd,
2754
+ cwd: activeTrust.cwd,
2755
+ authority: activeTrust,
2724
2756
  modelRegistry: ctx.modelRegistry,
2725
2757
  callerSupplied: resolvedConfig.modelFromParams,
2726
2758
  agentLabel: customConfig?.displayName ?? subagentType,
@@ -3217,6 +3249,42 @@ Terse command-style prompts produce shallow, generic work.
3217
3249
  pi.registerTool(agentTool);
3218
3250
  agentToolRef = agentTool;
3219
3251
 
3252
+ // Pi snapshots description and parameters when wrapping a registered tool.
3253
+ // Trust changes alone emit no host event. Re-register before each new prompt,
3254
+ // ahead of Pi's effective loadout snapshot, using its public post-bind refresh.
3255
+ // Turn end also covers provider continuations after tools revoke authority.
3256
+ // Keep the same exposure/execution policy and retain the active set.
3257
+ function freshAgentToolDefinition() {
3258
+ reloadCustomAgents();
3259
+ return {
3260
+ ...agentTool,
3261
+ description: agentToolDescription(),
3262
+ parameters: {
3263
+ ...agentTool.parameters,
3264
+ properties: {
3265
+ ...agentTool.parameters.properties,
3266
+ subagent_type: { ...agentTool.parameters.properties.subagent_type, description: agentTypeParameterDescription() },
3267
+ tier: { ...agentTool.parameters.properties.tier, description: buildAgentTierParameterDescription() },
3268
+ },
3269
+ },
3270
+ };
3271
+ }
3272
+ const refreshAgentDeclaration = () => {
3273
+ const refreshed = freshAgentToolDefinition();
3274
+ const activeTools = pi.getActiveTools();
3275
+ pi.registerTool(refreshed);
3276
+ // Pi's explicit --tools/SDK tools selector can re-activate every matching
3277
+ // name during registry refresh. A metadata refresh must not undo a later
3278
+ // activation/exposure decision by the host or another extension.
3279
+ const refreshedActive = pi.getActiveTools();
3280
+ if (activeTools.length !== refreshedActive.length || activeTools.some((name, i) => name !== refreshedActive[i])) {
3281
+ pi.setActiveTools(activeTools);
3282
+ }
3283
+ agentToolRef = refreshed;
3284
+ };
3285
+ pi.on("before_agent_start", refreshAgentDeclaration);
3286
+ pi.on("turn_end", refreshAgentDeclaration);
3287
+
3220
3288
  // ---- get_subagent_result tool ----
3221
3289
 
3222
3290
  pi.registerTool(
@@ -4834,7 +4902,7 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4834
4902
  // scoped set would fail the scope check at spawn time.
4835
4903
  if (isScopeModelsEnabled()) {
4836
4904
  const enabled = resolveEnabledModels(
4837
- readEnabledModels(sessionCwd),
4905
+ readEnabledModels(sessionCwd, captureProjectTrust(configurationOwner)),
4838
4906
  ctx.modelRegistry,
4839
4907
  sessionCwd,
4840
4908
  );
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
11
+ import type { ProjectTrust } from "./project-trust.js";
11
12
 
12
13
  /**
13
14
  * When enabled, subagent model choices are validated against `enabledModels`
@@ -41,6 +42,8 @@ export type ModelScopeVerdict =
41
42
  export function checkModelScope(args: {
42
43
  model: { provider: string; id: string } | undefined;
43
44
  cwd: string;
45
+ /** Captured config root/trust and retained ancestor denials; omit only for standalone helpers. */
46
+ authority?: ProjectTrust;
44
47
  modelRegistry: ModelRegistryRef;
45
48
  /** True when the model came from the tool call rather than frontmatter. */
46
49
  callerSupplied: boolean;
@@ -52,7 +55,7 @@ export function checkModelScope(args: {
52
55
  const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
53
56
  if (!scopeModelsEnabled || !model) return { kind: "ok" };
54
57
 
55
- const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
58
+ const allowed = resolveEnabledModels(readEnabledModels(cwd, args.authority), modelRegistry, cwd);
56
59
  if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
57
60
 
58
61
  // A tier whose profile says `inherit` names no model of its own, so fall back
@@ -27,6 +27,7 @@ import {
27
27
  streamToOutputFile,
28
28
  writeInitialEntry,
29
29
  } from "./output-file.js";
30
+ import { configurationContext, type ProjectTrust } from "./project-trust.js";
30
31
  import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
31
32
  import type {
32
33
  AgentConfig,
@@ -104,6 +105,8 @@ export interface NestedToolContext {
104
105
  allowedSubagents: "all" | string[];
105
106
  /** Root used for agent/config discovery; may differ from the agent's working directory. */
106
107
  configCwd: string;
108
+ /** Immutable configuration authority captured by the runner, including ancestor denials. */
109
+ projectTrust: ProjectTrust;
107
110
  }
108
111
 
109
112
  function textResult(text: string, isError = false) {
@@ -152,7 +155,9 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
152
155
  // Agents resolve from a registry built for THIS branch's config root (under
153
156
  // worktree isolation, the copy). Never via registerAgents — that is
154
157
  // process-global state shared with the main session and every other agent.
155
- const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
158
+ const loadRegistry = () => buildAgentRegistry(loadCustomAgents(
159
+ context.configCwd, false, context.projectTrust.trusted, context.projectTrust.deniedRoots,
160
+ ));
156
161
  const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
157
162
  context.allowedSubagents === "all"
158
163
  ? undefined
@@ -250,6 +255,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
250
255
  const scopeVerdict = checkModelScope({
251
256
  model,
252
257
  cwd: context.configCwd,
258
+ authority: context.projectTrust,
253
259
  modelRegistry: ctx.modelRegistry,
254
260
  callerSupplied: invocation.modelFromParams,
255
261
  agentLabel: config?.displayName ?? resolvedType,
@@ -339,7 +345,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
339
345
  }
340
346
  };
341
347
 
342
- // `ctx` is forwarded to the manager unmodified, never captured at tool-build
348
+ // `ctx` is forwarded with configuration-root provenance, never captured at tool-build
343
349
  // time: each AgentSession builds its own ExtensionRunner from that session's
344
350
  // cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing
345
351
  // one earlier would silently give a grandchild the wrong worktree base, the
@@ -350,7 +356,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
350
356
  // letting it escape into the child's turn.
351
357
  try {
352
358
  if (invocation.runInBackground) {
353
- const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
359
+ const id = context.manager.spawn(context.pi, configurationContext(ctx, context.configCwd, context.projectTrust), resolvedType, params.prompt, {
354
360
  ...options,
355
361
  isBackground: true,
356
362
  });
@@ -362,7 +368,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
362
368
 
363
369
  const { record } = await context.manager.spawnAndWait(
364
370
  context.pi,
365
- ctx,
371
+ configurationContext(ctx, context.configCwd, context.projectTrust),
366
372
  resolvedType,
367
373
  params.prompt,
368
374
  { ...options, signal },
@@ -0,0 +1,146 @@
1
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext, ExtensionFactory, SettingsManager } from "@earendil-works/pi-coding-agent";
4
+
5
+ // Child contexts report the execution cwd, but their SettingsManager may own
6
+ // a different configuration root. Keep that provenance package-internal when
7
+ // nested tools forward the child's public context to the manager.
8
+ export interface ProjectTrust {
9
+ readonly cwd: string;
10
+ readonly trusted: boolean;
11
+ readonly deniedRoots: readonly string[];
12
+ }
13
+
14
+ const configurationRoots = new WeakMap<ExtensionContext, {
15
+ cwd: string;
16
+ trusted?: boolean;
17
+ deniedRoots: readonly string[];
18
+ }>();
19
+
20
+ export function configurationContext(ctx: ExtensionContext, cwd: string, trust?: ProjectTrust): ExtensionContext {
21
+ const previous = configurationRoots.get(ctx);
22
+ configurationRoots.set(ctx, {
23
+ cwd,
24
+ trusted: trust?.trusted ?? previous?.trusted,
25
+ deniedRoots: Object.freeze([...new Set([...(previous?.deniedRoots ?? []), ...(trust?.deniedRoots ?? [])])]),
26
+ });
27
+ return ctx;
28
+ }
29
+
30
+ /** A changed configuration root is denied; ancestor denials survive every hop. */
31
+ export function childProjectTrust(parent: ProjectTrust, cwd: string): ProjectTrust {
32
+ const trusted = parent.trusted && isSameConfiguration(cwd, parent.cwd);
33
+ return Object.freeze({
34
+ cwd: canonicalPath(cwd),
35
+ trusted,
36
+ deniedRoots: Object.freeze([...new Set([...parent.deniedRoots, ...(trusted ? [] : [canonicalPath(cwd)])])]),
37
+ });
38
+ }
39
+
40
+ /** Pi's MCP factory reads config from the session cwd; pin only its bootstrap to the config root. */
41
+ export function configurationMcpFactory(factory: ExtensionFactory, cwd: string, settings: SettingsManager): ExtensionFactory {
42
+ return (pi) => factory(new Proxy(pi, {
43
+ get(target, key, receiver) {
44
+ if (key === "on") return ((event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) => {
45
+ if (event === "session_start") {
46
+ return target.on("session_start", async (event, ctx) => {
47
+ await handler(event, {
48
+ ...ctx,
49
+ cwd,
50
+ isProjectTrusted: () => settings.isProjectTrusted(),
51
+ });
52
+ });
53
+ }
54
+ return Reflect.apply(target.on, target, [event, handler]);
55
+ }) as ExtensionAPI["on"];
56
+ return Reflect.get(target, key, receiver);
57
+ },
58
+ }));
59
+ }
60
+
61
+ function canonicalPath(path: string): string {
62
+ try {
63
+ return realpathSync(path);
64
+ } catch {
65
+ return resolve(path);
66
+ }
67
+ }
68
+
69
+ /** Capture before child resolution/async work; missing or failed trust is denial. */
70
+ export function captureProjectTrust(ctx: ExtensionContext): ProjectTrust {
71
+ const provenance = configurationRoots.get(ctx);
72
+ const cwd = canonicalPath(provenance?.cwd ?? ctx.cwd);
73
+ let trusted = false;
74
+ try {
75
+ // Validate the public trust API even with retained authority. If present,
76
+ // prefer the captured configuration trust to a late execution-cwd callback.
77
+ if (typeof ctx.isProjectTrusted === "function") {
78
+ const publicTrust = ctx.isProjectTrusted() === true;
79
+ trusted = provenance?.trusted ?? publicTrust;
80
+ }
81
+ } catch {
82
+ // A context that cannot establish trust must not authorize project code.
83
+ }
84
+ return Object.freeze({
85
+ cwd,
86
+ trusted,
87
+ deniedRoots: Object.freeze([...new Set([...(provenance?.deniedRoots ?? []), ...(trusted ? [] : [cwd])])]),
88
+ });
89
+ }
90
+
91
+ export function isSameConfiguration(cwd: string, parentCwd: string): boolean {
92
+ return canonicalPath(cwd) === parentCwd;
93
+ }
94
+
95
+ /**
96
+ * Check every traversed prefix, including intermediate symlink targets. A
97
+ * global alias into denied metadata cannot gain authority by pointing its
98
+ * descendants back outside. This is provenance admission, not a race-free
99
+ * filesystem sandbox; an absent leaf still has its existing ancestors checked.
100
+ */
101
+ export function isProjectResource(path: string, cwd: string): boolean {
102
+ if (path.startsWith("builtin:")) return false;
103
+ const roots = [resolve(cwd), canonicalPath(cwd)];
104
+ const within = (file: string): boolean => roots.some(root => {
105
+ const rel = relative(root, file);
106
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
107
+ });
108
+ const visited = new Set<string>();
109
+ const traverse = (file: string): boolean => {
110
+ // Normalize separator variants, never dot segments. Both resolve and join
111
+ // would erase a traversed symlink before a following ".." is inspected.
112
+ let absolute = sep === "\\" ? file.replaceAll("/", sep) : file;
113
+ if (!isAbsolute(absolute)) {
114
+ const root = parse(absolute).root;
115
+ // Resolve only the base (including Windows drive-relative bases), not
116
+ // the unexamined components supplied by the caller or readlink.
117
+ absolute = `${resolve(root || ".")}${sep}${absolute.slice(root.length)}`;
118
+ }
119
+ let prefix = parse(absolute).root;
120
+ if (within(prefix) || within(canonicalPath(prefix))) return true;
121
+ for (const component of absolute.slice(prefix.length).split(sep)) {
122
+ if (!component) continue;
123
+ prefix = `${prefix}${prefix.endsWith(sep) ? "" : sep}${component}`;
124
+ if (within(prefix) || within(canonicalPath(prefix))) return true;
125
+ try {
126
+ if (!lstatSync(prefix).isSymbolicLink()) continue;
127
+ } catch {
128
+ // No readable descendant can exist beyond an absent/invalid prefix.
129
+ return false;
130
+ }
131
+ // Unknown/cyclic resolution is not evidence of global authority.
132
+ if (visited.has(prefix) || visited.size >= 64) return true;
133
+ visited.add(prefix);
134
+ try {
135
+ const target = readlinkSync(prefix);
136
+ if (traverse(isAbsolute(target) ? target : `${dirname(prefix)}${sep}${target}`)) return true;
137
+ } catch {
138
+ return true;
139
+ } finally {
140
+ visited.delete(prefix);
141
+ }
142
+ }
143
+ return false;
144
+ };
145
+ return traverse(path);
146
+ }
package/src/settings.ts CHANGED
@@ -12,6 +12,7 @@ import { isManagedAgentTier, MAX_AGENT_TIER_PROFILES } from "@signalridge/pi-sub
12
12
  // edit index.ts in the same change.
13
13
  import { setDefaultToolTimeoutMs } from "./agent-runner.js";
14
14
  import { NO_FALLBACK } from "./agent-types.js";
15
+ import { isProjectResource } from "./project-trust.js";
15
16
  import type { JoinMode, ThinkingLevel } from "./types.js";
16
17
 
17
18
  /** How a `@handle message` mention is dispatched. See `agentMentions`. */
@@ -722,11 +723,13 @@ function readSettingsFile(path: string): ReadSettings {
722
723
  * Load merged settings: global provides defaults, project overrides. Agent-tier
723
724
  * profiles are complete replacement units: a project entry never inherits one
724
725
  * field from the global entry, and an invalid project entry blocks that global
725
- * entry instead of silently reviving it.
726
+ * entry instead of silently reviving it. Runtime callers must pass captured trust;
727
+ * the default preserves standalone helper compatibility.
726
728
  */
727
- export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
728
- const global = readSettingsFile(globalPath());
729
- const project = readSettingsFile(projectPath(cwd));
729
+ export function loadSettings(cwd: string = process.cwd(), projectTrusted = true): SubagentsSettings {
730
+ const empty = (): ReadSettings => ({ settings: {}, agentTiers: emptyAgentTiersSource() });
731
+ const global = projectTrusted || !isProjectResource(globalPath(), cwd) ? readSettingsFile(globalPath()) : empty();
732
+ const project = projectTrusted ? readSettingsFile(projectPath(cwd)) : empty();
730
733
  const { agentTiers: _globalAgentTiers, ...globalSettings } = global.settings;
731
734
  const { agentTiers: _projectAgentTiers, ...projectSettings } = project.settings;
732
735
  const agentTiers = mergeAgentTierSources(global.agentTiers, project.agentTiers);
@@ -30,37 +30,48 @@ export interface PreloadedSkill {
30
30
  content: string;
31
31
  }
32
32
 
33
- export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
34
- return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
33
+ export interface PreloadSkillsOptions {
34
+ /** Denied/unknown project trust excludes both project roots; user skills remain available. */
35
+ projectTrusted: boolean;
36
+ /** Preserve denied source provenance even through aliases of user skill roots. */
37
+ deniedResource?: (path: string) => boolean;
35
38
  }
36
39
 
37
- function loadSkillContent(name: string, cwd: string): string {
40
+ export function preloadSkills(skillNames: string[], cwd: string, options: PreloadSkillsOptions): PreloadedSkill[] {
41
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, options) }));
42
+ }
43
+
44
+ function loadSkillContent(name: string, cwd: string, options: PreloadSkillsOptions): string {
38
45
  if (isUnsafeName(name)) {
39
46
  return `(Skill "${name}" skipped: name contains path traversal characters)`;
40
47
  }
41
48
  const roots = [
42
- join(cwd, ".pi", "skills"), // project — Pi standard
43
- join(cwd, ".agents", "skills"), // project — Agent Skills spec
49
+ ...(options.projectTrusted === true ? [
50
+ join(cwd, ".pi", "skills"), // project — Pi standard
51
+ join(cwd, ".agents", "skills"), // project — Agent Skills spec
52
+ ] : []),
44
53
  join(getAgentDir(), "skills"), // user — Pi standard
45
54
  join(homedir(), ".agents", "skills"), // user — Agent Skills spec
46
55
  join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
47
56
  ];
48
57
  for (const root of roots) {
49
- const content = findInRoot(root, name);
58
+ if (options.deniedResource?.(root)) continue;
59
+ const content = findInRoot(root, name, options.deniedResource);
50
60
  if (content !== undefined) return content;
51
61
  }
52
62
  return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
53
63
  }
54
64
 
55
- function findInRoot(root: string, name: string): string | undefined {
65
+ function findInRoot(root: string, name: string, deniedResource?: (path: string) => boolean): string | undefined {
56
66
  if (isSymlink(root)) return undefined; // reject symlinked roots entirely
57
- const flat = safeReadFile(join(root, `${name}.md`))?.trim();
67
+ const flatPath = join(root, `${name}.md`);
68
+ const flat = deniedResource?.(flatPath) ? undefined : safeReadFile(flatPath)?.trim();
58
69
  if (flat !== undefined) return flat;
59
- return findSkillDirectory(root, name);
70
+ return findSkillDirectory(root, name, deniedResource);
60
71
  }
61
72
 
62
73
  /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
63
- function findSkillDirectory(root: string, name: string): string | undefined {
74
+ function findSkillDirectory(root: string, name: string, deniedResource?: (path: string) => boolean): string | undefined {
64
75
  if (!existsSync(root)) return undefined;
65
76
  const queue: string[] = [root];
66
77
 
@@ -85,6 +96,7 @@ function findSkillDirectory(root: string, name: string): string | undefined {
85
96
  // Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
86
97
  const path = join(current, entry.name);
87
98
  const skillMd = join(path, "SKILL.md");
99
+ if (deniedResource?.(skillMd)) continue;
88
100
  const isSkillDir = existsSync(skillMd);
89
101
 
90
102
  if (isSkillDir) {