@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 +23 -0
- package/README.md +16 -4
- package/package.json +8 -8
- package/src/agent-runner.ts +66 -28
- package/src/agent-types.ts +6 -0
- package/src/custom-agents.ts +12 -4
- package/src/enabled-models.ts +12 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +133 -65
- package/src/model-scope.ts +4 -1
- package/src/nested-tools.ts +10 -4
- package/src/project-trust.ts +146 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
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
|
-
-
|
|
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**:
|
|
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;
|
|
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.
|
|
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": "
|
|
29
|
-
"@earendil-works/pi-coding-agent": "
|
|
30
|
-
"@earendil-works/pi-tui": "
|
|
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.
|
|
36
|
-
"@signalridge/pi-ui": "^1.3.
|
|
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.
|
|
56
|
+
"vitest": "^4.1.11"
|
|
57
57
|
},
|
|
58
58
|
"pi": {
|
|
59
59
|
"extensions": [
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
|
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).
|
|
1093
|
-
// post-factory
|
|
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
|
|
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
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
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,
|
|
1212
|
+
extensionFactories.push({ name: "mcp", builtin: true, replaceable: true,
|
|
1213
|
+
factory: configurationMcpFactory(PiCodingAgent.createMcpExtension(), configCwd, settingsManager) });
|
|
1191
1214
|
}
|
|
1192
1215
|
|
|
1193
|
-
const
|
|
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.
|
|
1201
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
package/src/agent-types.ts
CHANGED
|
@@ -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,
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
144
|
-
loadFromDir(
|
|
145
|
-
|
|
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) {
|
package/src/enabled-models.ts
CHANGED
|
@@ -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
|
-
* **
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
586
|
+
const trust = captureProjectTrust(ctx);
|
|
587
|
+
const startupSettings = loadSettings(trust.cwd, trust.trusted);
|
|
586
588
|
const startupAgents = loadCustomAgents(
|
|
587
|
-
|
|
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
|
|
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
|
-
|
|
617
|
+
initialContext: ExtensionContext,
|
|
615
618
|
unsubscribeRootContext: () => void,
|
|
616
619
|
): RootRuntime {
|
|
617
|
-
let
|
|
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
|
|
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
|
-
|
|
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).
|
|
1864
|
-
//
|
|
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
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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()}${
|
|
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: () =>
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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
|
);
|
package/src/model-scope.ts
CHANGED
|
@@ -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
|
package/src/nested-tools.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
729
|
-
const
|
|
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);
|
package/src/skill-loader.ts
CHANGED
|
@@ -30,37 +30,48 @@ export interface PreloadedSkill {
|
|
|
30
30
|
content: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|