@tduyng/pi-subagent 0.0.0-stage → 0.0.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/LICENSE +178 -0
- package/README.md +80 -2
- package/package.json +51 -4
- package/src/bulk-read.ts +147 -0
- package/src/lib/child-runtime.ts +18 -0
- package/src/log-reducer.ts +242 -0
- package/src/redact.ts +17 -0
- package/src/subagent/agent.ts +112 -0
- package/src/subagent/drive.ts +187 -0
- package/src/subagent/durable.ts +271 -0
- package/src/subagent/format.ts +241 -0
- package/src/subagent/guards/cloak.ts +396 -0
- package/src/subagent/guards/git-interceptor.ts +73 -0
- package/src/subagent/helpers.ts +212 -0
- package/src/subagent/index.ts +853 -0
- package/src/subagent/recover.ts +268 -0
- package/src/subagent/session.ts +87 -0
- package/src/subagent/state.ts +63 -0
- package/src/subagent/structured.ts +202 -0
- package/src/subagent/types.ts +109 -0
- package/src/subagent/viewer.ts +184 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
const isOpenObject = new Validator(t.object({}, { additionalProperties: true })).isValidObject
|
|
9
|
+
const isString = new Validator(t.string()).isValidObject
|
|
10
|
+
const isNumber = new Validator(t.number()).isValidObject
|
|
11
|
+
|
|
12
|
+
export const CLOAK_CONFIG_FILE = 'cloak.json'
|
|
13
|
+
|
|
14
|
+
function configPath(): string {
|
|
15
|
+
return join(getAgentDir(), CLOAK_CONFIG_FILE)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function projectConfigPath(): string {
|
|
19
|
+
return join(process.cwd(), '.pi', CLOAK_CONFIG_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CloakPattern {
|
|
23
|
+
pattern: string
|
|
24
|
+
replace?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CloakRule {
|
|
28
|
+
filePattern: string | string[]
|
|
29
|
+
cloakPattern: string | CloakPattern[]
|
|
30
|
+
replace?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CloakConfig {
|
|
34
|
+
enabled?: boolean
|
|
35
|
+
cloakCharacter?: string
|
|
36
|
+
cloakLength?: number | null
|
|
37
|
+
tryAllPatterns?: boolean
|
|
38
|
+
patterns?: CloakRule[]
|
|
39
|
+
vendorKeys?: boolean
|
|
40
|
+
pemBlocks?: boolean
|
|
41
|
+
sensitiveFileTypes?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the file on disk parsed to: the two scalars below are narrowed there, once. */
|
|
45
|
+
interface LoadedConfig extends CloakConfig {
|
|
46
|
+
cloakCharacter: string
|
|
47
|
+
cloakLength?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CompiledRegex {
|
|
51
|
+
regex: RegExp
|
|
52
|
+
replace?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CompiledRule {
|
|
56
|
+
fileRegex: RegExp
|
|
57
|
+
regexes: CompiledRegex[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function globToRegExp(glob: string): RegExp {
|
|
61
|
+
let out = '^'
|
|
62
|
+
for (let index = 0; index < glob.length; index++) {
|
|
63
|
+
const char = glob[index]
|
|
64
|
+
if (char === undefined) break
|
|
65
|
+
const next = glob[index + 1]
|
|
66
|
+
if (char === '*' && next === '*') {
|
|
67
|
+
out += '.*'
|
|
68
|
+
index++
|
|
69
|
+
if (glob[index + 1] === '/') index++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (char === '*') {
|
|
73
|
+
out += '[^/]*'
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
out += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`${out}$`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function compileRule(rule: CloakRule): CompiledRule {
|
|
82
|
+
const filePatterns = Array.isArray(rule.filePattern) ? rule.filePattern : [rule.filePattern]
|
|
83
|
+
const specs: CloakPattern[] = Array.isArray(rule.cloakPattern)
|
|
84
|
+
? rule.cloakPattern
|
|
85
|
+
: [{ pattern: rule.cloakPattern, replace: rule.replace }]
|
|
86
|
+
return {
|
|
87
|
+
fileRegex: new RegExp(filePatterns.map((pattern) => globToRegExp(pattern).source).join('|')),
|
|
88
|
+
regexes: specs.map((spec) => ({ regex: new RegExp(spec.pattern, 'gi'), replace: spec.replace }))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function compileRules(patterns: CloakRule[]): CompiledRule[] {
|
|
93
|
+
const compiled: CompiledRule[] = []
|
|
94
|
+
for (const rule of patterns) {
|
|
95
|
+
try {
|
|
96
|
+
compiled.push(compileRule(rule))
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
return compiled
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readConfigFile(path: string): CloakConfig {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
105
|
+
return isOpenObject(parsed) && !Array.isArray(parsed) ? (parsed as CloakConfig) : {}
|
|
106
|
+
} catch {
|
|
107
|
+
return {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mergeCloakConfig(base: CloakConfig, project: CloakConfig): CloakConfig {
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
patterns: [
|
|
115
|
+
...(Array.isArray(base.patterns) ? base.patterns : []),
|
|
116
|
+
...(Array.isArray(project.patterns) ? project.patterns : [])
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadConfig(): LoadedConfig {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = mergeCloakConfig(readConfigFile(configPath()), readConfigFile(projectConfigPath()))
|
|
124
|
+
const length = parsed.cloakLength
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
enabled: true,
|
|
128
|
+
...parsed,
|
|
129
|
+
cloakCharacter: isString(parsed.cloakCharacter) ? parsed.cloakCharacter : '*',
|
|
130
|
+
cloakLength: isNumber(length) && Number.isFinite(length) && length >= 0 ? length : undefined,
|
|
131
|
+
patterns: Array.isArray(parsed.patterns) ? parsed.patterns : []
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
return { enabled: true, cloakCharacter: '*', patterns: [] }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mask(match: string, cloakChar: string, cloakLength: number | null | undefined): string {
|
|
139
|
+
const visible = match.slice(0, 1)
|
|
140
|
+
const target = cloakLength ?? Math.max(match.length, 1)
|
|
141
|
+
return visible + cloakChar.repeat(Math.max(0, target - 1))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface Match {
|
|
145
|
+
start: number
|
|
146
|
+
end: number
|
|
147
|
+
replacement: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface VendorPattern {
|
|
151
|
+
name: string
|
|
152
|
+
prefix: string
|
|
153
|
+
tail: RegExp
|
|
154
|
+
wordBoundary: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const VENDOR: VendorPattern[] = [
|
|
158
|
+
{ name: 'AWS Access Key', prefix: 'AKIA', tail: /^AKIA[A-Z0-9]{16}\b/, wordBoundary: true },
|
|
159
|
+
{ name: 'AWS Temp Access Key', prefix: 'ASIA', tail: /^ASIA[A-Z0-9]{16}\b/, wordBoundary: true },
|
|
160
|
+
{ name: 'Bearer Token', prefix: 'Bearer', tail: /^Bearer\s+[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
|
|
161
|
+
{ name: 'OpenAI/Anthropic API Key', prefix: 'sk-', tail: /^sk-[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
|
|
162
|
+
{ name: 'Stripe Live Key', prefix: 'sk_live_', tail: /^sk_live_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
|
|
163
|
+
{ name: 'Stripe Test Key', prefix: 'sk_test_', tail: /^sk_test_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
|
|
164
|
+
{ name: 'GitHub Token', prefix: 'gh', tail: /^gh[pousr]_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
|
|
165
|
+
{
|
|
166
|
+
name: 'GitHub Fine-grained PAT',
|
|
167
|
+
prefix: 'github_pat_',
|
|
168
|
+
tail: /^github_pat_[a-zA-Z0-9_]{20,}\b/,
|
|
169
|
+
wordBoundary: true
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'JWT',
|
|
173
|
+
prefix: 'eyJ',
|
|
174
|
+
tail: /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
|
175
|
+
wordBoundary: true
|
|
176
|
+
},
|
|
177
|
+
{ name: 'Slack Token', prefix: 'xoxb-', tail: /^xox[baprs]-[A-Za-z0-9-]{20,}\b/, wordBoundary: true },
|
|
178
|
+
{ name: 'GitLab Token', prefix: 'glpat-', tail: /^glpat-[A-Za-z0-9_-]{20,}\b/, wordBoundary: true },
|
|
179
|
+
{ name: 'Google API Key', prefix: 'AIza', tail: /^AIza[A-Za-z0-9_-]{35}\b/, wordBoundary: true },
|
|
180
|
+
{ name: 'npm Token', prefix: 'npm_', tail: /^npm_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
|
|
181
|
+
{
|
|
182
|
+
name: 'SendGrid API Key',
|
|
183
|
+
prefix: 'SG.',
|
|
184
|
+
tail: /^SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{32,}\b/,
|
|
185
|
+
wordBoundary: true
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
function isWordChar(c: string): boolean {
|
|
190
|
+
const code = c.charCodeAt(0)
|
|
191
|
+
return (
|
|
192
|
+
(code >= 0x30 && code <= 0x39) ||
|
|
193
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
194
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
195
|
+
code === 0x5f
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildMarker(value: string, type: string): string {
|
|
200
|
+
const prefix = value.slice(0, 4)
|
|
201
|
+
const stars = '*'.repeat(Math.min(20, Math.max(8, value.length - 4)))
|
|
202
|
+
return `${prefix}${stars}[REDACTED:${type}]`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function vendorScan(text: string): Match[] {
|
|
206
|
+
const matches: Match[] = []
|
|
207
|
+
for (const { name, prefix, tail, wordBoundary } of VENDOR) {
|
|
208
|
+
let idx = 0
|
|
209
|
+
while (idx < text.length) {
|
|
210
|
+
const found = text.indexOf(prefix, idx)
|
|
211
|
+
if (found === -1) break
|
|
212
|
+
if (wordBoundary) {
|
|
213
|
+
const prev = found > 0 ? text[found - 1] : ''
|
|
214
|
+
if (prev && isWordChar(prev)) {
|
|
215
|
+
idx = found + 1
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const window = text.slice(found)
|
|
220
|
+
const m = tail.exec(window)
|
|
221
|
+
if (!m) {
|
|
222
|
+
idx = found + 1
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
const start = found
|
|
226
|
+
const end = found + m[0].length
|
|
227
|
+
matches.push({ start, end, replacement: buildMarker(m[0], name) })
|
|
228
|
+
idx = end
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return matches
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const PEM_BLOCK_REGEX = /-----BEGIN ([A-Z ]+)-----\r?\n([\s\S]*?)(?:\r?\n-----END \1-----|$)/g
|
|
235
|
+
|
|
236
|
+
export function pemScan(text: string): Match[] {
|
|
237
|
+
const matches: Match[] = []
|
|
238
|
+
PEM_BLOCK_REGEX.lastIndex = 0
|
|
239
|
+
let m: RegExpExecArray | null
|
|
240
|
+
while ((m = PEM_BLOCK_REGEX.exec(text)) !== null) {
|
|
241
|
+
matches.push({ start: m.index, end: m.index + m[0].length, replacement: `[REDACTED:PEM ${m[1] ?? ''}]` })
|
|
242
|
+
}
|
|
243
|
+
return matches
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A quoted JSON field whose name is a credential word, and whose value is shaped like one:
|
|
248
|
+
* no whitespace, at least 16 characters, and a digit. Without the shape the name alone
|
|
249
|
+
* masks `"tokenizers": "0.22.2"` and `"token": "ts-node -T -s scripts/cli/ts/token"`,
|
|
250
|
+
* which is most of a package.json.
|
|
251
|
+
*/
|
|
252
|
+
const CREDENTIAL_FIELD_REGEX =
|
|
253
|
+
/("(?:[a-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key)[a-z0-9_-]*)"\s*:\s*")(?=[^"\s]{16,}")(?=[^"]*\d)[^"]+"/gi
|
|
254
|
+
|
|
255
|
+
export function credentialFieldScan(text: string): Match[] {
|
|
256
|
+
const matches: Match[] = []
|
|
257
|
+
CREDENTIAL_FIELD_REGEX.lastIndex = 0
|
|
258
|
+
let m: RegExpExecArray | null
|
|
259
|
+
while ((m = CREDENTIAL_FIELD_REGEX.exec(text)) !== null) {
|
|
260
|
+
const start = m.index + m[1]!.length
|
|
261
|
+
matches.push({ start, end: m.index + m[0].length - 1, replacement: '[REDACTED:credential field]' })
|
|
262
|
+
}
|
|
263
|
+
return matches
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyMatches(text: string, matches: Match[]): string {
|
|
267
|
+
if (matches.length === 0) return text
|
|
268
|
+
const sorted = matches.toSorted((a, b) => a.start - b.start)
|
|
269
|
+
const parts: string[] = []
|
|
270
|
+
let cursor = 0
|
|
271
|
+
for (const m of sorted) {
|
|
272
|
+
if (m.start < cursor) continue
|
|
273
|
+
if (m.start > cursor) parts.push(text.slice(cursor, m.start))
|
|
274
|
+
parts.push(m.replacement)
|
|
275
|
+
cursor = m.end
|
|
276
|
+
}
|
|
277
|
+
if (cursor < text.length) parts.push(text.slice(cursor))
|
|
278
|
+
return parts.join('')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const SENSITIVE_PATH_PATTERNS: { pattern: RegExp; type: string }[] = [
|
|
282
|
+
{ pattern: /\.pem$/i, type: 'PEM File' },
|
|
283
|
+
{ pattern: /\.crt$/i, type: 'Certificate File' },
|
|
284
|
+
{ pattern: /\.cer$/i, type: 'Certificate File' },
|
|
285
|
+
{ pattern: /\.p12$/i, type: 'PKCS12 File' },
|
|
286
|
+
{ pattern: /\.pfx$/i, type: 'PKCS12 File' },
|
|
287
|
+
{ pattern: /(^|\/)\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i, type: 'SSH Key' },
|
|
288
|
+
{ pattern: /\.ssh\/known_hosts$/i, type: 'SSH Known Hosts' },
|
|
289
|
+
{ pattern: /\.ssh\/authorized_keys$/i, type: 'SSH Authorized Keys' },
|
|
290
|
+
{ pattern: /\.aws\/credentials$/i, type: 'AWS Credentials' },
|
|
291
|
+
{ pattern: /\.npmrc$/i, type: 'npm Config' },
|
|
292
|
+
{ pattern: /\.pypirc$/i, type: 'PyPI Config' },
|
|
293
|
+
{ pattern: /\.netrc$/i, type: 'Netrc File' },
|
|
294
|
+
{ pattern: /\.docker\/config\.json$/i, type: 'Docker Config' },
|
|
295
|
+
{ pattern: /\.kube\/config$/i, type: 'Kube Config' },
|
|
296
|
+
{ pattern: /(?:^|\/)\.env(?:\.[\w-]+)?$/i, type: 'Environment File' }
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
export function matchSensitivePath(path: string): string | null {
|
|
300
|
+
for (const { pattern, type } of SENSITIVE_PATH_PATTERNS) if (pattern.test(path)) return type
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Canonical copy; pi-subagent/src/redact.ts mirrors it so that package stands alone.
|
|
305
|
+
const SIDE_MODEL_REDACTIONS: Array<[RegExp, string]> = [
|
|
306
|
+
[
|
|
307
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
308
|
+
'[redacted private key block]'
|
|
309
|
+
],
|
|
310
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted aws access key]'],
|
|
311
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, '[redacted api key]'],
|
|
312
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
|
|
313
|
+
[/^(\s*(?:export\s+)?[A-Za-z_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z_]*\s*=\s*)("?)[^"]+\2$/gm, '$1$2[redacted]$2']
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
export function redactSecrets(text: string): string {
|
|
317
|
+
let out = text
|
|
318
|
+
for (const [re, replacement] of SIDE_MODEL_REDACTIONS) out = out.replace(re, replacement)
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export default function (pi: ExtensionAPI) {
|
|
323
|
+
const config = loadConfig()
|
|
324
|
+
|
|
325
|
+
pi.registerCommand('cloak-status', {
|
|
326
|
+
description: 'Show cloak config status',
|
|
327
|
+
handler: async (_args, ctx) => {
|
|
328
|
+
const status = config.enabled === false ? 'disabled' : 'enabled'
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
`cloak ${status}, patterns=${(config.patterns ?? []).length}, ` +
|
|
331
|
+
`vendorKeys=${config.vendorKeys !== false}, pemBlocks=${config.pemBlocks !== false}, ` +
|
|
332
|
+
`sensitiveFileTypes=${config.sensitiveFileTypes !== false}`,
|
|
333
|
+
'info'
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (config.enabled === false) return
|
|
339
|
+
const rules = compileRules(config.patterns ?? [])
|
|
340
|
+
const vendorKeysEnabled = config.vendorKeys !== false
|
|
341
|
+
const pemBlocksEnabled = config.pemBlocks !== false
|
|
342
|
+
const sensitiveFileTypesEnabled = config.sensitiveFileTypes !== false
|
|
343
|
+
if (rules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled && !sensitiveFileTypesEnabled) return
|
|
344
|
+
|
|
345
|
+
const cloakChar = config.cloakCharacter
|
|
346
|
+
const cloakLength = config.cloakLength ?? null
|
|
347
|
+
|
|
348
|
+
pi.on('tool_result', (event) => {
|
|
349
|
+
if (!Array.isArray(event.content)) return
|
|
350
|
+
const input = event.input as { path?: string }
|
|
351
|
+
const rawPath = event.toolName === 'read' ? (input.path ?? '') : ''
|
|
352
|
+
|
|
353
|
+
const sensitiveType = sensitiveFileTypesEnabled && rawPath ? matchSensitivePath(rawPath) : null
|
|
354
|
+
const matchingRules = rawPath ? rules.filter((rule) => rule.fileRegex.test(rawPath)) : []
|
|
355
|
+
if (!sensitiveType && matchingRules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled) return
|
|
356
|
+
|
|
357
|
+
let changed = false
|
|
358
|
+
const content = event.content.map((part) => {
|
|
359
|
+
if (part.type !== 'text') return part
|
|
360
|
+
|
|
361
|
+
if (sensitiveType) {
|
|
362
|
+
changed = true
|
|
363
|
+
return { ...part, text: `[REDACTED:Protected File (${sensitiveType})]` }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let text = part.text
|
|
367
|
+
for (const rule of matchingRules) {
|
|
368
|
+
for (const compiled of rule.regexes) {
|
|
369
|
+
const next =
|
|
370
|
+
compiled.replace !== undefined
|
|
371
|
+
? text.replace(compiled.regex, compiled.replace)
|
|
372
|
+
: text.replace(compiled.regex, (match) => mask(match, cloakChar, cloakLength))
|
|
373
|
+
if (next !== text) {
|
|
374
|
+
changed = true
|
|
375
|
+
text = next
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (config.tryAllPatterns === false) break
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const structuralMatches = [
|
|
382
|
+
...(vendorKeysEnabled ? [...vendorScan(text), ...credentialFieldScan(text)] : []),
|
|
383
|
+
...(pemBlocksEnabled ? pemScan(text) : [])
|
|
384
|
+
]
|
|
385
|
+
if (structuralMatches.length > 0) {
|
|
386
|
+
changed = true
|
|
387
|
+
text = applyMatches(text, structuralMatches)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return changed ? { ...part, text } : part
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
if (!changed) return
|
|
394
|
+
return { content }
|
|
395
|
+
})
|
|
396
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Validator } from 'ata-validator'
|
|
3
|
+
import { t } from 'ata-validator/t'
|
|
4
|
+
|
|
5
|
+
const SENSITIVE_COMMAND_RE =
|
|
6
|
+
/(?:^|[\s"'=:~]|\/)\.env(?:\.[A-Za-z0-9_-]+)?(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)(?:[^\s"';&|]*\/)?(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)[^\s"';&|]*\.(?:pem|key|p12|pfx)(?=$|[\s"';&|),\]} ])/i
|
|
7
|
+
|
|
8
|
+
const SENSITIVE_REASON =
|
|
9
|
+
'BLOCKED: shell access to a credential or private-key file is not allowed, and a read of one ' +
|
|
10
|
+
'comes back redacted. Ask the user for the value you need.'
|
|
11
|
+
|
|
12
|
+
const BLOCK_REASON =
|
|
13
|
+
'BLOCKED: --no-verify is not allowed. Git hooks exist for a reason. ' +
|
|
14
|
+
'Do not attempt to bypass them. Instead: fix the underlying issue causing ' +
|
|
15
|
+
'the hook to fail, or ask the user for help.'
|
|
16
|
+
|
|
17
|
+
const CommandInput = t.object({
|
|
18
|
+
command: t.optional(t.string()),
|
|
19
|
+
then_run: t.optional(t.union([t.string(), t.object({ command: t.string() })]))
|
|
20
|
+
})
|
|
21
|
+
const isCommandInput = new Validator(CommandInput).isValidObject
|
|
22
|
+
const isThenRunCommand = new Validator(t.object({ command: t.string() })).isValidObject
|
|
23
|
+
const isThenRunText = new Validator(t.string()).isValidObject
|
|
24
|
+
|
|
25
|
+
function words(segment: string): string[] {
|
|
26
|
+
const found = segment.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g) ?? []
|
|
27
|
+
return found.map((word) => word.replace(/^(["'])(.*)\1$/, '$2'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blocksHooks(command: string): boolean {
|
|
31
|
+
if (!/\bgit\b/.test(command)) return false
|
|
32
|
+
for (const segment of command.split(/[;&|\n]+/)) {
|
|
33
|
+
const tokens = words(segment)
|
|
34
|
+
const git = tokens.findIndex((token) => token === 'git' || token.endsWith('/git'))
|
|
35
|
+
if (git === -1) continue
|
|
36
|
+
const args = tokens.slice(git + 1)
|
|
37
|
+
const commit = args.findIndex((token) => token === 'commit')
|
|
38
|
+
if (commit === -1) continue
|
|
39
|
+
if (args.includes('--no-verify')) return true
|
|
40
|
+
if (args.some((token, index) => token === '-c' && (args[index + 1]?.startsWith('core.hooksPath=') ?? false)))
|
|
41
|
+
return true
|
|
42
|
+
const after = args.slice(commit + 1)
|
|
43
|
+
const message = after.findIndex(
|
|
44
|
+
(token) => token === '-m' || token === '--message' || token.startsWith('--message=')
|
|
45
|
+
)
|
|
46
|
+
const flags = message === -1 ? after : after.slice(0, message)
|
|
47
|
+
if (flags.some((token) => /^-[A-Za-z]*n[A-Za-z]*$/.test(token))) return true
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commands(input: { command?: string; then_run?: string | { command: string } }): string[] {
|
|
53
|
+
const found: string[] = []
|
|
54
|
+
if (input.command !== undefined) found.push(input.command)
|
|
55
|
+
if (isThenRunCommand(input.then_run)) found.push(input.then_run.command)
|
|
56
|
+
else if (isThenRunText(input.then_run)) found.push(input.then_run)
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function (pi: ExtensionAPI) {
|
|
61
|
+
process.env['GIT_EDITOR'] = 'true'
|
|
62
|
+
process.env['GIT_SEQUENCE_EDITOR'] = 'true'
|
|
63
|
+
process.env['GIT_MERGE_AUTOEDIT'] = 'no'
|
|
64
|
+
|
|
65
|
+
pi.on('tool_call', (event) => {
|
|
66
|
+
if (!isCommandInput(event.input)) return
|
|
67
|
+
for (const command of commands(event.input)) {
|
|
68
|
+
if (command.trim() === '') continue
|
|
69
|
+
if (blocksHooks(command)) return { block: true, reason: BLOCK_REASON }
|
|
70
|
+
if (SENSITIVE_COMMAND_RE.test(command)) return { block: true, reason: SENSITIVE_REASON }
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_MAX_TURNS, MAX_CONCURRENT } from './types.js'
|
|
7
|
+
|
|
8
|
+
export const MAX_DELAY_MS = 2_147_483_647
|
|
9
|
+
|
|
10
|
+
export const MIN_TIMEOUT_MS = 30 * 60_000
|
|
11
|
+
|
|
12
|
+
export async function abortableDelay(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
13
|
+
if (signal?.aborted) throw new Error('Subagent aborted.')
|
|
14
|
+
ms = Math.min(ms, MAX_DELAY_MS)
|
|
15
|
+
await new Promise<void>((resolve, reject) => {
|
|
16
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort)
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
cleanup()
|
|
19
|
+
resolve()
|
|
20
|
+
}, ms)
|
|
21
|
+
const onAbort = () => {
|
|
22
|
+
clearTimeout(timer)
|
|
23
|
+
cleanup()
|
|
24
|
+
reject(new Error('Subagent aborted.'))
|
|
25
|
+
}
|
|
26
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveTimeoutMs(ms: number | undefined): number | undefined {
|
|
31
|
+
return ms !== undefined && Number.isFinite(ms) && ms > 0 ? Math.max(ms, MIN_TIMEOUT_MS) : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveMaxTurns(requested: number | undefined, profile: number | undefined): number {
|
|
35
|
+
const valid = (value: number | undefined): value is number =>
|
|
36
|
+
value !== undefined && Number.isFinite(value) && value >= 1
|
|
37
|
+
const fallback = valid(profile) ? profile : DEFAULT_MAX_TURNS
|
|
38
|
+
return Math.floor(valid(requested) ? requested : fallback)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function wrapUpMessage(hasSchema: boolean): string {
|
|
42
|
+
const close = hasSchema
|
|
43
|
+
? 'call `structured_output` now with what you already have'
|
|
44
|
+
: 'reply now with your final answer'
|
|
45
|
+
return `Turn budget spent: this is your last turn. Stop investigating and ${close}, naming what you could not finish.`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function structuredRecoveryMessage(attempt: number): string {
|
|
49
|
+
const lead =
|
|
50
|
+
'You finished without calling the `structured_output` tool. Do not investigate further. Call `structured_output` now, exactly once, with the fields the task requires, using the findings you already have.'
|
|
51
|
+
return attempt <= 1 ? lead : `${lead} The parent cannot use a plain text answer for this task; this is reminder ${attempt}.`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isProviderSchemaError(error: Error | string): boolean {
|
|
55
|
+
const message = error instanceof Error ? error.message : error
|
|
56
|
+
return (
|
|
57
|
+
/tool[_ ]schema|schema.*invalid|invalid[_ ]schema|structured[_ ]output|json_schema|response[_ ]format|response_format/i.test(
|
|
58
|
+
message
|
|
59
|
+
) || /invalid request parameters/i.test(message)
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function shouldRetryWithoutSchema(error: Error | string, hasSchema: boolean): boolean {
|
|
64
|
+
return hasSchema && isProviderSchemaError(error)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RunOutcome {
|
|
68
|
+
hasAssistant: boolean
|
|
69
|
+
timedOut: boolean
|
|
70
|
+
duration: string | undefined
|
|
71
|
+
hitTurnLimit: boolean
|
|
72
|
+
maxTurns: number
|
|
73
|
+
aborted: boolean
|
|
74
|
+
hasSchema: boolean
|
|
75
|
+
hasStructured: boolean
|
|
76
|
+
output: string
|
|
77
|
+
stopReason: string | undefined
|
|
78
|
+
errorMessage: string | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function failureReason(outcome: RunOutcome): string | undefined {
|
|
82
|
+
if (outcome.timedOut) {
|
|
83
|
+
return `Timed out after ${outcome.duration ?? 'the deadline'}. Raise timeoutMs or narrow the task.`
|
|
84
|
+
}
|
|
85
|
+
if (outcome.aborted || (!outcome.hitTurnLimit && outcome.stopReason === 'aborted')) return 'Cancelled'
|
|
86
|
+
if (!outcome.hasAssistant) return 'Child produced no response.'
|
|
87
|
+
if (outcome.stopReason === 'error') return outcome.errorMessage ?? 'Child produced no response.'
|
|
88
|
+
if (outcome.hitTurnLimit && !outcome.output && !outcome.hasStructured) return 'Child produced no response.'
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function salvageOutput(assistant: { content?: unknown } | undefined): string | undefined {
|
|
93
|
+
if (!assistant) return undefined
|
|
94
|
+
const content = assistant.content
|
|
95
|
+
if (!Array.isArray(content)) return undefined
|
|
96
|
+
const text = content
|
|
97
|
+
.filter((part): part is { type: 'text'; text: string } => {
|
|
98
|
+
if (!part) return false
|
|
99
|
+
const p = part as { type: unknown; text: unknown }
|
|
100
|
+
return p.type === 'text' && p.text != null
|
|
101
|
+
})
|
|
102
|
+
.map((part) => String(part.text))
|
|
103
|
+
.join('')
|
|
104
|
+
.trim()
|
|
105
|
+
return text || undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function salvageFromMessages(messages: readonly unknown[] | undefined): string | undefined {
|
|
109
|
+
if (!messages) return undefined
|
|
110
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
111
|
+
const message = messages[index] as { role?: unknown; content?: unknown } | undefined
|
|
112
|
+
if (!message || message.role !== 'assistant') continue
|
|
113
|
+
const text = salvageOutput(message)
|
|
114
|
+
if (text) return text
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function childPromptExtras(
|
|
120
|
+
model: { baseUrl?: string } | undefined,
|
|
121
|
+
parentPrompt: string,
|
|
122
|
+
schemaInstruction: string | undefined
|
|
123
|
+
): string[] {
|
|
124
|
+
const inherited = model?.baseUrl === 'claude-bridge' ? parentPrompt.trim() : ''
|
|
125
|
+
return [inherited, schemaInstruction].filter((part): part is string => Boolean(part))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function validateCwd(cwd: string): Promise<void> {
|
|
129
|
+
let info
|
|
130
|
+
try {
|
|
131
|
+
info = await stat(cwd)
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(`Subagent working directory does not exist: ${cwd}`)
|
|
134
|
+
}
|
|
135
|
+
if (!info.isDirectory()) throw new Error(`Subagent working directory is not a directory: ${cwd}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const execFileAsync = promisify(execFile)
|
|
139
|
+
|
|
140
|
+
export async function createWorktree(repo: string, target: string): Promise<void> {
|
|
141
|
+
try {
|
|
142
|
+
await execFileAsync('git', ['-C', repo, 'worktree', 'add', '-b', worktreeBranch(target), target, 'HEAD'])
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
145
|
+
throw new Error(`Could not create a worktree under ${repo}: ${reason}`, { cause: error })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The branch a worktree at `target` commits to, named after the task directory holding it. */
|
|
150
|
+
export function worktreeBranch(target: string): string {
|
|
151
|
+
return `pi-task-${basename(dirname(target))}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function gitStatus(repo: string): Promise<string> {
|
|
155
|
+
return execFileAsync('git', ['-C', repo, 'status', '--porcelain']).then(
|
|
156
|
+
(result) => result.stdout,
|
|
157
|
+
() => ''
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function changedPaths(status: string): string[] {
|
|
162
|
+
return status
|
|
163
|
+
.split('\n')
|
|
164
|
+
.filter((line) => line.trim())
|
|
165
|
+
.map((line) => {
|
|
166
|
+
const arrow = line.indexOf(' -> ')
|
|
167
|
+
const raw = arrow >= 0 ? line.slice(arrow + 4) : line.slice(3)
|
|
168
|
+
return raw.trim().replace(/^"|"$/g, '')
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let permitsInUse = 0
|
|
173
|
+
const permitWaiters: Array<() => void> = []
|
|
174
|
+
|
|
175
|
+
export async function acquirePermit(signal: AbortSignal | undefined): Promise<void> {
|
|
176
|
+
if (signal?.aborted) throw new Error('Subagent aborted while queued.')
|
|
177
|
+
if (permitsInUse < MAX_CONCURRENT) {
|
|
178
|
+
permitsInUse++
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
await new Promise<void>((resolve, reject) => {
|
|
182
|
+
const waiter = () => {
|
|
183
|
+
signal?.removeEventListener('abort', onAbort)
|
|
184
|
+
permitsInUse++
|
|
185
|
+
resolve()
|
|
186
|
+
}
|
|
187
|
+
const onAbort = () => {
|
|
188
|
+
const index = permitWaiters.indexOf(waiter)
|
|
189
|
+
if (index >= 0) permitWaiters.splice(index, 1)
|
|
190
|
+
reject(new Error('Subagent aborted while queued.'))
|
|
191
|
+
}
|
|
192
|
+
permitWaiters.push(waiter)
|
|
193
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function releasePermit(): void {
|
|
198
|
+
permitsInUse--
|
|
199
|
+
permitWaiters.shift()?.()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function findDuplicateTask(tasks: readonly string[]): { first: number; second: number } | undefined {
|
|
203
|
+
const seen = new Map<string, number>()
|
|
204
|
+
for (let index = 0; index < tasks.length; index++) {
|
|
205
|
+
const key = (tasks[index] ?? '').trim().replace(/\s+/g, ' ').toLowerCase()
|
|
206
|
+
if (!key) continue
|
|
207
|
+
const first = seen.get(key)
|
|
208
|
+
if (first !== undefined) return { first, second: index }
|
|
209
|
+
seen.set(key, index)
|
|
210
|
+
}
|
|
211
|
+
return undefined
|
|
212
|
+
}
|