@bacnh85/pi-subagent 0.12.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +5 -3
- package/agents/general-purpose.md +0 -1
- package/extensions/index.ts +26 -8
- package/extensions/runner.ts +73 -8
- package/extensions/security.ts +51 -10
- package/extensions/service.ts +17 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 (2026-07-31)
|
|
4
|
+
|
|
5
|
+
### Subagents inherit parent extensions & tools by default
|
|
6
|
+
|
|
7
|
+
Subagents can now use the same tools the main agent has — including extension
|
|
8
|
+
tools like `web_search`, `serena_*`, `munin_*`, `obsidian`, and `notebooklm`.
|
|
9
|
+
Previously children were restricted to the 7 Pi built-in tools (`read, grep,
|
|
10
|
+
find, ls, bash, edit, write`) and could not load extensions, which made
|
|
11
|
+
delegation far less capable than the main agent.
|
|
12
|
+
|
|
13
|
+
This follows the **Claude Code model**: subagents inherit the parent's tool set,
|
|
14
|
+
with a small denylist (`subagent` — recursive delegation is always prevented)
|
|
15
|
+
and per-agent restriction via an explicit `tools:` line.
|
|
16
|
+
|
|
17
|
+
**Tool resolution:**
|
|
18
|
+
- Agent **omits** `tools:` → inherits **all parent tools** (minus denylist).
|
|
19
|
+
`worker` and `general-purpose` now do this.
|
|
20
|
+
- Agent **specifies** `tools:` → restricted to that list, validated against
|
|
21
|
+
built-ins ∪ parent tools.
|
|
22
|
+
- `sandbox: read-only` / `readOnly` → filters the effective set to read-only.
|
|
23
|
+
- Denied tools (`subagent`) are **silently stripped**, never errored — whether
|
|
24
|
+
explicitly listed or inherited. Inheritance must not crash on a tool the
|
|
25
|
+
child cannot have (the inherited set always includes `subagent`).
|
|
26
|
+
|
|
27
|
+
**Smart lean optimization:** extensions are only loaded when the effective tool
|
|
28
|
+
set contains at least one non-built-in tool. Recon agents with a built-in-only
|
|
29
|
+
`tools:` line (scout, tester, planner, reviewer) stay cheap — zero extension
|
|
30
|
+
overhead, same fast cold-start.
|
|
31
|
+
|
|
32
|
+
**Per-child extension loader.** Children that need extensions get a FRESH
|
|
33
|
+
`DefaultResourceLoader` each run (extensions only: no skills, prompt templates,
|
|
34
|
+
AGENTS.md, or themes). The loader must NOT be cached/shared: extensions capture
|
|
35
|
+
the ExtensionAPI at factory-load time, and its actions delegate to the runtime
|
|
36
|
+
the factory was given (pi.getAllTools() → runtime.getAllTools()). A shared
|
|
37
|
+
loader's runtime is never the one any single child binds — children then hit
|
|
38
|
+
the runtime's throwing "Extension runtime not initialized" stubs on the first
|
|
39
|
+
provider request (pi-model-tools' before_provider_request calls pi.getAllTools())
|
|
40
|
+
or stale-ctx errors after the first child's dispose invalidates the shared
|
|
41
|
+
runtime. A per-child loader keeps every captured `pi` pointing at a runtime the
|
|
42
|
+
child both binds and owns. reload() per child re-reads extension files +
|
|
43
|
+
re-runs factories; acceptable for short-lived children.
|
|
44
|
+
|
|
45
|
+
Extension load errors in children are logged, not fatal. Project-extension trust
|
|
46
|
+
is inherited from the parent (children never prompt).
|
|
47
|
+
|
|
48
|
+
### Bundled agent changes
|
|
49
|
+
|
|
50
|
+
- `worker` and `general-purpose`: removed the explicit `tools:` line so they
|
|
51
|
+
inherit all parent tools.
|
|
52
|
+
- `scout`, `tester`, `planner`, `reviewer`: unchanged (still lean + restricted).
|
|
53
|
+
|
|
3
54
|
## 0.12.4 (2026-07-30)
|
|
4
55
|
|
|
5
56
|
### Improvements
|
package/README.md
CHANGED
|
@@ -16,13 +16,15 @@ Requires Node.js >= 20.18.
|
|
|
16
16
|
| --- | --- | --- | --- |
|
|
17
17
|
| `scout` | `zai-coding-cn/glm-5-turbo` → `nvidia/openai/gpt-oss-20b` → `opencode-go/deepseek-v4-flash` | off | read, grep, find, ls |
|
|
18
18
|
| `tester` | `zai-coding-cn/glm-5-turbo` → `nvidia/openai/gpt-oss-20b` → `opencode-go/deepseek-v4-flash` | off | read, bash, grep, find, ls |
|
|
19
|
-
| `worker` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium |
|
|
20
|
-
| `general-purpose` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium |
|
|
19
|
+
| `worker` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | **inherits all parent tools** |
|
|
20
|
+
| `general-purpose` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | **inherits all parent tools** |
|
|
21
21
|
| `planner` | `zai-coding-cn/glm-5.2` → `openrouter/nvidia/nemotron-3-ultra-550b-a55b:free` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
|
|
22
22
|
| `reviewer` | `zai-coding-cn/glm-5.2` → `openrouter/nvidia/nemotron-3-ultra-550b-a55b:free` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
|
|
23
23
|
|
|
24
24
|
Each role uses the first authenticated preference available through Pi's model registry, then falls back to the authenticated parent model. Chains are **free-first** to conserve the metered opencode-go budget: **zai-coding-cn** (free GLM, primary) → free **nvidia** NIM and **openrouter** `:free` models → **opencode-go** (paid DeepSeek, last resort — one per role: `deepseek-v4-flash` for fast/strong-coding, `deepseek-v4-pro` for deep reasoning). opencode-go's GLM models cost ~$1.40/$4.40 per M versus zai-coding-cn's free GLM, so GLM stays on zai-coding-cn. Fallback models were live-verified on 2026-07-24; `nvidia/moonshotai/kimi-k2.6` and `nvidia/z-ai/glm-5.2` return 404/timeout on the user's account and were removed — non-rate-limit failures kill the subagent instead of advancing the chain. User/project agent files remain stronger overrides and may set legacy `model`, ordered `models`, and `thinking`.
|
|
25
25
|
|
|
26
|
+
**Tool inheritance.** Agents without an explicit `tools:` line (worker, general-purpose) inherit every tool the parent session has — including extension tools like `web_search`, `serena_*`, `munin_*`, `obsidian`, and `notebooklm`. Agents with an explicit `tools:` list (scout, tester, planner, reviewer) are restricted to those tools and, when the list contains only built-ins, run in a lean loader with no extension overhead. To force an agent lean even while inheriting, set `tools: read, bash, edit, write, grep, find, ls`. The `subagent` tool itself is always denied to children (no recursive delegation).
|
|
27
|
+
|
|
26
28
|
## Agent files
|
|
27
29
|
|
|
28
30
|
Create `~/.pi/agent/agents/*.md` or `.pi/agents/*.md`:
|
|
@@ -45,7 +47,7 @@ Agent definitions are cached with file-signature invalidation; `/subagent reload
|
|
|
45
47
|
|
|
46
48
|
## Context and limits
|
|
47
49
|
|
|
48
|
-
Children use in-memory SDK sessions with no extensions, skills, prompt templates, or automatic `AGENTS.md` loading. The optional `instructions` argument passes a bounded 16 KB task/repository contract.
|
|
50
|
+
Children use in-memory SDK sessions. Agents that inherit parent tools load the parent's extensions (web, Serena, Munin, …) into the child; agents restricted to built-in tools run in a lean loader with no extensions, skills, prompt templates, or automatic `AGENTS.md` loading. The optional `instructions` argument passes a bounded 16 KB task/repository contract.
|
|
49
51
|
|
|
50
52
|
Threads are session-memory only and are cleared when Pi replaces or reloads the session. Timeout and parent cancellation propagate to child sessions. Subagents cannot recursively invoke `subagent`.
|
|
51
53
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: general-purpose
|
|
3
3
|
description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
|
|
4
|
-
tools: read, bash, edit, write, grep, find, ls
|
|
5
4
|
models:
|
|
6
5
|
- zai-coding-cn/glm-5.1
|
|
7
6
|
- nvidia/mistralai/mistral-small-4-119b-2603
|
package/extensions/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
normalizeTimeout,
|
|
44
44
|
resolveSafeCwd,
|
|
45
45
|
validateAgentTools,
|
|
46
|
+
needsExtensions,
|
|
46
47
|
truncateParallelOutput,
|
|
47
48
|
validateExecutionRequest,
|
|
48
49
|
READ_ONLY_TOOLS,
|
|
@@ -170,7 +171,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
171
|
: " (parent fallback)";
|
|
171
172
|
const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
|
|
172
173
|
const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
|
|
173
|
-
|
|
174
|
+
// ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
|
|
175
|
+
const toolsInfo = agent.tools ? `, tools: ${agent.tools.join(", ")}` : ", tools: inherits all parent tools";
|
|
176
|
+
return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}${toolsInfo}`;
|
|
174
177
|
})
|
|
175
178
|
.join("\n");
|
|
176
179
|
return {
|
|
@@ -179,6 +182,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
182
|
`\n\n## Available Subagents\n${catalog}\n\n` +
|
|
180
183
|
"The subagent tool can delegate tasks to these specialized agents with isolated context. " +
|
|
181
184
|
"Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
|
|
185
|
+
"Agents marked `inherits all parent tools` can use web, Serena, Munin, and other extensions the main agent has; " +
|
|
186
|
+
"agents with an explicit tool list are leaner and restricted to those tools.\n" +
|
|
182
187
|
"Prefer **scout** and **tester** for cheap routine work. " +
|
|
183
188
|
"Prefer **worker** or **general-purpose** for normal coding. " +
|
|
184
189
|
"Prefer **planner** and **reviewer** for consequential reasoning. " +
|
|
@@ -206,6 +211,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
211
|
timeout: request.timeout,
|
|
207
212
|
instructions: request.instructions,
|
|
208
213
|
signal: request.signal,
|
|
214
|
+
readOnly: request.readOnly,
|
|
209
215
|
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
210
216
|
onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
|
|
211
217
|
}).then((result) => {
|
|
@@ -465,6 +471,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
465
471
|
const modelRuntime = (modelRegistry as any).runtime;
|
|
466
472
|
const authStorage = (modelRegistry as any).authStorage;
|
|
467
473
|
|
|
474
|
+
// Parent session's registered tool names. Agents that omit `tools` inherit
|
|
475
|
+
// the full set (minus the denylist); agents with an explicit `tools` line
|
|
476
|
+
// are validated against built-ins ∪ this set.
|
|
477
|
+
const parentToolNames = pi.getAllTools().map((t) => t.name);
|
|
478
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
479
|
+
|
|
468
480
|
// Helper: resolve a safe child working directory.
|
|
469
481
|
function resolveChildCwd(childCwd: string | undefined): string {
|
|
470
482
|
const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
|
|
@@ -474,21 +486,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
474
486
|
return safe.path;
|
|
475
487
|
}
|
|
476
488
|
|
|
477
|
-
// Helper: validate and normalise tools for an agent.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
489
|
+
// Helper: validate and normalise tools for an agent. Returns the effective
|
|
490
|
+
// tool list and whether extensions must be loaded (any non-built-in tool).
|
|
491
|
+
function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): { tools: string[]; loadExtensions: boolean } {
|
|
492
|
+
// Omitted tools => inherit all parent tools (Claude Code model).
|
|
493
|
+
let rawTools = agentTools ?? parentToolNames;
|
|
481
494
|
// sandbox overrides tools: silently strip mutation tools, not an error
|
|
482
495
|
if (sandbox === "read-only") {
|
|
483
496
|
rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
|
|
484
497
|
if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
|
|
485
498
|
}
|
|
486
499
|
const effectiveReadOnly = readOnly || sandbox === "read-only";
|
|
487
|
-
const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
|
|
500
|
+
const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly, availableTools: parentToolNames });
|
|
488
501
|
if (result.errors.length > 0) {
|
|
489
502
|
throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
|
|
490
503
|
}
|
|
491
|
-
return result.tools;
|
|
504
|
+
return { tools: result.tools, loadExtensions: needsExtensions(result.tools) };
|
|
492
505
|
}
|
|
493
506
|
|
|
494
507
|
// Helper: normalise timeout.
|
|
@@ -550,10 +563,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
550
563
|
|
|
551
564
|
// Security: validate tools, timeout, and cwd (wrapped in try/catch).
|
|
552
565
|
let tools: string[];
|
|
566
|
+
let loadExtensions: boolean;
|
|
553
567
|
let effectiveTimeoutMs: number | undefined;
|
|
554
568
|
let safeCwd: string;
|
|
555
569
|
try {
|
|
556
|
-
|
|
570
|
+
const resolved = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
|
|
571
|
+
tools = resolved.tools;
|
|
572
|
+
loadExtensions = resolved.loadExtensions;
|
|
557
573
|
effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
|
|
558
574
|
safeCwd = resolveChildCwd(cwd);
|
|
559
575
|
} catch (err: unknown) {
|
|
@@ -646,6 +662,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
646
662
|
thinkingLevel: agent.thinking,
|
|
647
663
|
onMessage: onProgress,
|
|
648
664
|
onProgress: onActivity,
|
|
665
|
+
loadExtensions,
|
|
666
|
+
projectTrusted,
|
|
649
667
|
});
|
|
650
668
|
|
|
651
669
|
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
package/extensions/runner.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
19
19
|
import {
|
|
20
20
|
createAgentSession,
|
|
21
21
|
createExtensionRuntime,
|
|
22
|
+
DefaultResourceLoader,
|
|
23
|
+
getAgentDir,
|
|
22
24
|
type ResourceLoader,
|
|
23
25
|
SessionManager,
|
|
24
26
|
SettingsManager,
|
|
@@ -46,6 +48,54 @@ export interface UsageStats {
|
|
|
46
48
|
export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
47
49
|
export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
48
50
|
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Extension resource loader
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a FRESH lean DefaultResourceLoader for this child — no skills, prompt
|
|
57
|
+
* templates, context files (AGENTS.md), or themes. Extensions are loaded per
|
|
58
|
+
* child so their factories run against a runtime that THIS child's session will
|
|
59
|
+
* bind.
|
|
60
|
+
*
|
|
61
|
+
* Do NOT cache/share this loader. Extensions capture the ExtensionAPI (`pi`)
|
|
62
|
+
* at factory load time, and its actions delegate to the runtime passed to the
|
|
63
|
+
* factory (see createExtensionAPI: pi.getAllTools() → runtime.getAllTools()).
|
|
64
|
+
* If extensions were loaded once against a shared/cached runtime, that runtime
|
|
65
|
+
* is never the one any single child binds: a session binds the runtime its own
|
|
66
|
+
* getExtensions() returned (AgentSession constructor), while the factory-captured
|
|
67
|
+
* pi still points at the shared runtime. A child then hits the runtime's throwing
|
|
68
|
+
* "Extension runtime not initialized" stubs on the first provider request
|
|
69
|
+
* (pi-model-tools' before_provider_request calls pi.getAllTools()), or stale-ctx
|
|
70
|
+
* errors when the shared runtime is invalidated by the first child's dispose.
|
|
71
|
+
* A per-child loader keeps every captured `pi` pointing at a runtime this child
|
|
72
|
+
* both binds and owns.
|
|
73
|
+
*
|
|
74
|
+
* Project-extension trust is resolved from the parent (`projectTrusted`) so a
|
|
75
|
+
* child never prompts for trust (it has no UI). reload() per child re-reads
|
|
76
|
+
* extension files + re-runs factories; acceptable for short-lived children.
|
|
77
|
+
*/
|
|
78
|
+
async function getExtensionLoader(cwd: string, projectTrusted: boolean): Promise<ResourceLoader> {
|
|
79
|
+
const loader = new DefaultResourceLoader({
|
|
80
|
+
cwd,
|
|
81
|
+
agentDir: getAgentDir(),
|
|
82
|
+
noSkills: true,
|
|
83
|
+
noPromptTemplates: true,
|
|
84
|
+
noContextFiles: true,
|
|
85
|
+
noThemes: true,
|
|
86
|
+
});
|
|
87
|
+
await loader.reload({ resolveProjectTrust: async () => projectTrusted });
|
|
88
|
+
// Log (don't throw on) extension load errors — a misbehaving extension must
|
|
89
|
+
// not crash the child; the agent simply won't have that tool.
|
|
90
|
+
const loadErrors = loader.getExtensions().errors;
|
|
91
|
+
if (loadErrors.length > 0) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`[pi-subagent] extension load warnings in child loader: ${loadErrors.map((e) => `${e.path}: ${e.error}`).join("; ")}\n`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return loader;
|
|
97
|
+
}
|
|
98
|
+
|
|
49
99
|
export interface SubAgentProgress {
|
|
50
100
|
label: string;
|
|
51
101
|
at: number;
|
|
@@ -97,25 +147,40 @@ export async function runSubAgent(options: {
|
|
|
97
147
|
onProgress?: (progress: SubAgentProgress) => void;
|
|
98
148
|
timeoutMs?: number;
|
|
99
149
|
hardTimeoutMs?: number;
|
|
150
|
+
/**
|
|
151
|
+
* When true, build a DefaultResourceLoader so the child inherits the parent's
|
|
152
|
+
* extensions (and thus extension tools: web, serena, munin, …). When false or
|
|
153
|
+
* unset, the lean empty-loader stub is used (no extensions).
|
|
154
|
+
*/
|
|
155
|
+
loadExtensions?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Whether the parent session trusts the project. Forwarded to the loader's
|
|
158
|
+
* resolveProjectTrust so children inherit the parent's trust decision and
|
|
159
|
+
* never prompt for project-extension trust (children have no UI). Defaults true.
|
|
160
|
+
*/
|
|
161
|
+
projectTrusted?: boolean;
|
|
100
162
|
}): Promise<SubAgentResult> {
|
|
101
163
|
const {
|
|
102
164
|
cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
|
|
103
165
|
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
104
166
|
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
167
|
+
loadExtensions = false, projectTrusted = true,
|
|
105
168
|
} = options;
|
|
106
169
|
const result: SubAgentResult = {
|
|
107
170
|
agent: agentName, task, exitCode: 0, messages: [], stderr: "",
|
|
108
171
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
109
172
|
model: `${model.provider}/${model.id}`, status: undefined,
|
|
110
173
|
};
|
|
111
|
-
const resourceLoader: ResourceLoader =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
174
|
+
const resourceLoader: ResourceLoader = loadExtensions
|
|
175
|
+
? await getExtensionLoader(cwd, projectTrusted)
|
|
176
|
+
: {
|
|
177
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
178
|
+
getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
179
|
+
getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
180
|
+
getSystemPrompt: () => systemPrompt, getSystemPromptSource: () => undefined,
|
|
181
|
+
getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [],
|
|
182
|
+
extendResources: () => {}, reload: async () => {},
|
|
183
|
+
};
|
|
119
184
|
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
|
|
120
185
|
const startedAt = Date.now();
|
|
121
186
|
let inactivityDeadline = startedAt + timeoutMs;
|
package/extensions/security.ts
CHANGED
|
@@ -12,8 +12,12 @@ import * as path from "node:path";
|
|
|
12
12
|
// Constants
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Pi's built-in tools. Agents whose effective tool set is a subset of these
|
|
17
|
+
* run in the cheap lean loader (no extensions loaded). Any other tool name
|
|
18
|
+
* (web_search, serena_*, munin_*, …) requires loading the parent's extensions.
|
|
19
|
+
*/
|
|
20
|
+
export const BUILTIN_TOOLS = [
|
|
17
21
|
"read",
|
|
18
22
|
"grep",
|
|
19
23
|
"find",
|
|
@@ -23,6 +27,16 @@ export const ALLOWED_CHILD_TOOLS = [
|
|
|
23
27
|
"write",
|
|
24
28
|
] as const;
|
|
25
29
|
|
|
30
|
+
/** Backwards-compatible alias. */
|
|
31
|
+
export const ALLOWED_CHILD_TOOLS = BUILTIN_TOOLS;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tools never available to child agents, regardless of inheritance.
|
|
35
|
+
* `subagent` is the only delegation primitive today; if another extension ever
|
|
36
|
+
* adds a delegation/spawn tool, it must be added here too (recursion guard).
|
|
37
|
+
*/
|
|
38
|
+
export const DENIED_CHILD_TOOLS = new Set(["subagent"]);
|
|
39
|
+
|
|
26
40
|
export const READ_ONLY_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
27
41
|
export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
28
42
|
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
@@ -208,6 +222,14 @@ export interface ValidateToolsOptions {
|
|
|
208
222
|
tools: string[];
|
|
209
223
|
/** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */
|
|
210
224
|
readOnly?: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* Parent session's registered tool names. When provided, a tool is accepted if
|
|
227
|
+
* it is a built-in OR in this set (and not in DENIED_CHILD_TOOLS) — this is
|
|
228
|
+
* how children inherit extension tools (web, serena, munin, …). When omitted,
|
|
229
|
+
* only BUILTIN_TOOLS are accepted (lean/legacy mode).
|
|
230
|
+
* Callers must pass a stable snapshot and not mutate it afterwards.
|
|
231
|
+
*/
|
|
232
|
+
availableTools?: readonly string[];
|
|
211
233
|
}
|
|
212
234
|
|
|
213
235
|
export interface ValidateToolsResult {
|
|
@@ -224,8 +246,20 @@ export interface ValidateToolsResult {
|
|
|
224
246
|
* Deduplicates tool names.
|
|
225
247
|
* When `readOnly` is true, only READ_ONLY_TOOLS are permitted.
|
|
226
248
|
*/
|
|
249
|
+
/**
|
|
250
|
+
* Whether a tool set contains any extension tool (non-built-in). When true the
|
|
251
|
+
* runner loads the parent's extensions into the child; when false it uses the
|
|
252
|
+
* lean empty-loader stub, saving system-prompt tokens. Recon agents with an
|
|
253
|
+
* explicit built-in-only `tools` line stay cheap automatically.
|
|
254
|
+
*/
|
|
255
|
+
export function needsExtensions(tools: readonly string[]): boolean {
|
|
256
|
+
const builtins = new Set<string>(BUILTIN_TOOLS);
|
|
257
|
+
return tools.some((t) => !builtins.has(t));
|
|
258
|
+
}
|
|
259
|
+
|
|
227
260
|
export function validateAgentTools(options: ValidateToolsOptions): ValidateToolsResult {
|
|
228
|
-
const { readOnly = false } = options;
|
|
261
|
+
const { readOnly = false, availableTools } = options;
|
|
262
|
+
const extensionTools = availableTools ? new Set(availableTools) : undefined;
|
|
229
263
|
const seen = new Set<string>();
|
|
230
264
|
const tools: string[] = [];
|
|
231
265
|
const errors: string[] = [];
|
|
@@ -234,16 +268,23 @@ export function validateAgentTools(options: ValidateToolsOptions): ValidateTools
|
|
|
234
268
|
const tool = raw.trim();
|
|
235
269
|
if (!tool) continue;
|
|
236
270
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
271
|
+
// Silently strip denied tools (e.g. "subagent") regardless of casing. They
|
|
272
|
+
// are never available to children — whether explicitly listed in an agent's
|
|
273
|
+
// `tools:` line or inherited via parentToolNames. This matches Claude Code,
|
|
274
|
+
// which removes denied tools from subagents "even when listed in the tools
|
|
275
|
+
// field". No error: inheritance must not crash on a tool the child can't have.
|
|
276
|
+
if (DENIED_CHILD_TOOLS.has(tool.toLowerCase())) {
|
|
241
277
|
continue;
|
|
242
278
|
}
|
|
243
279
|
|
|
244
|
-
// Check against allowlist
|
|
245
|
-
|
|
246
|
-
|
|
280
|
+
// Check against allowlist: built-ins, plus inherited extension tools when availableTools given.
|
|
281
|
+
const isBuiltin = BUILTIN_TOOLS.includes(tool as any);
|
|
282
|
+
const isExtension = extensionTools?.has(tool) ?? false;
|
|
283
|
+
if (!isBuiltin && !isExtension) {
|
|
284
|
+
const allowed = extensionTools
|
|
285
|
+
? "parent tools"
|
|
286
|
+
: BUILTIN_TOOLS.join(", ");
|
|
287
|
+
errors.push(`Unknown tool "${tool}". Allowed tools: ${allowed}.`);
|
|
247
288
|
continue;
|
|
248
289
|
}
|
|
249
290
|
|
package/extensions/service.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveModel } from "./model.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
isRateLimitError,
|
|
7
7
|
validateAgentTools,
|
|
8
|
+
needsExtensions,
|
|
8
9
|
normalizeTimeout,
|
|
9
10
|
resolveSafeCwd,
|
|
10
11
|
MAX_INSTRUCTIONS_LENGTH,
|
|
@@ -39,6 +40,8 @@ export async function runNamedAgent(options: {
|
|
|
39
40
|
timeout?: number;
|
|
40
41
|
instructions?: string;
|
|
41
42
|
signal?: AbortSignal;
|
|
43
|
+
/** When true, only read-only tools are permitted regardless of agent.sandbox. */
|
|
44
|
+
readOnly?: boolean;
|
|
42
45
|
onMessage?: (result: SubAgentResult) => void;
|
|
43
46
|
onProgress?: (progress: SubAgentProgress) => void;
|
|
44
47
|
}): Promise<SubAgentResult> {
|
|
@@ -52,17 +55,25 @@ export async function runNamedAgent(options: {
|
|
|
52
55
|
// Security: validate and normalise timeout.
|
|
53
56
|
const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
|
|
54
57
|
|
|
55
|
-
//
|
|
56
|
-
|
|
58
|
+
// Parent tool names — agents without an explicit `tools` line inherit them.
|
|
59
|
+
const parentToolNames = (options.ctx as any).getAllTools?.()?.map((t: { name: string }) => t.name) as string[] | undefined;
|
|
60
|
+
|
|
61
|
+
// Security: validate tools against allowlist (built-ins ∪ inherited parent tools).
|
|
62
|
+
// readOnly from the caller (e.g. pi-review) is enforced even when agent.sandbox
|
|
63
|
+
// is unset — never trust a caller-supplied tool list without the read-only filter.
|
|
64
|
+
const effectiveReadOnly = options.readOnly === true || options.agent.sandbox === "read-only";
|
|
65
|
+
let rawTools = options.agent.tools ?? parentToolNames ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
57
66
|
// Enforce read-only sandbox: strip mutating and execution tools
|
|
58
|
-
if (
|
|
67
|
+
if (effectiveReadOnly) {
|
|
59
68
|
rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
|
|
60
69
|
if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
|
|
61
70
|
}
|
|
62
|
-
const toolValidation = validateAgentTools({ tools: rawTools, readOnly:
|
|
71
|
+
const toolValidation = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly, availableTools: parentToolNames });
|
|
63
72
|
if (toolValidation.errors.length > 0) {
|
|
64
73
|
throw new Error(`Tool validation errors for agent "${options.agent.name}": ${toolValidation.errors.join("; ")}`);
|
|
65
74
|
}
|
|
75
|
+
const loadExtensions = needsExtensions(toolValidation.tools);
|
|
76
|
+
const projectTrusted = options.ctx.isProjectTrusted();
|
|
66
77
|
|
|
67
78
|
// Security: validate cwd (service caller must provide valid cwd).
|
|
68
79
|
// The service path uses the same policy as the tool path.
|
|
@@ -119,6 +130,8 @@ export async function runNamedAgent(options: {
|
|
|
119
130
|
thinkingLevel: options.agent.thinking,
|
|
120
131
|
onMessage: options.onMessage,
|
|
121
132
|
onProgress: options.onProgress,
|
|
133
|
+
loadExtensions,
|
|
134
|
+
projectTrusted,
|
|
122
135
|
});
|
|
123
136
|
|
|
124
137
|
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
package/package.json
CHANGED