pi-code 0.2.0 → 0.2.2
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/extensions/context-imports.ts +36 -8
- package/extensions/hooks.ts +59 -15
- package/extensions/mcp.ts +5 -4
- package/extensions/memory.ts +2 -12
- package/extensions/output-guard.ts +23 -0
- package/extensions/plan-mode/utils.ts +52 -7
- package/extensions/project-approval.ts +73 -0
- package/extensions/subagent/agents.ts +23 -0
- package/extensions/subagent/index.ts +10 -3
- package/package.json +1 -1
- package/extensions/project-trust.ts +0 -65
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
* Claude Code's `@path` imports inside them. This fills that one gap: on
|
|
6
6
|
* before_agent_start it reads the already-loaded context files from
|
|
7
7
|
* systemPromptOptions, resolves any `@path` imports (recursive, depth-capped,
|
|
8
|
-
* cycle-safe; ~ expands to home, relative paths resolve against
|
|
9
|
-
* file), and appends ONLY the imported content. pi already
|
|
10
|
-
* files, so nothing is duplicated.
|
|
8
|
+
* cycle-safe, budget-capped; ~ expands to home, relative paths resolve against
|
|
9
|
+
* the importing file), and appends ONLY the imported content. pi already
|
|
10
|
+
* injected the base files, so nothing is duplicated.
|
|
11
11
|
*
|
|
12
12
|
* Security: context files can come from an untrusted project, so imports are
|
|
13
13
|
* confined (after resolving symlinks) to the working directory and the user's
|
|
14
14
|
* own ~/.claude and ~/.pi config roots. An import that escapes those roots
|
|
15
15
|
* (absolute paths, ~/.ssh, ../.. traversal, symlinks) is ignored, so a hostile
|
|
16
16
|
* CLAUDE.md cannot read arbitrary files into the prompt. Imports inside fenced
|
|
17
|
-
* code blocks are also skipped.
|
|
17
|
+
* code blocks are also skipped. One byte-and-file budget is shared by the whole
|
|
18
|
+
* run, so a context file cannot flood the prompt by importing breadth-first;
|
|
19
|
+
* what the budget refused is stated in the prompt rather than dropped silently.
|
|
18
20
|
*
|
|
19
21
|
* Docs: https://code.claude.com/docs/en/memory.md (imports)
|
|
20
22
|
*/
|
|
@@ -25,6 +27,8 @@ import * as path from 'node:path'
|
|
|
25
27
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
28
|
|
|
27
29
|
const MAX_IMPORT_DEPTH = 5
|
|
30
|
+
export const MAX_IMPORT_FILES = 50
|
|
31
|
+
export const MAX_IMPORT_BYTES = 256 * 1024
|
|
28
32
|
|
|
29
33
|
export function expandHome(target: string, home: string): string {
|
|
30
34
|
if (target === '~') return home
|
|
@@ -54,6 +58,18 @@ export interface ImportedFile {
|
|
|
54
58
|
body: string
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** Appended to the last body the byte budget could only partly pay for. */
|
|
62
|
+
export const IMPORT_TRUNCATED_MARKER = '[truncated: import byte budget exhausted]'
|
|
63
|
+
|
|
64
|
+
/** Remaining import allowance, shared across every context file of one run. */
|
|
65
|
+
export interface ImportBudget {
|
|
66
|
+
files: number
|
|
67
|
+
bytes: number
|
|
68
|
+
dropped: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
|
|
72
|
+
|
|
57
73
|
/** The `@path` targets of a context file, in document order, skipping fenced code blocks. */
|
|
58
74
|
function importTargets(content: string): string[] {
|
|
59
75
|
const targets: string[] = []
|
|
@@ -94,13 +110,22 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
94
110
|
* discovery order. Imports are resolved through symlinks and kept within
|
|
95
111
|
* `allowedRoots` (which must already be realpath'd).
|
|
96
112
|
*/
|
|
97
|
-
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, depth = 0): ImportedFile[] {
|
|
113
|
+
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0): ImportedFile[] {
|
|
98
114
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
99
115
|
const out: ImportedFile[] = []
|
|
100
116
|
for (const target of importTargets(content)) {
|
|
117
|
+
// Checked before the read so an exhausted budget costs no I/O.
|
|
118
|
+
if (budget.files === 0 || budget.bytes === 0) {
|
|
119
|
+
budget.dropped += 1
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
101
122
|
const file = readImport(target, fromDir, home, allowedRoots, seen)
|
|
102
123
|
if (!file) continue
|
|
103
|
-
|
|
124
|
+
budget.files -= 1
|
|
125
|
+
const kept = file.body.slice(0, budget.bytes)
|
|
126
|
+
budget.bytes -= kept.length
|
|
127
|
+
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
128
|
+
out.push({ path: file.real, body }, ...collectImports(kept, path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1))
|
|
104
129
|
}
|
|
105
130
|
return out
|
|
106
131
|
}
|
|
@@ -132,14 +157,17 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
132
157
|
const seenSet = new Set(seen)
|
|
133
158
|
|
|
134
159
|
const imported: ImportedFile[] = []
|
|
160
|
+
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
161
|
+
const budget = createImportBudget()
|
|
135
162
|
for (const file of contextFiles) {
|
|
136
163
|
// Roots are scoped per importing file: a project file never reaches user config.
|
|
137
164
|
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
138
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet))
|
|
165
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
|
|
139
166
|
}
|
|
140
167
|
if (imported.length === 0) return
|
|
141
168
|
|
|
142
169
|
const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
|
|
143
|
-
|
|
170
|
+
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
171
|
+
return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}${notice}` }
|
|
144
172
|
})
|
|
145
173
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -21,12 +21,14 @@
|
|
|
21
21
|
* Docs: https://code.claude.com/docs/en/hooks.md
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { spawn } from 'node:child_process'
|
|
24
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
25
25
|
import * as fs from 'node:fs'
|
|
26
26
|
import * as os from 'node:os'
|
|
27
27
|
import * as path from 'node:path'
|
|
28
28
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
29
29
|
|
|
30
|
+
import { isProjectApproved } from './project-approval.js'
|
|
31
|
+
|
|
30
32
|
const DEFAULT_TIMEOUT_S = 60
|
|
31
33
|
|
|
32
34
|
interface HookCommand {
|
|
@@ -48,6 +50,8 @@ export interface HookRunResult {
|
|
|
48
50
|
code: number
|
|
49
51
|
stdout: string
|
|
50
52
|
stderr: string
|
|
53
|
+
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
54
|
+
timedOut: boolean
|
|
51
55
|
}
|
|
52
56
|
export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
53
57
|
|
|
@@ -110,27 +114,64 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
110
114
|
return { block: false }
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
/** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
|
|
118
|
+
const MAX_HOOK_OUTPUT = 1_000_000
|
|
119
|
+
|
|
120
|
+
/** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
|
|
121
|
+
const TIMEOUT_EXIT_CODE = 124
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
|
|
125
|
+
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
126
|
+
*/
|
|
127
|
+
function killTree(child: ChildProcess): void {
|
|
128
|
+
try {
|
|
129
|
+
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
130
|
+
if (child.pid) {
|
|
131
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Group already reaped, or the platform refused it; fall through to the direct kill.
|
|
136
|
+
}
|
|
137
|
+
child.kill('SIGKILL')
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
114
141
|
new Promise((resolve) => {
|
|
115
142
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
116
|
-
|
|
143
|
+
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
144
|
+
// the descendants too.
|
|
145
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
|
117
146
|
let stdout = ''
|
|
118
147
|
let stderr = ''
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
child.stderr?.on('data', (chunk) => {
|
|
124
|
-
stderr += chunk
|
|
125
|
-
})
|
|
126
|
-
child.on('close', (code) => {
|
|
148
|
+
let settled = false
|
|
149
|
+
const finish = (result: HookRunResult): void => {
|
|
150
|
+
if (settled) return
|
|
151
|
+
settled = true
|
|
127
152
|
clearTimeout(timer)
|
|
128
|
-
resolve(
|
|
153
|
+
resolve(result)
|
|
154
|
+
}
|
|
155
|
+
// Resolve from the timer itself rather than waiting for `close`: `close` fires only
|
|
156
|
+
// once every stdio pipe is closed, and a grandchild that inherited them can hold the
|
|
157
|
+
// promise pending long past the timeout, stalling the tool call that awaits it.
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
killTree(child)
|
|
160
|
+
finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
|
|
161
|
+
}, timeoutMs)
|
|
162
|
+
// Decode on the stream: concatenating Buffers as strings mangles a multi-byte
|
|
163
|
+
// character split across chunks, and a mangled byte in a hook's deny decision makes
|
|
164
|
+
// it unparseable, which reads as an allow.
|
|
165
|
+
child.stdout?.setEncoding('utf8')
|
|
166
|
+
child.stderr?.setEncoding('utf8')
|
|
167
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
168
|
+
if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
|
|
129
169
|
})
|
|
130
|
-
child.on('
|
|
131
|
-
|
|
132
|
-
resolve({ code: 0, stdout, stderr })
|
|
170
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
171
|
+
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
133
172
|
})
|
|
173
|
+
child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
|
|
174
|
+
child.on('error', () => finish({ code: 0, stdout, stderr, timedOut: false }))
|
|
134
175
|
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
135
176
|
// so ignore EPIPE on this write rather than crashing the host process.
|
|
136
177
|
child.stdin?.on('error', () => {})
|
|
@@ -145,6 +186,9 @@ function timeoutMs(command: HookCommand): number {
|
|
|
145
186
|
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
|
|
146
187
|
for (const command of matchingCommands(config.PreToolUse, toolName)) {
|
|
147
188
|
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
|
|
189
|
+
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
190
|
+
// would otherwise read as a clean allow. Fail closed instead.
|
|
191
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
|
|
148
192
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
149
193
|
if (decision.block) return decision
|
|
150
194
|
}
|
|
@@ -159,7 +203,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
159
203
|
let config: HooksConfig = {}
|
|
160
204
|
|
|
161
205
|
pi.on('session_start', async (event, ctx) => {
|
|
162
|
-
const trusted = ctx
|
|
206
|
+
const trusted = await isProjectApproved(ctx)
|
|
163
207
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
164
208
|
// Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
|
|
165
209
|
// "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
|
package/extensions/mcp.ts
CHANGED
|
@@ -24,10 +24,11 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
|
|
|
24
24
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
25
25
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
26
26
|
import { Type } from 'typebox'
|
|
27
|
+
import { capForContext } from './output-guard.js'
|
|
28
|
+
import { isProjectApproved } from './project-approval.js'
|
|
27
29
|
|
|
28
30
|
const CONNECT_TIMEOUT_MS = 10_000
|
|
29
31
|
const CALL_TIMEOUT_MS = 120_000
|
|
30
|
-
const MAX_INLINE_RESULT = 50_000
|
|
31
32
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
32
33
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
33
34
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -114,8 +115,7 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
|
|
|
114
115
|
}
|
|
115
116
|
return content.map((block) => {
|
|
116
117
|
if (block.type === 'text') {
|
|
117
|
-
|
|
118
|
-
return text.length > MAX_INLINE_RESULT ? { type: 'text', text: `${text.slice(0, MAX_INLINE_RESULT)}\n[truncated ${text.length - MAX_INLINE_RESULT} chars]` } : { type: 'text', text }
|
|
118
|
+
return { type: 'text', text: capForContext(block.text ?? '') }
|
|
119
119
|
}
|
|
120
120
|
if (block.type === 'image' && block.data) {
|
|
121
121
|
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
@@ -249,7 +249,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
249
249
|
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
250
250
|
}
|
|
251
251
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
252
|
-
|
|
252
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
253
|
+
if (!projectConnected && (await isProjectApproved(ctx))) {
|
|
253
254
|
projectConnected = true
|
|
254
255
|
await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
|
|
255
256
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -11,8 +11,9 @@ import * as fs from 'node:fs'
|
|
|
11
11
|
import * as os from 'node:os'
|
|
12
12
|
import * as path from 'node:path'
|
|
13
13
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
|
-
import {
|
|
14
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
15
15
|
import { Type } from 'typebox'
|
|
16
|
+
import { capForContext } from './output-guard.js'
|
|
16
17
|
|
|
17
18
|
const INDEX_FILE = 'MEMORY.md'
|
|
18
19
|
|
|
@@ -63,17 +64,6 @@ function readIndex(dir: string): string {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
-
* Keep a memory inside pi's context budget. truncateHead keeps whole lines, so a
|
|
68
|
-
* single oversized line yields nothing; fall back to a hard slice in that case.
|
|
69
|
-
*/
|
|
70
|
-
function capForContext(body: string): string {
|
|
71
|
-
const cut = truncateHead(body, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
|
72
|
-
if (!cut.truncated) return body
|
|
73
|
-
const kept = cut.content || body.slice(0, DEFAULT_MAX_BYTES)
|
|
74
|
-
return `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total]`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
67
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
78
68
|
let dir = memoryDir(process.cwd())
|
|
79
69
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Guard
|
|
3
|
+
*
|
|
4
|
+
* pi requires every tool to truncate its output, at 50KB or 2000 lines, whichever is hit
|
|
5
|
+
* first (docs/extensions.md, "Tool output"). Each tool used to decide that for itself, so
|
|
6
|
+
* the budgets and the truncation notices diverged and a byte-only cap let thousands of
|
|
7
|
+
* short lines through. This is the single place that decision lives.
|
|
8
|
+
*
|
|
9
|
+
* `truncateHead` keeps whole lines, which means a single line over the budget yields no
|
|
10
|
+
* content at all. That trap is handled here once rather than at each call site.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
|
|
14
|
+
|
|
15
|
+
/** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
|
|
16
|
+
export function capForContext(text: string): string {
|
|
17
|
+
const cut = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
|
18
|
+
if (!cut.truncated) return text
|
|
19
|
+
const kept = cut.content || text.slice(0, DEFAULT_MAX_BYTES)
|
|
20
|
+
const capped = `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total, ${cut.totalLines} lines]`
|
|
21
|
+
// Just over the budget, the notice can cost more than the trim saves.
|
|
22
|
+
return capped.length < text.length ? capped : text
|
|
23
|
+
}
|
|
@@ -94,9 +94,57 @@ const SAFE_PATTERNS = [
|
|
|
94
94
|
// outright rather than parsed.
|
|
95
95
|
const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
|
|
99
|
+
* newline) so every subcommand is checked on its own, ignoring separators inside quotes:
|
|
100
|
+
* `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
|
|
101
|
+
* fails the caller closed rather than guessing at the intended split.
|
|
102
|
+
*
|
|
103
|
+
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
104
|
+
*/
|
|
105
|
+
/** Length of the separator at `i`, or 0 when there is none. */
|
|
106
|
+
function separatorAt(command: string, i: number): number {
|
|
107
|
+
const pair = command.slice(i, i + 2)
|
|
108
|
+
if (pair === '&&' || pair === '||' || pair === '|&') return 2
|
|
109
|
+
const ch = command[i]
|
|
110
|
+
return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitSegments(command: string): string[] {
|
|
114
|
+
const segments: string[] = []
|
|
115
|
+
let current = ''
|
|
116
|
+
let quote: "'" | '"' | undefined
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < command.length; i++) {
|
|
119
|
+
const ch = command[i]
|
|
120
|
+
if (quote !== undefined) {
|
|
121
|
+
current += ch
|
|
122
|
+
if (ch === quote) quote = undefined
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (ch === "'" || ch === '"') {
|
|
126
|
+
quote = ch
|
|
127
|
+
current += ch
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
131
|
+
current += ch + command[++i]
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const separator = separatorAt(command, i)
|
|
135
|
+
if (separator > 0) {
|
|
136
|
+
segments.push(current)
|
|
137
|
+
current = ''
|
|
138
|
+
i += separator - 1
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
current += ch
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (quote !== undefined) return []
|
|
145
|
+
segments.push(current)
|
|
146
|
+
return segments.map((segment) => segment.trim()).filter(Boolean)
|
|
147
|
+
}
|
|
100
148
|
|
|
101
149
|
// find is allowlisted for traversal only; these actions run commands or delete.
|
|
102
150
|
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
@@ -116,10 +164,7 @@ function isSafeSegment(segment: string): boolean {
|
|
|
116
164
|
*/
|
|
117
165
|
export function isSafeCommand(command: string): boolean {
|
|
118
166
|
if (SUBSTITUTION.test(command)) return false
|
|
119
|
-
const segments = command
|
|
120
|
-
.split(SEPARATORS)
|
|
121
|
-
.map((s) => s.trim())
|
|
122
|
-
.filter(Boolean)
|
|
167
|
+
const segments = splitSegments(command)
|
|
123
168
|
return segments.length > 0 && segments.every(isSafeSegment)
|
|
124
169
|
}
|
|
125
170
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Approval
|
|
3
|
+
*
|
|
4
|
+
* `ctx.isProjectTrusted()` is not sufficient on its own. pi decides whether to ask for
|
|
5
|
+
* trust in `hasTrustRequiringProjectResources`, which looks only under `cwd/.pi` and for
|
|
6
|
+
* `.agents/skills`. A repository shipping just `.claude/` and `.mcp.json` matches neither,
|
|
7
|
+
* so `resolveProjectTrusted` short-circuits to `true` before it ever emits `project_trust`:
|
|
8
|
+
*
|
|
9
|
+
* if (!hasTrustRequiringProjectResources(cwd)) return true
|
|
10
|
+
* if (extensionsResult) { ...emitProjectTrustEvent... }
|
|
11
|
+
*
|
|
12
|
+
* A `project_trust` handler therefore cannot cover this case; the event only fires for
|
|
13
|
+
* projects pi was already going to prompt about. The decision has to be made where the
|
|
14
|
+
* project config is consumed instead, which is what this module does.
|
|
15
|
+
*
|
|
16
|
+
* Answers are stored in pi's own trust store, so approving here also satisfies pi if the
|
|
17
|
+
* project later grows `.pi` resources, and a decision recorded on a parent directory applies.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
23
|
+
|
|
24
|
+
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
25
|
+
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
26
|
+
|
|
27
|
+
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
28
|
+
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApprovalContext {
|
|
32
|
+
cwd: string
|
|
33
|
+
hasUI: boolean
|
|
34
|
+
isProjectTrusted?: () => boolean
|
|
35
|
+
ui: { confirm: (title: string, body: string) => Promise<boolean> }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApprovalDeps {
|
|
39
|
+
hasClaudeShaped: (cwd: string) => boolean
|
|
40
|
+
piWouldAsk: (cwd: string) => boolean
|
|
41
|
+
savedDecision: (cwd: string) => boolean | null
|
|
42
|
+
remember: (cwd: string, trusted: boolean) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultDeps: ApprovalDeps = {
|
|
46
|
+
hasClaudeShaped: hasClaudeShapedConfig,
|
|
47
|
+
piWouldAsk: hasTrustRequiringProjectResources,
|
|
48
|
+
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
49
|
+
remember: (cwd, trusted) => new ProjectTrustStore(getAgentDir()).set(cwd, trusted),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MCP servers, hooks and agents can run commands from this repository.'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether project-controlled config may be acted on.
|
|
56
|
+
*
|
|
57
|
+
* Refuses without a UI rather than deferring: pi reached this point without consulting
|
|
58
|
+
* `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
|
|
59
|
+
* that cannot ask has not been approved.
|
|
60
|
+
*/
|
|
61
|
+
export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
|
|
62
|
+
if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
|
|
63
|
+
if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
|
|
64
|
+
if (deps.piWouldAsk(ctx.cwd)) return true // pi genuinely prompted for this project
|
|
65
|
+
|
|
66
|
+
const stored = deps.savedDecision(ctx.cwd)
|
|
67
|
+
if (stored !== null) return stored
|
|
68
|
+
if (!ctx.hasUI) return false
|
|
69
|
+
|
|
70
|
+
const approved = await ctx.ui.confirm('Trust this project?', `${ctx.cwd}\n\n${APPROVAL_BODY}`)
|
|
71
|
+
deps.remember(ctx.cwd, approved)
|
|
72
|
+
return approved
|
|
73
|
+
}
|
|
@@ -99,12 +99,35 @@ function isDirectory(p: string): boolean {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Project root at or above `from`. `.git` is a file in worktrees and submodules. */
|
|
103
|
+
const ROOT_MARKERS = ['.git', 'package.json']
|
|
104
|
+
|
|
105
|
+
function repoRoot(from: string): string | undefined {
|
|
106
|
+
let currentDir = from
|
|
107
|
+
while (true) {
|
|
108
|
+
if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
|
|
109
|
+
const parentDir = path.dirname(currentDir)
|
|
110
|
+
if (parentDir === currentDir) return undefined
|
|
111
|
+
currentDir = parentDir
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Nearest `relative` directory at or above `cwd`, stopping at the repository root.
|
|
117
|
+
*
|
|
118
|
+
* Without the boundary the search runs to the filesystem root, so an agent planted in a
|
|
119
|
+
* world-writable ancestor such as /tmp is offered as a project agent for every session
|
|
120
|
+
* beneath it. With no project marker (.git, package.json) the extent is unknown, so only
|
|
121
|
+
* `cwd` is considered.
|
|
122
|
+
*/
|
|
102
123
|
function findNearestDir(cwd: string, relative: string): string | null {
|
|
124
|
+
const boundary = repoRoot(cwd) ?? cwd
|
|
103
125
|
let currentDir = cwd
|
|
104
126
|
while (true) {
|
|
105
127
|
const candidate = path.join(currentDir, relative)
|
|
106
128
|
if (isDirectory(candidate)) return candidate
|
|
107
129
|
|
|
130
|
+
if (currentDir === boundary) return null
|
|
108
131
|
const parentDir = path.dirname(currentDir)
|
|
109
132
|
if (parentDir === currentDir) return null
|
|
110
133
|
currentDir = parentDir
|
|
@@ -22,6 +22,7 @@ import { StringEnum } from '@earendil-works/pi-ai'
|
|
|
22
22
|
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
24
24
|
import { type Static, Type } from 'typebox'
|
|
25
|
+
import { isProjectApproved } from '../project-approval.js'
|
|
25
26
|
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
26
27
|
import { backgroundStatusText, startBackgroundRun } from './background.js'
|
|
27
28
|
|
|
@@ -484,13 +485,19 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
484
485
|
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
485
486
|
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
486
487
|
|
|
487
|
-
|
|
488
|
-
const
|
|
488
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
489
|
+
const approved = await isProjectApproved(ctx)
|
|
490
|
+
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
491
|
+
// Agent names come from repo-controlled frontmatter; a newline in one would otherwise
|
|
492
|
+
// let it write its own "Source:" line into the prompt body.
|
|
493
|
+
const names = requestedProjectAgents.map((a) => a.name.replace(/\s+/g, ' ').trim()).join(', ')
|
|
489
494
|
if (gate === 'refuse') {
|
|
490
495
|
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
|
491
496
|
}
|
|
492
497
|
if (gate === 'confirm') {
|
|
493
|
-
|
|
498
|
+
// Each agent knows where it was loaded from; projectAgentsDir only ever held .pi/agents.
|
|
499
|
+
const dirs = [...new Set(requestedProjectAgents.map((a) => path.dirname(a.filePath)))]
|
|
500
|
+
const dir = dirs.join(', ') || projectAgentsDir || '(unknown)'
|
|
494
501
|
const ok = await ctx.ui.confirm('Run project-local agents?', `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`)
|
|
495
502
|
if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
|
|
496
503
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project Trust Extension
|
|
3
|
-
*
|
|
4
|
-
* pi decides whether to prompt for trust from `hasTrustRequiringProjectResources`,
|
|
5
|
-
* which looks only at entries under `cwd/.pi` and at `.agents/skills`. A repository
|
|
6
|
-
* that ships only Claude Code shaped config, `.claude/` plus `.mcp.json`, matches
|
|
7
|
-
* none of them, so pi trusts it without asking.
|
|
8
|
-
*
|
|
9
|
-
* That is exactly the shape pi-code exists to load, and the other extensions gate
|
|
10
|
-
* their project input on `ctx.isProjectTrusted()`. Without this handler that flag is
|
|
11
|
-
* true for a freshly cloned repo nobody was asked about, and a project `.mcp.json`
|
|
12
|
-
* server command, project hooks and project agents all run.
|
|
13
|
-
*
|
|
14
|
-
* Only user/global and CLI extensions receive `project_trust`, so this works when
|
|
15
|
-
* pi-code is installed with `pi install npm:pi-code`. A project-local install
|
|
16
|
-
* (`pi install -l`) is not loaded until trust is already resolved.
|
|
17
|
-
*
|
|
18
|
-
* Docs: node_modules/@earendil-works/pi-coding-agent/docs/extensions.md (project_trust)
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import * as fs from 'node:fs'
|
|
22
|
-
import * as path from 'node:path'
|
|
23
|
-
import { type ExtensionAPI, getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
24
|
-
|
|
25
|
-
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
26
|
-
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
27
|
-
|
|
28
|
-
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
29
|
-
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export type TrustDecision = { trusted: 'yes' | 'no' | 'undecided'; remember?: boolean }
|
|
33
|
-
|
|
34
|
-
interface TrustDeps {
|
|
35
|
-
hasClaudeShaped: (cwd: string) => boolean
|
|
36
|
-
piWouldAsk: (cwd: string) => boolean
|
|
37
|
-
savedDecision: (cwd: string) => boolean | null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const defaultDeps: TrustDeps = {
|
|
41
|
-
hasClaudeShaped: hasClaudeShapedConfig,
|
|
42
|
-
piWouldAsk: hasTrustRequiringProjectResources,
|
|
43
|
-
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Whether to take over the trust decision for this project.
|
|
48
|
-
*
|
|
49
|
-
* Returns `undecided` wherever pi already resolves things correctly, so a remembered
|
|
50
|
-
* decision is never overridden and a headless run keeps whatever `defaultProjectTrust`
|
|
51
|
-
* says rather than being forced closed.
|
|
52
|
-
*/
|
|
53
|
-
export async function decideTrust(cwd: string, hasUI: boolean, confirm: (title: string, body: string) => Promise<boolean>, deps: TrustDeps = defaultDeps): Promise<TrustDecision> {
|
|
54
|
-
if (!deps.hasClaudeShaped(cwd)) return { trusted: 'undecided' }
|
|
55
|
-
if (deps.piWouldAsk(cwd)) return { trusted: 'undecided' } // pi prompts on its own
|
|
56
|
-
if (deps.savedDecision(cwd) !== null) return { trusted: 'undecided' } // apply the stored answer
|
|
57
|
-
if (!hasUI) return { trusted: 'undecided' } // cannot ask; leave pi's default in charge
|
|
58
|
-
|
|
59
|
-
const approved = await confirm('Trust this project?', `${cwd}\n\nIt ships Claude Code configuration that pi-code loads: MCP servers, hooks and agents can run commands from this repository.`)
|
|
60
|
-
return { trusted: approved ? 'yes' : 'no', remember: true }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export default function projectTrustExtension(pi: ExtensionAPI) {
|
|
64
|
-
pi.on('project_trust', async (event, ctx) => decideTrust(event.cwd, ctx.hasUI, (title, body) => ctx.ui.confirm(title, body)))
|
|
65
|
-
}
|