pantheon-opencode 1.6.0-beta.6 → 1.6.0-beta.7
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/AGENTS.md +27 -820
- package/CHANGELOG.md +116 -0
- package/README.md +132 -39
- package/README.pt-BR.md +47 -23
- package/package.json +6 -1
- package/plugin.json +1 -1
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +162 -28
- package/scripts/doctor.mjs +104 -22
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +154 -7
- package/scripts/install/opencode.mjs +202 -40
- package/scripts/install/shared.mjs +115 -7
- package/scripts/install/strings.mjs +8 -0
- package/scripts/probe-pantheon-cost.mjs +16 -3
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +820 -86
- package/skills-lock.json +6 -5
- package/src/agents/aphrodite.md +17 -28
- package/src/agents/demeter.md +8 -18
- package/src/agents/hephaestus.md +10 -11
- package/src/agents/hermes.md +15 -30
- package/src/agents/prometheus.md +16 -10
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +4 -3
- package/src/agents/zeus.md +16 -280
- package/src/instructions/agent-return-format.instructions.md +4 -50
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -121
- package/src/instructions/zeus-anti-stall.instructions.md +8 -111
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
- package/src/pantheon/secret-scanner.ts +489 -0
- package/src/pantheon/v2-delegation-enforce.ts +86 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +254 -102
- package/src/plugins/pantheon-hooks.ts +82 -35
- package/src/plugins/tui/README.md +14 -0
- package/src/plugins/tui/dist/tui.d.ts +11 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -62
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +163 -107
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +12 -9
- package/src/skills/README.md +9 -4
- package/src/skills/agent-coordination/SKILL.md +13 -83
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +37 -27
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/council-synthesis/SKILL.md +29 -0
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/scripts/hooks/scan-secrets.sh +0 -97
- package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { types as utilTypes } from 'node:util'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-process runtime secret scanner shared by the OpenCode V1 and V2 hooks.
|
|
5
|
+
*
|
|
6
|
+
* Patterns preserve the former shell scanner semantics: high-confidence
|
|
7
|
+
* formats deny, while key/header names and generic assignments are advisory.
|
|
8
|
+
* Findings contain only the former scanner's masked representation;
|
|
9
|
+
* neither input nor raw matches are returned or logged here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SecretScanFinding = {
|
|
13
|
+
confidence: 'high' | 'low'
|
|
14
|
+
pattern: string
|
|
15
|
+
masked: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SecretScanResult =
|
|
19
|
+
| { status: 'clean' }
|
|
20
|
+
| { status: 'invalid' }
|
|
21
|
+
| { status: 'block'; findings: SecretScanFinding[] }
|
|
22
|
+
| { status: 'advisory'; findings: SecretScanFinding[] }
|
|
23
|
+
|
|
24
|
+
/** Maximum UTF-8 size of the serialized scanner input (5 MiB). */
|
|
25
|
+
export const MAX_SECRET_SCAN_INPUT_BYTES = 5 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
type SecretPattern = {
|
|
28
|
+
name: string
|
|
29
|
+
confidence: 'high' | 'low'
|
|
30
|
+
regex: RegExp
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bifrostTokenPrefix = ['sk', '-bf-'].join('')
|
|
34
|
+
const bifrostHeader = ['x', '-bf-', 'vk'].join('')
|
|
35
|
+
const notLineFeedWhitespace = String.raw`[^\S\n]`
|
|
36
|
+
const IDENTIFIER_TOKEN_RE = /[A-Za-z0-9_-]+/g
|
|
37
|
+
const WHITESPACE_RE = /\s/
|
|
38
|
+
|
|
39
|
+
// Compile all runtime detection expressions once when this shared module loads.
|
|
40
|
+
// Do not add the repository-hygiene patterns from scripts/secret-scan.mjs here.
|
|
41
|
+
// The expressions use bounded character classes and fixed separators; none has
|
|
42
|
+
// nested ambiguous quantifiers. The 5 MiB input cap also bounds their linear
|
|
43
|
+
// scans and the assignment/JWT parsing loops below.
|
|
44
|
+
const SECRET_PATTERNS: SecretPattern[] = [
|
|
45
|
+
{ name: 'aws-access-key', confidence: 'high', regex: /AKIA[0-9A-Z]{16}/i },
|
|
46
|
+
{ name: 'github-token', confidence: 'high', regex: /gh[pousr]_[A-Za-z0-9_]{36,}/i },
|
|
47
|
+
{ name: 'gitlab-token', confidence: 'high', regex: /glpat-[A-Za-z0-9_-]{20}/i },
|
|
48
|
+
{ name: 'api-token', confidence: 'high', regex: /sk-[A-Za-z0-9]{20,}/i },
|
|
49
|
+
{ name: 'live-api-token', confidence: 'high', regex: /sk_live_[A-Za-z0-9]{20,}/i },
|
|
50
|
+
{ name: 'test-api-token', confidence: 'high', regex: /sk_test_[A-Za-z0-9]{20,}/i },
|
|
51
|
+
{
|
|
52
|
+
name: 'messaging-token',
|
|
53
|
+
confidence: 'high',
|
|
54
|
+
regex: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[A-Za-z0-9]{24}/i,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'bearer-token',
|
|
58
|
+
confidence: 'high',
|
|
59
|
+
regex: new RegExp(`bearer${notLineFeedWhitespace}+[A-Za-z0-9_.-]{20,}`, 'i'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'jwt',
|
|
63
|
+
confidence: 'high',
|
|
64
|
+
regex: /eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/i,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'bifrost-token',
|
|
68
|
+
confidence: 'high',
|
|
69
|
+
regex: new RegExp(`${bifrostTokenPrefix}[A-Za-z0-9_-]{8,}`, 'i'),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'api-key-assignment',
|
|
73
|
+
confidence: 'low',
|
|
74
|
+
regex: /api[_-]?key/i,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'password-assignment',
|
|
78
|
+
confidence: 'low',
|
|
79
|
+
regex: /password/i,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'secret-assignment',
|
|
83
|
+
confidence: 'low',
|
|
84
|
+
regex: /secret/i,
|
|
85
|
+
},
|
|
86
|
+
{ name: 'bifrost-header', confidence: 'low', regex: new RegExp(bifrostHeader, 'i') },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
export const SECRET_SCAN_PATTERN_COUNT = SECRET_PATTERNS.length
|
|
90
|
+
|
|
91
|
+
const MAX_SECRET_SCAN_JSON_DEPTH = 512
|
|
92
|
+
|
|
93
|
+
type JsonPreflightFrame =
|
|
94
|
+
| { kind: 'value'; value: unknown; depth: number }
|
|
95
|
+
| {
|
|
96
|
+
kind: 'array'
|
|
97
|
+
value: unknown[]
|
|
98
|
+
index: number
|
|
99
|
+
length: number
|
|
100
|
+
depth: number
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
kind: 'object'
|
|
104
|
+
value: Record<string, unknown>
|
|
105
|
+
keys: IterableIterator<string>
|
|
106
|
+
first: boolean
|
|
107
|
+
depth: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function serializedStringBytes(value: string, remainingBytes: number): number | undefined {
|
|
111
|
+
let bytes = 2 // The JSON quotes.
|
|
112
|
+
if (bytes > remainingBytes) return undefined
|
|
113
|
+
|
|
114
|
+
for (let index = 0; index < value.length; index++) {
|
|
115
|
+
const code = value.charCodeAt(index)
|
|
116
|
+
if (
|
|
117
|
+
code === 0x22 ||
|
|
118
|
+
code === 0x5c ||
|
|
119
|
+
code === 0x08 ||
|
|
120
|
+
code === 0x09 ||
|
|
121
|
+
code === 0x0a ||
|
|
122
|
+
code === 0x0c ||
|
|
123
|
+
code === 0x0d
|
|
124
|
+
) {
|
|
125
|
+
bytes += 2
|
|
126
|
+
} else if (code < 0x20) {
|
|
127
|
+
bytes += 6
|
|
128
|
+
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
129
|
+
const next = value.charCodeAt(index + 1)
|
|
130
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
131
|
+
bytes += 4
|
|
132
|
+
index++
|
|
133
|
+
} else {
|
|
134
|
+
// Well-formed JSON.stringify escapes unpaired surrogates as \udxxx.
|
|
135
|
+
bytes += 6
|
|
136
|
+
}
|
|
137
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
138
|
+
bytes += 6
|
|
139
|
+
} else if (code <= 0x7f) {
|
|
140
|
+
bytes++
|
|
141
|
+
} else if (code <= 0x7ff) {
|
|
142
|
+
bytes += 2
|
|
143
|
+
} else {
|
|
144
|
+
bytes += 3
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (bytes > remainingBytes) return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return bytes
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ownEnumerableKeys(value: Record<string, unknown>): IterableIterator<string> {
|
|
154
|
+
return (function* (): IterableIterator<string> {
|
|
155
|
+
for (const key in value) {
|
|
156
|
+
if (Object.hasOwn(value, key)) yield key
|
|
157
|
+
}
|
|
158
|
+
})()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Measure JSON.stringify's compact UTF-8 output without creating serialized
|
|
163
|
+
* strings. Returns undefined for oversized or non-plain JSON data.
|
|
164
|
+
*/
|
|
165
|
+
function measureJsonBytes(value: unknown, limit: number): number | undefined {
|
|
166
|
+
let bytes = 0
|
|
167
|
+
const active = new WeakSet<object>()
|
|
168
|
+
const stack: JsonPreflightFrame[] = [{ kind: 'value', value, depth: 0 }]
|
|
169
|
+
|
|
170
|
+
while (stack.length > 0) {
|
|
171
|
+
const frame = stack[stack.length - 1]
|
|
172
|
+
if (frame === undefined) return undefined
|
|
173
|
+
|
|
174
|
+
if (frame.kind === 'value') {
|
|
175
|
+
stack.pop()
|
|
176
|
+
const current = frame.value
|
|
177
|
+
if (current === null) {
|
|
178
|
+
bytes += 4
|
|
179
|
+
} else if (typeof current === 'string') {
|
|
180
|
+
const stringBytes = serializedStringBytes(current, limit - bytes)
|
|
181
|
+
if (stringBytes === undefined) return undefined
|
|
182
|
+
bytes += stringBytes
|
|
183
|
+
} else if (typeof current === 'boolean') {
|
|
184
|
+
bytes += current ? 4 : 5
|
|
185
|
+
} else if (typeof current === 'number') {
|
|
186
|
+
if (!Number.isFinite(current)) return undefined
|
|
187
|
+
// Number#toString uses JSON's finite-number spelling, including -0 => 0.
|
|
188
|
+
bytes += String(current).length
|
|
189
|
+
} else if (typeof current !== 'object' || utilTypes.isProxy(current)) {
|
|
190
|
+
return undefined
|
|
191
|
+
} else {
|
|
192
|
+
if (frame.depth >= MAX_SECRET_SCAN_JSON_DEPTH || active.has(current)) return undefined
|
|
193
|
+
|
|
194
|
+
const prototype = Object.getPrototypeOf(current)
|
|
195
|
+
const isArray = Array.isArray(current)
|
|
196
|
+
if (
|
|
197
|
+
(isArray && prototype !== Array.prototype) ||
|
|
198
|
+
(!isArray && prototype !== Object.prototype && prototype !== null)
|
|
199
|
+
) {
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// JSON.stringify invokes toJSON even when it is non-enumerable. Reject
|
|
204
|
+
// custom hooks on either the value or its otherwise-plain prototype.
|
|
205
|
+
if (
|
|
206
|
+
Object.getOwnPropertyDescriptor(current, 'toJSON') !== undefined ||
|
|
207
|
+
(prototype !== null && Object.getOwnPropertyDescriptor(prototype, 'toJSON') !== undefined)
|
|
208
|
+
) {
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
active.add(current)
|
|
213
|
+
if (isArray) {
|
|
214
|
+
const array = current as unknown[]
|
|
215
|
+
const length = array.length
|
|
216
|
+
bytes++ // [
|
|
217
|
+
// Even the shortest JSON value takes one byte; account for commas
|
|
218
|
+
// and the closing bracket before traversing potentially huge arrays.
|
|
219
|
+
const minimumContentBytes = length === 0 ? 0 : length * 2 - 1
|
|
220
|
+
if (bytes + minimumContentBytes + 1 > limit) return undefined
|
|
221
|
+
stack.push({ kind: 'array', value: array, index: 0, length, depth: frame.depth })
|
|
222
|
+
} else {
|
|
223
|
+
bytes++ // {
|
|
224
|
+
stack.push({
|
|
225
|
+
kind: 'object',
|
|
226
|
+
value: current as Record<string, unknown>,
|
|
227
|
+
keys: ownEnumerableKeys(current as Record<string, unknown>),
|
|
228
|
+
first: true,
|
|
229
|
+
depth: frame.depth,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else if (frame.kind === 'array') {
|
|
234
|
+
if (frame.index === frame.length) {
|
|
235
|
+
bytes++ // ]
|
|
236
|
+
if (bytes > limit) return undefined
|
|
237
|
+
stack.pop()
|
|
238
|
+
active.delete(frame.value)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (frame.index > 0) bytes++ // ,
|
|
243
|
+
const descriptor = Object.getOwnPropertyDescriptor(frame.value, String(frame.index))
|
|
244
|
+
if (
|
|
245
|
+
descriptor === undefined ||
|
|
246
|
+
!descriptor.enumerable ||
|
|
247
|
+
!Object.hasOwn(descriptor, 'value')
|
|
248
|
+
) {
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
frame.index++
|
|
252
|
+
stack.push({ kind: 'value', value: descriptor.value, depth: frame.depth + 1 })
|
|
253
|
+
} else {
|
|
254
|
+
const next = frame.keys.next()
|
|
255
|
+
if (next.done) {
|
|
256
|
+
bytes++ // }
|
|
257
|
+
if (bytes > limit) return undefined
|
|
258
|
+
stack.pop()
|
|
259
|
+
active.delete(frame.value)
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const key = next.value
|
|
264
|
+
const descriptor = Object.getOwnPropertyDescriptor(frame.value, key)
|
|
265
|
+
if (
|
|
266
|
+
descriptor === undefined ||
|
|
267
|
+
!descriptor.enumerable ||
|
|
268
|
+
!Object.hasOwn(descriptor, 'value')
|
|
269
|
+
) {
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const keyBytes = serializedStringBytes(key, limit - bytes - (frame.first ? 1 : 2))
|
|
274
|
+
if (keyBytes === undefined) return undefined
|
|
275
|
+
bytes += (frame.first ? 0 : 1) + keyBytes + 1 // optional comma, key, colon
|
|
276
|
+
frame.first = false
|
|
277
|
+
stack.push({ kind: 'value', value: descriptor.value, depth: frame.depth + 1 })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (bytes > limit) return undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return bytes
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
287
|
+
if (
|
|
288
|
+
typeof value !== 'object' ||
|
|
289
|
+
value === null ||
|
|
290
|
+
Array.isArray(value) ||
|
|
291
|
+
utilTypes.isProxy(value)
|
|
292
|
+
) {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
const prototype = Object.getPrototypeOf(value)
|
|
296
|
+
return prototype === Object.prototype || prototype === null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function maskMatch(match: string): string {
|
|
300
|
+
const characters = Array.from(match)
|
|
301
|
+
if (characters.length <= 8) return '****'
|
|
302
|
+
return `${characters.slice(0, 4).join('')}****${characters.slice(-4).join('')}`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isIdentifierCode(code: number | undefined): boolean {
|
|
306
|
+
return (
|
|
307
|
+
code !== undefined &&
|
|
308
|
+
((code >= 48 && code <= 57) ||
|
|
309
|
+
(code >= 65 && code <= 90) ||
|
|
310
|
+
(code >= 97 && code <= 122) ||
|
|
311
|
+
code === 95 ||
|
|
312
|
+
code === 45)
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function skipAssignmentWhitespace(input: string, index: number): number {
|
|
317
|
+
while (index < input.length && input[index] !== '\n' && WHITESPACE_RE.test(input[index] ?? '')) {
|
|
318
|
+
index++
|
|
319
|
+
}
|
|
320
|
+
return index
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function findAssignmentMatch(
|
|
324
|
+
input: string,
|
|
325
|
+
lowerInput: string,
|
|
326
|
+
pattern: SecretPattern,
|
|
327
|
+
): string | undefined {
|
|
328
|
+
const marker =
|
|
329
|
+
pattern.name === 'api-key-assignment'
|
|
330
|
+
? 'api'
|
|
331
|
+
: pattern.name === 'password-assignment'
|
|
332
|
+
? 'password'
|
|
333
|
+
: 'secret'
|
|
334
|
+
if (!lowerInput.includes(marker)) return undefined
|
|
335
|
+
|
|
336
|
+
IDENTIFIER_TOKEN_RE.lastIndex = 0
|
|
337
|
+
let token = IDENTIFIER_TOKEN_RE.exec(input)
|
|
338
|
+
while (token !== null) {
|
|
339
|
+
if (pattern.regex.test(token[0])) {
|
|
340
|
+
let index = skipAssignmentWhitespace(input, token.index + token[0].length)
|
|
341
|
+
if (input[index] === ':' || input[index] === '=') {
|
|
342
|
+
index = skipAssignmentWhitespace(input, index + 1)
|
|
343
|
+
if (pattern.name === 'api-key-assignment') {
|
|
344
|
+
if (input[index] === '"' || input[index] === "'") index++
|
|
345
|
+
const valueStart = index
|
|
346
|
+
while (isIdentifierCode(input.charCodeAt(index))) index++
|
|
347
|
+
if (index - valueStart >= 16) {
|
|
348
|
+
if (input[index] === '"' || input[index] === "'") index++
|
|
349
|
+
return input.slice(token.index, index)
|
|
350
|
+
}
|
|
351
|
+
} else if (input[index] === '"' || input[index] === "'") {
|
|
352
|
+
index++
|
|
353
|
+
const valueStart = index
|
|
354
|
+
while (
|
|
355
|
+
index < input.length &&
|
|
356
|
+
input[index] !== '\n' &&
|
|
357
|
+
input[index] !== '"' &&
|
|
358
|
+
input[index] !== "'"
|
|
359
|
+
) {
|
|
360
|
+
index++
|
|
361
|
+
}
|
|
362
|
+
if (index - valueStart >= 8 && (input[index] === '"' || input[index] === "'")) {
|
|
363
|
+
return input.slice(token.index, index + 1)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
token = IDENTIFIER_TOKEN_RE.exec(input)
|
|
369
|
+
}
|
|
370
|
+
return undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function findAsciiInsensitive(input: string, needle: string, from: number): number {
|
|
374
|
+
for (let index = from; index <= input.length - needle.length; index++) {
|
|
375
|
+
let matched = true
|
|
376
|
+
for (let offset = 0; offset < needle.length; offset++) {
|
|
377
|
+
const code = input.charCodeAt(index + offset)
|
|
378
|
+
const lowerCode = code >= 65 && code <= 90 ? code + 32 : code
|
|
379
|
+
if (lowerCode !== needle.charCodeAt(offset)) {
|
|
380
|
+
matched = false
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (matched) return index
|
|
385
|
+
}
|
|
386
|
+
return -1
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function startsWithEyJ(input: string, index: number): boolean {
|
|
390
|
+
if (index < 0 || index + 3 > input.length) return false
|
|
391
|
+
const e = input.charCodeAt(index)
|
|
392
|
+
const y = input.charCodeAt(index + 1)
|
|
393
|
+
const j = input.charCodeAt(index + 2)
|
|
394
|
+
return (e === 69 || e === 101) && (y === 89 || y === 121) && (j === 74 || j === 106)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function findJwtMatch(input: string): string | undefined {
|
|
398
|
+
let searchFrom = 0
|
|
399
|
+
while (searchFrom < input.length) {
|
|
400
|
+
const start = findAsciiInsensitive(input, 'eyj', searchFrom)
|
|
401
|
+
if (start < 0) return undefined
|
|
402
|
+
let firstDot = start + 3
|
|
403
|
+
while (isIdentifierCode(input.charCodeAt(firstDot))) firstDot++
|
|
404
|
+
if (input[firstDot] !== '.') {
|
|
405
|
+
// No later prefix inside this base64 segment can reach a period either.
|
|
406
|
+
searchFrom = firstDot + 1
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const secondStart = firstDot + 1
|
|
411
|
+
if (!startsWithEyJ(input, secondStart)) {
|
|
412
|
+
searchFrom = secondStart
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
let secondDot = secondStart + 3
|
|
416
|
+
while (isIdentifierCode(input.charCodeAt(secondDot))) secondDot++
|
|
417
|
+
if (input[secondDot] !== '.') {
|
|
418
|
+
searchFrom = secondDot + 1
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
let end = secondDot + 1
|
|
422
|
+
while (isIdentifierCode(input.charCodeAt(end))) end++
|
|
423
|
+
return input.slice(start, end)
|
|
424
|
+
}
|
|
425
|
+
return undefined
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Scan serialized tool text, returning no raw input or match values. */
|
|
429
|
+
export function scanSecretText(input: unknown): SecretScanResult {
|
|
430
|
+
if (typeof input !== 'string') return { status: 'invalid' }
|
|
431
|
+
// A code-unit precheck avoids doing a full byte count for obviously huge
|
|
432
|
+
// strings. UTF-8 byte size is authoritative for the supported-input limit.
|
|
433
|
+
if (input.length > MAX_SECRET_SCAN_INPUT_BYTES) return { status: 'invalid' }
|
|
434
|
+
if (Buffer.byteLength(input, 'utf8') > MAX_SECRET_SCAN_INPUT_BYTES) {
|
|
435
|
+
return { status: 'invalid' }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const findings: SecretScanFinding[] = []
|
|
439
|
+
const lowerInput = input.toLowerCase()
|
|
440
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
441
|
+
const match =
|
|
442
|
+
pattern.name === 'jwt'
|
|
443
|
+
? findJwtMatch(input)
|
|
444
|
+
: pattern.confidence === 'low' && pattern.name !== 'bifrost-header'
|
|
445
|
+
? findAssignmentMatch(input, lowerInput, pattern)
|
|
446
|
+
: pattern.regex.exec(input)?.[0]
|
|
447
|
+
if (match !== undefined) {
|
|
448
|
+
findings.push({
|
|
449
|
+
confidence: pattern.confidence,
|
|
450
|
+
pattern: pattern.name,
|
|
451
|
+
masked: maskMatch(match),
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (findings.some((finding) => finding.confidence === 'high')) {
|
|
457
|
+
return { status: 'block', findings }
|
|
458
|
+
}
|
|
459
|
+
if (findings.length > 0) return { status: 'advisory', findings }
|
|
460
|
+
return { status: 'clean' }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Serialize the hook payload as the former stdin protocol did, then scan it. */
|
|
464
|
+
export function scanSecretPayload(payload: unknown): SecretScanResult {
|
|
465
|
+
try {
|
|
466
|
+
if (!isPlainObject(payload)) return { status: 'invalid' }
|
|
467
|
+
const toolInput = Object.getOwnPropertyDescriptor(payload, 'tool_input')
|
|
468
|
+
if (
|
|
469
|
+
toolInput === undefined ||
|
|
470
|
+
!toolInput.enumerable ||
|
|
471
|
+
!Object.hasOwn(toolInput, 'value') ||
|
|
472
|
+
!isPlainObject(toolInput.value)
|
|
473
|
+
) {
|
|
474
|
+
return { status: 'invalid' }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const measuredBytes = measureJsonBytes(payload, MAX_SECRET_SCAN_INPUT_BYTES)
|
|
478
|
+
if (measuredBytes === undefined) return { status: 'invalid' }
|
|
479
|
+
|
|
480
|
+
const serialized = JSON.stringify(payload)
|
|
481
|
+
if (typeof serialized !== 'string' || Buffer.byteLength(serialized, 'utf8') !== measuredBytes) {
|
|
482
|
+
return { status: 'invalid' }
|
|
483
|
+
}
|
|
484
|
+
return scanSecretText(serialized)
|
|
485
|
+
} catch {
|
|
486
|
+
// Never expose serialization errors: hostile/custom values can include input.
|
|
487
|
+
return { status: 'invalid' }
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Caller/target policy for V2's permission.evaluate hook. */
|
|
2
|
+
|
|
3
|
+
export const PANTHEON_AGENTS: ReadonlySet<string> = new Set([
|
|
4
|
+
'zeus',
|
|
5
|
+
'athena',
|
|
6
|
+
'apollo',
|
|
7
|
+
'hermes',
|
|
8
|
+
'aphrodite',
|
|
9
|
+
'demeter',
|
|
10
|
+
'themis',
|
|
11
|
+
'prometheus',
|
|
12
|
+
'hephaestus',
|
|
13
|
+
'nyx',
|
|
14
|
+
'gaia',
|
|
15
|
+
'iris',
|
|
16
|
+
'mnemosyne',
|
|
17
|
+
'talos',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
export interface V2PermissionEvent {
|
|
21
|
+
sessionID?: unknown
|
|
22
|
+
resources?: unknown
|
|
23
|
+
/** Optional event identity is checked but never used as authority. */
|
|
24
|
+
agent?: unknown
|
|
25
|
+
/** Permission effect reported by the V2 host event. */
|
|
26
|
+
effect?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthoritativeSession {
|
|
30
|
+
id?: unknown
|
|
31
|
+
agent?: unknown
|
|
32
|
+
parentID?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type V2DelegationDecision = 'allow' | 'deny' | 'passthrough' | 'preserve-deny'
|
|
36
|
+
|
|
37
|
+
function agentName(value: unknown): string | undefined {
|
|
38
|
+
if (typeof value !== 'string') return undefined
|
|
39
|
+
const valueNormalized = value.trim().toLowerCase()
|
|
40
|
+
return valueNormalized.length === 0 ? undefined : valueNormalized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Extract the target from the host's resource string or `{ target }` entry. */
|
|
44
|
+
export function v2ResourceTarget(resource: unknown): string | undefined {
|
|
45
|
+
if (typeof resource === 'string') return agentName(resource)
|
|
46
|
+
if (resource === null || typeof resource !== 'object' || Array.isArray(resource)) return undefined
|
|
47
|
+
return agentName((resource as { target?: unknown }).target)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide whether a permission evaluation is a Pantheon-managed delegation.
|
|
52
|
+
* Native/custom-to-native/custom calls are left entirely to host permissions.
|
|
53
|
+
*/
|
|
54
|
+
export function evaluateV2Delegation(
|
|
55
|
+
event: V2PermissionEvent,
|
|
56
|
+
session: AuthoritativeSession | undefined,
|
|
57
|
+
existingStatus?: unknown,
|
|
58
|
+
): V2DelegationDecision {
|
|
59
|
+
if (existingStatus === 'deny') return 'preserve-deny'
|
|
60
|
+
|
|
61
|
+
if (!Array.isArray(event.resources) || event.resources.length === 0) return 'deny'
|
|
62
|
+
const rawTargets = event.resources.map(v2ResourceTarget)
|
|
63
|
+
if (rawTargets.some((target) => target === undefined)) return 'deny'
|
|
64
|
+
const targets = rawTargets.filter((target): target is string => target !== undefined)
|
|
65
|
+
const pantheonTargets = targets.filter((target) => PANTHEON_AGENTS.has(target))
|
|
66
|
+
if (pantheonTargets.length === 0) return 'passthrough'
|
|
67
|
+
const sessionID = typeof event.sessionID === 'string' ? event.sessionID : undefined
|
|
68
|
+
const caller = agentName(session?.agent)
|
|
69
|
+
if (
|
|
70
|
+
sessionID === undefined ||
|
|
71
|
+
session === undefined ||
|
|
72
|
+
session.id !== sessionID ||
|
|
73
|
+
caller === undefined
|
|
74
|
+
) {
|
|
75
|
+
return 'deny'
|
|
76
|
+
}
|
|
77
|
+
const eventCaller = agentName(event.agent)
|
|
78
|
+
if (eventCaller !== undefined && eventCaller !== caller) return 'deny'
|
|
79
|
+
if (session.parentID !== undefined && session.parentID !== null && session.parentID !== '') {
|
|
80
|
+
return 'deny'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const callerMayTarget = (target: string): boolean =>
|
|
84
|
+
caller === 'zeus' || ((caller === 'athena' || caller === 'hermes') && target === 'apollo')
|
|
85
|
+
return pantheonTargets.every(callerMayTarget) ? 'allow' : 'deny'
|
|
86
|
+
}
|