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.
Files changed (58) hide show
  1. package/AGENTS.md +27 -820
  2. package/CHANGELOG.md +116 -0
  3. package/README.md +132 -39
  4. package/README.pt-BR.md +47 -23
  5. package/package.json +6 -1
  6. package/plugin.json +1 -1
  7. package/pyproject.toml +3 -1
  8. package/scripts/build-agents-md.mjs +162 -28
  9. package/scripts/doctor.mjs +104 -22
  10. package/scripts/install/agents-md.mjs +2 -2
  11. package/scripts/install/config-migration.mjs +154 -7
  12. package/scripts/install/opencode.mjs +202 -40
  13. package/scripts/install/shared.mjs +115 -7
  14. package/scripts/install/strings.mjs +8 -0
  15. package/scripts/probe-pantheon-cost.mjs +16 -3
  16. package/scripts/sync-artifacts.mjs +2 -2
  17. package/scripts/test-opencode-v2-sandbox.sh +820 -86
  18. package/skills-lock.json +6 -5
  19. package/src/agents/aphrodite.md +17 -28
  20. package/src/agents/demeter.md +8 -18
  21. package/src/agents/hephaestus.md +10 -11
  22. package/src/agents/hermes.md +15 -30
  23. package/src/agents/prometheus.md +16 -10
  24. package/src/agents/talos.md +4 -1
  25. package/src/agents/themis.md +4 -3
  26. package/src/agents/zeus.md +16 -280
  27. package/src/instructions/agent-return-format.instructions.md +4 -50
  28. package/src/instructions/backend-standards.instructions.md +2 -0
  29. package/src/instructions/frontend-standards.instructions.md +3 -1
  30. package/src/instructions/memory-protocol.instructions.md +12 -121
  31. package/src/instructions/zeus-anti-stall.instructions.md +8 -111
  32. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  33. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  34. package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
  35. package/src/pantheon/secret-scanner.ts +489 -0
  36. package/src/pantheon/v2-delegation-enforce.ts +86 -0
  37. package/src/pantheon/v2-execute-after.ts +186 -0
  38. package/src/pantheon/v2-unsupported.mjs +0 -23
  39. package/src/plugin-v2.ts +254 -102
  40. package/src/plugins/pantheon-hooks.ts +82 -35
  41. package/src/plugins/tui/README.md +14 -0
  42. package/src/plugins/tui/dist/tui.d.ts +11 -8
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +102 -62
  45. package/src/plugins/tui/package-lock.json +30 -33
  46. package/src/plugins/tui/package.json +5 -1
  47. package/src/plugins/tui/src/index.tsx +163 -107
  48. package/src/plugins/tui/tsdown.config.ts +17 -0
  49. package/src/routing.yml +12 -9
  50. package/src/skills/README.md +9 -4
  51. package/src/skills/agent-coordination/SKILL.md +13 -83
  52. package/src/skills/artifact-management/SKILL.md +12 -5
  53. package/src/skills/auto-continue/SKILL.md +37 -27
  54. package/src/skills/code-review-checklist/SKILL.md +5 -5
  55. package/src/skills/council-synthesis/SKILL.md +29 -0
  56. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  57. package/scripts/hooks/scan-secrets.sh +0 -97
  58. 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
+ }