@tduyng/pi-subagent 0.0.1 → 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-subagent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Delegation to in-process child sessions: a subagent tool with agent profiles and worktree isolation, a side chat, a guard that redirects bulk reads, and a verified log reducer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
package/src/subagent/durable.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { getAgentDir, type SessionManager } from '@earendil-works/pi-coding-agen
|
|
|
5
5
|
import { Validator, type Infer } from 'ata-validator'
|
|
6
6
|
import { t } from 'ata-validator/t'
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
import type { JsonPayload } from './structured.js'
|
|
10
9
|
import { RUNS_DIR, type TaskDetails, type ThinkingLevel } from './types.js'
|
|
11
10
|
|
|
@@ -35,21 +34,14 @@ const DurableTaskSchema = t.object({
|
|
|
35
34
|
schema: t.optional(JsonValueSchema),
|
|
36
35
|
worktree: t.optional(t.string()),
|
|
37
36
|
sessionFile: t.optional(t.string()),
|
|
38
|
-
status: t.union([
|
|
39
|
-
t.literal('queued'),
|
|
40
|
-
t.literal('running'),
|
|
41
|
-
t.literal('completed'),
|
|
42
|
-
t.literal('failed')
|
|
43
|
-
]),
|
|
37
|
+
status: t.union([t.literal('queued'), t.literal('running'), t.literal('completed'), t.literal('failed')]),
|
|
44
38
|
output: t.optional(t.string()),
|
|
45
39
|
error: t.optional(t.string()),
|
|
46
40
|
partial: t.optional(t.boolean()),
|
|
47
41
|
structured: t.optional(JsonValueSchema),
|
|
48
42
|
turnCount: t.optional(t.number()),
|
|
49
43
|
toolCallCount: t.optional(t.number()),
|
|
50
|
-
usage: t.optional(
|
|
51
|
-
t.object({ input: t.number(), output: t.number(), cost: t.number() })
|
|
52
|
-
),
|
|
44
|
+
usage: t.optional(t.object({ input: t.number(), output: t.number(), cost: t.number() })),
|
|
53
45
|
touched: t.optional(t.array(t.string())),
|
|
54
46
|
changes: t.optional(t.array(t.string())),
|
|
55
47
|
startedAt: t.optional(t.number()),
|
|
@@ -203,7 +195,10 @@ export function resolveSessionFile(record: DurableTask): string | undefined {
|
|
|
203
195
|
if (record.sessionFile && existsSync(record.sessionFile)) return record.sessionFile
|
|
204
196
|
try {
|
|
205
197
|
const newest = readdirSync(record.sessionDir)
|
|
206
|
-
.map((file) => ({
|
|
198
|
+
.map((file) => ({
|
|
199
|
+
file: path.join(record.sessionDir, file),
|
|
200
|
+
stat: statSync(path.join(record.sessionDir, file))
|
|
201
|
+
}))
|
|
207
202
|
.filter((entry) => entry.file.endsWith('.jsonl') && entry.stat.size > 0)
|
|
208
203
|
.map((entry) => ({ file: entry.file, at: entry.stat.mtimeMs }))
|
|
209
204
|
.toSorted((a, b) => b.at - a.at)[0]
|
|
@@ -247,14 +242,26 @@ function ownerPid(file: string): number | undefined {
|
|
|
247
242
|
|
|
248
243
|
export function claimRun(sessionId: string, runId: string): boolean {
|
|
249
244
|
const ownerFile = path.join(runDirectory(sessionId, runId), OWNER_FILE)
|
|
245
|
+
const claim = (): boolean => {
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, at: Date.now() }), {
|
|
248
|
+
mode: 0o600,
|
|
249
|
+
flag: 'wx'
|
|
250
|
+
})
|
|
251
|
+
return true
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (claim()) return true
|
|
250
257
|
const pid = ownerPid(ownerFile)
|
|
251
258
|
if (pid !== undefined && pid !== process.pid && pidAlive(pid)) return false
|
|
252
259
|
try {
|
|
253
|
-
|
|
254
|
-
return true
|
|
260
|
+
rmSync(ownerFile, { force: true })
|
|
255
261
|
} catch {
|
|
256
262
|
return false
|
|
257
263
|
}
|
|
264
|
+
return claim()
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
export function releaseClaim(sessionId: string, runId: string): void {
|
|
@@ -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
|
+
}
|
package/src/subagent/session.ts
CHANGED
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
type AgentSession
|
|
9
9
|
} from '@earendil-works/pi-coding-agent'
|
|
10
10
|
|
|
11
|
-
import cloak from '
|
|
12
|
-
import gitInterceptor from '
|
|
11
|
+
import cloak from './guards/cloak.js'
|
|
12
|
+
import gitInterceptor from './guards/git-interceptor.js'
|
|
13
13
|
import { childPromptExtras } from './helpers.js'
|
|
14
14
|
import { makeStructuredOutputTool, structuredOutputInstruction, type JsonPayload } from './structured.js'
|
|
15
15
|
import type { AgentProfile, ThinkingLevel } from './types.js'
|
package/src/subagent/state.ts
CHANGED
|
@@ -26,7 +26,11 @@ export function sweepRegistry(): void {
|
|
|
26
26
|
const finished = task.status === 'completed' || task.status === 'failed'
|
|
27
27
|
if (finished && task.finishedAt !== undefined && now - task.finishedAt > retentionFor(task)) evict(i)
|
|
28
28
|
}
|
|
29
|
-
while (registry.length > MAX_REGISTRY)
|
|
29
|
+
while (registry.length > MAX_REGISTRY) {
|
|
30
|
+
const index = registry.findIndex((task) => task.status === 'completed' || task.status === 'failed')
|
|
31
|
+
if (index === -1) break
|
|
32
|
+
evict(index)
|
|
33
|
+
}
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function nextExpiryAt(): number | undefined {
|