pi-code 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -41,7 +41,8 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
41
41
  | Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
42
42
  | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
43
43
  | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
44
- | MCP servers | `~/.claude.json`, `~/.pi/agent/mcp.json`, `.mcp.json`, `.pi/mcp.json` (later wins); stdio, HTTP, SSE | `mcp.ts` |
44
+ | MCP servers | user `~/.claude.json`, `~/.pi/agent/mcp.json` (loaded on session start); project `.mcp.json`, `.pi/mcp.json` (only once the project is approved); stdio, HTTP, SSE | `mcp.ts` |
45
+ | Project trust | prompts before loading project config (MCP servers, hooks, agents) that pi would otherwise trust silently | `project-approval.ts` |
45
46
  | Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
46
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
47
48
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
@@ -54,6 +55,8 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
54
55
 
55
56
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
56
57
 
58
+ `output-guard.ts` and `web-transport.ts` are shared internals (context-budget truncation, DNS-pinned fetch) with no feature of their own; the tools above use them.
59
+
57
60
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
58
61
 
59
62
  ## Development
package/extensions/mcp.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * MCP Adapter Extension
3
3
  *
4
- * Connects MCP (Model Context Protocol) servers from mcp.json and registers
5
- * their tools in pi as `<server>_<tool>`. Async factory connects eagerly at
6
- * startup (per-server timeout, failures skip with a notice); stdio and HTTP
7
- * (streamable with SSE fallback) transports; /mcp shows status.
4
+ * Connects MCP (Model Context Protocol) servers and registers their tools in pi
5
+ * as `<server>_<tool>`. Connects on `session_start`, not from the factory (pi runs
6
+ * the factory for invocations that never start a session); per-server timeout,
7
+ * failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
8
+ * transports; /mcp shows status.
8
9
  *
9
- * Reads Claude Code's MCP config too. Merge order (later wins): ~/.claude.json,
10
- * ~/.pi/agent/mcp.json, .mcp.json, then .pi/mcp.json. User config connects at
11
- * startup; project config (.mcp.json / .pi/mcp.json) can run arbitrary commands,
12
- * so it connects only once the project is trusted.
13
- * Values support ${VAR} environment interpolation.
10
+ * Reads Claude Code's MCP config too. User config (~/.claude.json,
11
+ * ~/.pi/agent/mcp.json) is the user's own and loads on the first session. Project
12
+ * config (.mcp.json, .pi/mcp.json) can run arbitrary commands on connect, so it
13
+ * loads only once the project is approved (see project-approval). The two scopes
14
+ * are loaded separately, not merged; user config connects first, so a project
15
+ * server cannot take the name of a user server that connected.
16
+ * Values support ${VAR} interpolation, and a stdio server receives only the SDK's
17
+ * default environment plus its own `env` block, not the whole process environment.
14
18
  */
15
19
 
16
20
  import * as fs from 'node:fs'
@@ -66,15 +70,6 @@ export function projectConfigPaths(cwd: string): string[] {
66
70
  return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
67
71
  }
68
72
 
69
- /** All config files, later winning: user first, then project. */
70
- export function configPaths(cwd: string, home: string): string[] {
71
- return [...userConfigPaths(home), ...projectConfigPaths(cwd)]
72
- }
73
-
74
- export function loadConfig(cwd: string): Record<string, ServerConfig> {
75
- return loadConfigFrom(configPaths(cwd, os.homedir()))
76
- }
77
-
78
73
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
79
74
  const servers: Record<string, ServerConfig> = {}
80
75
  for (const file of files) {
@@ -159,13 +159,3 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
159
159
  const agentMap = buildAgentMap(userAgents, projectAgents, scope)
160
160
  return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
161
161
  }
