pi-code 0.2.1 → 0.2.3
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 +56 -14
- package/extensions/plan-mode/utils.ts +11 -8
- package/extensions/subagent/agents.ts +23 -0
- package/extensions/subagent/index.ts +6 -2
- package/extensions/web-transport.ts +51 -0
- package/extensions/web.ts +30 -8
- package/package.json +1 -1
|
@@ -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,7 +21,7 @@
|
|
|
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'
|
|
@@ -50,6 +50,8 @@ export interface HookRunResult {
|
|
|
50
50
|
code: number
|
|
51
51
|
stdout: string
|
|
52
52
|
stderr: string
|
|
53
|
+
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
54
|
+
timedOut: boolean
|
|
53
55
|
}
|
|
54
56
|
export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
55
57
|
|
|
@@ -112,27 +114,64 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
112
114
|
return { block: false }
|
|
113
115
|
}
|
|
114
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
|
+
|
|
115
140
|
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
116
141
|
new Promise((resolve) => {
|
|
117
142
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
118
|
-
|
|
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 })
|
|
119
146
|
let stdout = ''
|
|
120
147
|
let stderr = ''
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
child.stderr?.on('data', (chunk) => {
|
|
126
|
-
stderr += chunk
|
|
127
|
-
})
|
|
128
|
-
child.on('close', (code) => {
|
|
148
|
+
let settled = false
|
|
149
|
+
const finish = (result: HookRunResult): void => {
|
|
150
|
+
if (settled) return
|
|
151
|
+
settled = true
|
|
129
152
|
clearTimeout(timer)
|
|
130
|
-
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
|
|
131
169
|
})
|
|
132
|
-
child.on('
|
|
133
|
-
|
|
134
|
-
resolve({ code: 0, stdout, stderr })
|
|
170
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
171
|
+
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
135
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 }))
|
|
136
175
|
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
137
176
|
// so ignore EPIPE on this write rather than crashing the host process.
|
|
138
177
|
child.stdin?.on('error', () => {})
|
|
@@ -147,6 +186,9 @@ function timeoutMs(command: HookCommand): number {
|
|
|
147
186
|
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
|
|
148
187
|
for (const command of matchingCommands(config.PreToolUse, toolName)) {
|
|
149
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}` }
|
|
150
192
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
151
193
|
if (decision.block) return decision
|
|
152
194
|
}
|
|
@@ -102,6 +102,14 @@ const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
|
102
102
|
*
|
|
103
103
|
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
104
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
|
+
|
|
105
113
|
function splitSegments(command: string): string[] {
|
|
106
114
|
const segments: string[] = []
|
|
107
115
|
let current = ''
|
|
@@ -123,16 +131,11 @@ function splitSegments(command: string): string[] {
|
|
|
123
131
|
current += ch + command[++i]
|
|
124
132
|
continue
|
|
125
133
|
}
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
segments.push(current)
|
|
129
|
-
current = ''
|
|
130
|
-
i++
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
|
|
134
|
+
const separator = separatorAt(command, i)
|
|
135
|
+
if (separator > 0) {
|
|
134
136
|
segments.push(current)
|
|
135
137
|
current = ''
|
|
138
|
+
i += separator - 1
|
|
136
139
|
continue
|
|
137
140
|
}
|
|
138
141
|
current += ch
|
|
@@ -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
|
|
@@ -488,12 +488,16 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
488
488
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
489
489
|
const approved = await isProjectApproved(ctx)
|
|
490
490
|
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
491
|
-
|
|
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(', ')
|
|
492
494
|
if (gate === 'refuse') {
|
|
493
495
|
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
|
494
496
|
}
|
|
495
497
|
if (gate === 'confirm') {
|
|
496
|
-
|
|
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)'
|
|
497
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.`)
|
|
498
502
|
if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
|
|
499
503
|
}
|
|
@@ -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
|
-
|
|
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
|
|
135
|
-
//
|
|
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
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.3",
|
|
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"
|