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.
@@ -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 the importing
9
- * file), and appends ONLY the imported content. pi already injected the base
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
- out.push({ path: file.real, body: file.body.trim() }, ...collectImports(file.body, path.dirname(file.real), home, allowedRoots, seen, depth + 1))
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
- return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}` }
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
  }
@@ -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
- const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
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
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
120
- child.stdout?.on('data', (chunk) => {
121
- stdout += chunk
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({ code: code ?? 0, stdout, stderr })
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('error', () => {
131
- clearTimeout(timer)
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.isProjectTrusted?.() ?? false
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
- const text = block.text ?? ''
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
- if (!projectConnected && ctx.isProjectTrusted?.()) {
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
  }
@@ -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 { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type ExtensionAPI, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
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
- // Claude Code's separator set (code.claude.com/docs/en/permissions): every subcommand
98
- // must qualify on its own, otherwise an allowlisted first token buys the rest of the line.
99
- const SEPARATORS = /\|\||&&|\|&|[;|&\n]/
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
- const gate = projectAgentGate(requestedProjectAgents.length, ctx.isProjectTrusted?.() ?? false, ctx.hasUI, params.confirmProjectAgents ?? true)
488
- const names = requestedProjectAgents.map((a) => a.name).join(', ')
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
- const dir = projectAgentsDir ?? '(unknown)'
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.0",
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
- }