162
-
163
- export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
164
- if (agents.length === 0) return { text: 'none', remaining: 0 }
165
- const listed = agents.slice(0, maxItems)
166
- const remaining = agents.length - listed.length
167
- return {
168
- text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join('; '),
169
- remaining,
170
- }
171
- }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Web transport
3
+ *
4
+ * A single HTTP(S) request pinned to a caller-supplied DNS resolution. Global `fetch`
5
+ * resolves the hostname itself, independently of any prior guard, so a validate-then-fetch
6
+ * SSRF check has a time-of-check/time-of-use gap: a zero-TTL record can answer public to
7
+ * the guard and private to fetch's own lookup. `node:http`/`node:https` accept a `lookup`
8
+ * option, which is the seam that closes the gap: the socket connects to exactly the address
9
+ * the guard validated, while `servername` (SNI, certificate validation) and the `Host`
10
+ * header stay the real hostname, so virtual hosts and TLS still work.
11
+ */
12
+
13
+ import { request as httpRequest } from 'node:http'
14
+ import { request as httpsRequest } from 'node:https'
15
+ import type { LookupFunction } from 'node:net'
16
+ import { Readable } from 'node:stream'
17
+
18
+ export interface TransportOptions {
19
+ signal: AbortSignal
20
+ lookup: LookupFunction
21
+ userAgent: string
22
+ }
23
+
24
+ /** One request, no redirect following (the caller re-validates and re-pins per hop). */
25
+ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
26
+ const request = url.protocol === 'https:' ? httpsRequest : httpRequest
27
+ return new Promise((resolve, reject) => {
28
+ const req = request(
29
+ url,
30
+ {
31
+ method: 'GET',
32
+ headers: { 'User-Agent': opts.userAgent },
33
+ signal: opts.signal,
34
+ lookup: opts.lookup,
35
+ // servername is left to default to url.hostname, so SNI and certificate
36
+ // validation use the real host even though the socket connects to the pinned IP.
37
+ },
38
+ (res) => {
39
+ const headers = new Headers()
40
+ for (const [key, value] of Object.entries(res.headers)) {
41
+ if (typeof value === 'string') headers.set(key, value)
42
+ else if (Array.isArray(value)) headers.set(key, value.join(', '))
43
+ }
44
+ const body = Readable.toWeb(res) as ReadableStream<Uint8Array>
45
+ resolve(new Response(body, { status: res.statusCode ?? 0, headers }))
46
+ },
47
+ )
48
+ req.on('error', reject)
49
+ req.end()
50
+ })
51
+ }
package/extensions/web.ts CHANGED
@@ -6,10 +6,14 @@
6
6
  * Honors the local-only setup: no cloud accounts, plain HTTPS to public web.
7
7
  */
8
8
 
9
+ import type { LookupAddress } from 'node:dns'
9
10
  import { lookup } from 'node:dns/promises'
11
+ import type { LookupFunction } from 'node:net'
10
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
11
13
  import { Type } from 'typebox'
12
14
 
15
+ import { httpFetch } from './web-transport.js'
16
+
13
17
  const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
14
18
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
15
19
  const MAX_FETCH_CHARS = 30_000
@@ -128,15 +132,32 @@ export function isPrivateAddress(ip: string): boolean {
128
132
  return addr.includes(':') ? isPrivateIpv6(addr) : isPrivateIpv4(addr)
129
133
  }
130
134
 
131
- async function assertPublicHost(url: URL): Promise<void> {
135
+ /** A lookup that always yields `addresses`, so the socket cannot resolve the host again. */
136
+ export function pinnedLookup(addresses: LookupAddress[]): LookupFunction {
137
+ return (_hostname, options, callback) => {
138
+ const cb = (typeof options === 'function' ? options : callback) as (err: Error | null, address: unknown, family?: number) => void
139
+ if (typeof options !== 'function' && options.all) return cb(null, addresses)
140
+ const [first] = addresses
141
+ cb(null, first.address, first.family)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolve a host once, reject any private address, and return a lookup pinned to exactly
147
+ * those addresses. Passing that lookup to the transport is what closes the SSRF
148
+ * time-of-check/time-of-use gap: the connection reuses the validated resolution rather
149
+ * than issuing a second, unchecked DNS query that a rebinding record could answer privately.
150
+ */
151
+ async function resolveAndPin(url: URL): Promise<LookupFunction> {
132
152
  const host = url.hostname.replace(/^\[|\]$/g, '')
133
153
  const addresses = await lookup(host, { all: true, verbatim: true })
134
- // An empty list would leave nothing for the loop to reject, so the guard would pass
135
- // vacuously. Schemes without a host (data:, file:) reach here the same way.
154
+ // An empty list would leave nothing to reject, so the guard would pass vacuously.
155
+ // Schemes without a host (data:, file:) reach here the same way.
136
156
  if (addresses.length === 0) throw new Error(`${url.hostname || url.protocol} did not resolve to any address`)
137
157
  for (const { address } of addresses) {
138
158
  if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
139
159
  }
160
+ return pinnedLookup(addresses)
140
161
  }
141
162
 
142
163
  const MAX_REDIRECTS = 5
@@ -156,14 +177,15 @@ async function readCapped(response: Response): Promise<string> {
156
177
  return text.slice(0, MAX_RAW_CHARS)
157
178
  }
158
179
 
159
- async function fetchText(rawUrl: string): Promise<{ text: string; contentType: string }> {
180
+ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text: string; contentType: string }> {
160
181
  let url = new URL(rawUrl)
161
182
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
162
- await assertPublicHost(url)
163
- const response = await fetch(url, {
164
- headers: { 'User-Agent': USER_AGENT },
183
+ // Resolve, validate and pin per hop: a redirect target gets the same guarantee.
184
+ const lookup = await resolveAndPin(url)
185
+ const response = await transport(url, {
165
186
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
166
- redirect: 'manual',
187
+ lookup,
188
+ userAgent: USER_AGENT,
167
189
  })
168
190
  if (response.status >= 300 && response.status < 400) {
169
191
  const location = response.headers.get('location')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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"