pi-code 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,8 @@ 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 {
@@ -159,7 +161,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
159
161
  let config: HooksConfig = {}
160
162
 
161
163
  pi.on('session_start', async (event, ctx) => {
162
- const trusted = ctx.isProjectTrusted?.() ?? false
164
+ const trusted = await isProjectApproved(ctx)
163
165
  config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
164
166
  // Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
165
167
  // "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,54 @@ 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
+ function splitSegments(command: string): string[] {
106
+ const segments: string[] = []
107
+ let current = ''
108
+ let quote: "'" | '"' | undefined
109
+
110
+ for (let i = 0; i < command.length; i++) {
111
+ const ch = command[i]
112
+ if (quote !== undefined) {
113
+ current += ch
114
+ if (ch === quote) quote = undefined
115
+ continue
116
+ }
117
+ if (ch === "'" || ch === '"') {
118
+ quote = ch
119
+ current += ch
120
+ continue
121
+ }
122
+ if (ch === '\\' && i + 1 < command.length) {
123
+ current += ch + command[++i]
124
+ continue
125
+ }
126
+ const pair = command.slice(i, i + 2)
127
+ if (pair === '&&' || pair === '||' || pair === '|&') {
128
+ segments.push(current)
129
+ current = ''
130
+ i++
131
+ continue
132
+ }
133
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
134
+ segments.push(current)
135
+ current = ''
136
+ continue
137
+ }
138
+ current += ch
139
+ }
140
+
141
+ if (quote !== undefined) return []
142
+ segments.push(current)
143
+ return segments.map((segment) => segment.trim()).filter(Boolean)
144
+ }
100
145
 
101
146
  // find is allowlisted for traversal only; these actions run commands or delete.
102
147
  const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
@@ -116,10 +161,7 @@ function isSafeSegment(segment: string): boolean {
116
161
  */
117
162
  export function isSafeCommand(command: string): boolean {
118
163
  if (SUBSTITUTION.test(command)) return false
119
- const segments = command
120
- .split(SEPARATORS)
121
- .map((s) => s.trim())
122
- .filter(Boolean)
164
+ const segments = splitSegments(command)
123
165
  return segments.length > 0 && segments.every(isSafeSegment)
124
166
  }
125
167
 
@@ -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
+ }
@@ -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,7 +485,9 @@ 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
+ // 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)
488
491
  const names = requestedProjectAgents.map((a) => a.name).join(', ')
489
492
  if (gate === 'refuse') {
490
493
  return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
- }