pantheon-opencode 1.6.0-beta.5 → 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 (62) hide show
  1. package/AGENTS.md +27 -820
  2. package/CHANGELOG.md +130 -0
  3. package/README.md +205 -30
  4. package/README.pt-BR.md +47 -23
  5. package/package.json +10 -5
  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/run-node-tests.mjs +189 -0
  17. package/scripts/sync-artifacts.mjs +2 -2
  18. package/scripts/test-opencode-v2-sandbox.sh +820 -86
  19. package/skills-lock.json +6 -5
  20. package/src/agents/aphrodite.md +17 -28
  21. package/src/agents/athena.md +1 -0
  22. package/src/agents/demeter.md +8 -18
  23. package/src/agents/hephaestus.md +10 -11
  24. package/src/agents/hermes.md +15 -30
  25. package/src/agents/iris.md +1 -0
  26. package/src/agents/nyx.md +1 -0
  27. package/src/agents/prometheus.md +16 -10
  28. package/src/agents/talos.md +4 -1
  29. package/src/agents/themis.md +5 -3
  30. package/src/agents/zeus.md +17 -280
  31. package/src/instructions/agent-return-format.instructions.md +4 -50
  32. package/src/instructions/backend-standards.instructions.md +2 -0
  33. package/src/instructions/frontend-standards.instructions.md +3 -1
  34. package/src/instructions/memory-protocol.instructions.md +12 -121
  35. package/src/instructions/zeus-anti-stall.instructions.md +8 -111
  36. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  37. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  38. package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
  39. package/src/pantheon/secret-scanner.ts +489 -0
  40. package/src/pantheon/v2-delegation-enforce.ts +86 -0
  41. package/src/pantheon/v2-execute-after.ts +186 -0
  42. package/src/pantheon/v2-unsupported.mjs +0 -23
  43. package/src/plugin-v2.ts +254 -102
  44. package/src/plugins/pantheon-hooks.ts +82 -35
  45. package/src/plugins/tui/README.md +14 -0
  46. package/src/plugins/tui/dist/tui.d.ts +11 -8
  47. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  48. package/src/plugins/tui/dist/tui.js +102 -62
  49. package/src/plugins/tui/package-lock.json +30 -33
  50. package/src/plugins/tui/package.json +5 -1
  51. package/src/plugins/tui/src/index.tsx +163 -107
  52. package/src/plugins/tui/tsdown.config.ts +17 -0
  53. package/src/routing.yml +12 -9
  54. package/src/skills/README.md +9 -4
  55. package/src/skills/agent-coordination/SKILL.md +13 -83
  56. package/src/skills/artifact-management/SKILL.md +12 -5
  57. package/src/skills/auto-continue/SKILL.md +38 -28
  58. package/src/skills/code-review-checklist/SKILL.md +5 -5
  59. package/src/skills/council-synthesis/SKILL.md +29 -0
  60. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  61. package/scripts/hooks/scan-secrets.sh +0 -97
  62. package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
package/src/plugin-v2.ts CHANGED
@@ -12,15 +12,15 @@
12
12
  * session, and the shared `createEnforcementGuard` throws to deny
13
13
  * `hashline_edit` / `pantheon_model` / `edit` / `write` / `bash` / `task`.
14
14
  * See `registerV2ToolHooks`.
15
- * - The SAME `execute.before` hook runs scripts/hooks/scan-secrets.sh over the
16
- * tool input and throws on exit 2 (high-confidence token match). This is the
15
+ * - The SAME `execute.before` hook scans serialized tool input in-process and
16
+ * throws on a high-confidence token match. This is the
17
17
  * ONLY hard security block on the V2 surface. It is wired HERE, not delegated
18
18
  * to `src/plugins/pantheon-hooks.ts`: opencode.json declares only
19
19
  * `src/plugin-v2` in `plugins`, so the V1 plugin never loads on a V2-only
20
20
  * install and V1's identical block was inert by construction.
21
21
 
22
- * - A tool `execute.after` hook and a permission hook, registered as
23
- * registration points with no V2-side behaviour
22
+ * - A tool `execute.after` hook and a permission hook, with permission
23
+ * evaluation enforcing the caller/target matrix for Pantheon agents
24
24
  *
25
25
  * Every tool registered through `draft.add` MUST declare `output`, and its
26
26
  * `execute` MUST resolve to a value carrying `output`. The host enforces the
@@ -88,14 +88,22 @@ import {
88
88
  type ToolExecuteBeforeInput,
89
89
  type ToolExecuteBeforeOutput,
90
90
  } from './pantheon/delegation-enforce.ts'
91
+ import { scanSecretPayload } from './pantheon/secret-scanner.ts'
91
92
  import {
92
93
  getV2BridgeFromContext,
93
94
  type PantheonV2Bridge,
94
95
  type V2ContextLike,
95
96
  } from './pantheon/v2-bridge.ts'
97
+ import {
98
+ type AuthoritativeSession,
99
+ evaluateV2Delegation,
100
+ PANTHEON_AGENTS,
101
+ type V2PermissionEvent,
102
+ v2ResourceTarget,
103
+ } from './pantheon/v2-delegation-enforce.ts'
104
+ import { createV2ExecuteAfter, resolveV2SandboxConfig } from './pantheon/v2-execute-after.ts'
96
105
  import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
97
106
  import { V2_UNSUPPORTED_FEATURE_SEED } from './pantheon/v2-unsupported.mjs'
98
- import { type HookPayload, runHook } from './plugins/hook-runner.ts'
99
107
 
100
108
  // ─── Unsupported Features Registry ───────────────────────────────────────
101
109
 
@@ -220,12 +228,15 @@ interface V2EventDomain {
220
228
  /**
221
229
  * The minimal shape of `ctx.permission`.
222
230
  *
223
- * Only a hook name and a handler: the plugin registers `evaluate` as a
224
- * registration point and deliberately runs no V2-side permission logic, so no
225
- * richer payload type is claimed here.
231
+ * The V2.0.25 live contract supplies an `evaluate` event with `resources`;
232
+ * runtime validation remains defensive because SDK declarations omit this
233
+ * domain and the host may evolve independently.
226
234
  */
227
235
  interface V2PermissionDomain {
228
- hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
236
+ hook?: (
237
+ name: string,
238
+ handler: (event: unknown, output?: unknown) => void | Promise<void>,
239
+ ) => Promise<unknown>
229
240
  }
230
241
 
231
242
  /**
@@ -237,6 +248,7 @@ interface V2PermissionDomain {
237
248
  */
238
249
  interface V2SessionDomain {
239
250
  hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
251
+ get?: (sessionID: string) => Promise<unknown>
240
252
  }
241
253
 
242
254
  /** `PluginContext` plus the four domains 1.18.x omits. See the module header. */
@@ -719,15 +731,12 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
719
731
  // ─── V2 Secret Scan ─────────────────────────────────────────────────────
720
732
 
721
733
  /**
722
- * High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
723
- * src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
724
- * scripts/hooks/scan-secrets.sh.
734
+ * High-confidence secret token formats used to redact unrelated runtime logs.
735
+ * Detection itself is shared with V1 in `pantheon/secret-scanner.ts`.
725
736
  *
726
- * Duplicated rather than imported because the V1 module is a separate plugin
727
- * that is NOT loaded on a V2-only install (see the module header), and pulling
728
- * it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
729
- * remove. The drift risk is real and is asserted by a test in
730
- * tests/pantheon/plugin-v2-contract.test.ts.
737
+ * This separate log-redaction expression is not the runtime detector. It is
738
+ * kept local because it masks arbitrary log strings, while the shared scanner
739
+ * returns masked findings and never returns raw payload content.
731
740
  *
732
741
  * Applied to every log line this module writes, so a hook trail can never
733
742
  * contain a raw credential. Header/KEY names (the Bifrost header alone,
@@ -742,7 +751,7 @@ function maskSecret(text: string): string {
742
751
  }
743
752
 
744
753
  /**
745
- * Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
754
+ * Message thrown when a high-confidence token match is detected.
746
755
  *
747
756
  * This is the only deliberate throw on the V2 surface. opencode's
748
757
  * Plugin.trigger has no catch around hook invocations, so the rejection
@@ -760,6 +769,91 @@ function maskSecret(text: string): string {
760
769
  const SECRET_BLOCK_MESSAGE =
761
770
  '[plugin-v2] Blocked: high-confidence secret detected in tool input — see .pantheon/logs/hooks.log'
762
771
 
772
+ /**
773
+ * Message thrown when the scanner cannot validate or serialize the tool input.
774
+ * Invalid scanner input is a deliberate fail-CLOSED denial.
775
+ *
776
+ * English and user-facing, like {@link SECRET_BLOCK_MESSAGE}. It carries no
777
+ * scanner output on purpose: a scan that failed mid-flight may still be holding
778
+ * the raw payload, and the denial must not become the leak it exists to prevent.
779
+ */
780
+ const SECRET_SCAN_UNAVAILABLE_MESSAGE =
781
+ '[plugin-v2] Blocked: the secret scanner could not complete, so this tool call was denied (fail-closed) — see .pantheon/logs/hooks.log'
782
+
783
+ const SECRET_SCAN_MARKER = '[SECRET SCAN]'
784
+
785
+ type SecretScanPayload = {
786
+ tool_name?: string
787
+ tool_input?: unknown
788
+ agent_id?: string
789
+ session_id?: string
790
+ }
791
+
792
+ type SecretScanHookResult = {
793
+ code: number
794
+ stdout: string
795
+ stderr: string
796
+ signal: NodeJS.Signals | null
797
+ timedOut: boolean
798
+ }
799
+
800
+ type SecretScanFailureReason = 'scanner-malformed-output' | 'scanner-unexpected-exit'
801
+
802
+ type SecretScanDecision =
803
+ | { action: 'allow' }
804
+ | { action: 'advisory' }
805
+ | { action: 'block' }
806
+ | { action: 'fail-closed'; reason: SecretScanFailureReason }
807
+
808
+ function evaluateSecretScanResult(scan: SecretScanHookResult): SecretScanDecision {
809
+ if (scan.timedOut || scan.signal !== null) {
810
+ return { action: 'fail-closed', reason: 'scanner-unexpected-exit' }
811
+ }
812
+ if (!Number.isInteger(scan.code)) {
813
+ return { action: 'fail-closed', reason: 'scanner-malformed-output' }
814
+ }
815
+ if (scan.code === 0) return { action: 'allow' }
816
+ if (scan.code === 2) return { action: 'block' }
817
+ if (scan.code === 1) {
818
+ return scan.stderr.includes(SECRET_SCAN_MARKER)
819
+ ? { action: 'advisory' }
820
+ : { action: 'fail-closed', reason: 'scanner-malformed-output' }
821
+ }
822
+ return { action: 'fail-closed', reason: 'scanner-unexpected-exit' }
823
+ }
824
+
825
+ /** Convert the pure scanner result into the existing fail-closed verdict shape. */
826
+ async function scanSecretInProcess(payload: SecretScanPayload): Promise<SecretScanHookResult> {
827
+ const result = scanSecretPayload(payload)
828
+ const reports =
829
+ result.status === 'block' || result.status === 'advisory'
830
+ ? result.findings.map(
831
+ (finding) =>
832
+ `[SECRET SCAN] ${finding.confidence}-confidence match: ${finding.masked} (pattern: ${finding.pattern})`,
833
+ )
834
+ : []
835
+ const code = result.status === 'clean' ? 0 : result.status === 'block' ? 2 : 1
836
+ const stderr =
837
+ result.status === 'invalid'
838
+ ? 'scanner input validation failed'
839
+ : result.status === 'block'
840
+ ? `${reports.join('\n')}\n[SECRET SCAN] High-confidence hardcoded secret detected. Tool call will be BLOCKED.`
841
+ : result.status === 'advisory'
842
+ ? `${reports.join('\n')}\n[SECRET SCAN] Possible secret detected. Remove it and use environment variables or a vault.`
843
+ : ''
844
+ return { code, stdout: '', stderr, signal: null, timedOut: false }
845
+ }
846
+
847
+ let secretScanRunner: (payload: SecretScanPayload) => Promise<SecretScanHookResult> =
848
+ scanSecretInProcess
849
+
850
+ /** Test-only seam for exercising malformed scanner verdicts without a host. */
851
+ export function setV2SecretScanRunnerForTests(
852
+ runner: ((payload: SecretScanPayload) => Promise<SecretScanHookResult>) | null,
853
+ ): void {
854
+ secretScanRunner = runner ?? scanSecretInProcess
855
+ }
856
+
763
857
  /**
764
858
  * Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
765
859
  * working directory, and return whether it landed.
@@ -831,41 +925,11 @@ interface V2ToolExecuteBeforeEvent {
831
925
  * tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
832
926
  * reason they are denied in V1.
833
927
  *
834
- * Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`.
835
- * There are TWO distinct reasons, and conflating them is what produced a wrong
836
- * causal chain here once already — keep them apart:
837
- *
838
- * 1. MISSING `getSessionAgent` (wiring). This is what would deny every
839
- * `task()` call. V2 learns the active agent from the `execute.before` event
840
- * itself and keeps no session→agent map, so there is no lookup to hand the
841
- * guard. The guard would call `isDelegationAllowed(undefined, target)`,
842
- * which returns `false`, and throw "caller agent is unavailable" — for
843
- * every caller, including Zeus. Note what is NOT happening: an unseeded
844
- * `isRootSession` would *pass* the root gate (`isRoot` reports `true` for an
845
- * unknown session), so the hierarchy is not what denies here.
846
- *
847
- * 2. UNWIRED HIERARCHY (design). `SessionHierarchyRegistry` is what gates
848
- * exactly two things, and nothing else: the depth-2 child deny
849
- * (`isChildSession`) and the root-session gate (`isRootSession`). Left
850
- * unseeded, `isChild` is `false` for every session, so depth-2 would never
851
- * fire on a genuine child — a hierarchy that cannot tell a child from a root
852
- * is not a trustworthy input to either check.
853
- *
854
- * "Unwired", not "impossible": the V2 `session.created` event carries
855
- * `properties.info: Session` including `parentID?`, the same field V1 seeds
856
- * from on the same event, so one line in `onSessionCreated` would seed live
857
- * sessions (see its docstring). What has no V2 equivalent is V1's SECOND
858
- * source, the `client.session.list()` startup seed — V2's `PluginContext`
859
- * exposes no `client`. Whether a live 2.0.22 host populates `info.parentID`
860
- * is UNVERIFIED; no canary reads an event payload. So the correct summary is
861
- * "seeded for nothing", not "unseedable", and the guard's omission is right
862
- * today for the weaker reason that nothing seeds it at all.
863
- *
864
- * Omitting all three skips the branch wholesale: the guard only enters it when
865
- * `options?.isRootSession !== undefined`. `task` is still denied inside a
866
- * read-only session via the blocked-tool list, so depth-2 holds for
867
- * apollo/gaia; the caller/target matrix itself is NOT enforced on V2. That is
868
- * a known gap, not a covered case.
928
+ * Caller/target delegation authorization is separate: V2's
929
+ * `permission.evaluate` hook resolves the authoritative caller from the host
930
+ * session and applies the matrix in `v2-delegation-enforce.ts`. Keeping the
931
+ * responsibilities separate avoids treating this mutating-tool deny list as
932
+ * the delegation policy.
869
933
  */
870
934
  const v2EnforcementGuard = createEnforcementGuard({
871
935
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
@@ -912,7 +976,9 @@ function adaptV2ExecuteBeforeEvent(event: unknown): {
912
976
  * ctx.tool.hook("execute.before", handler) — pre-execution guard
913
977
  * ctx.tool.hook("execute.after", handler) — post-execution augment
914
978
  *
915
- * Returns true if any hooks were registered.
979
+ * Returns true only if the security-critical `execute.before` hook registered.
980
+ * The post-execution hook is optional and cannot make an unprotected tool
981
+ * surface ready.
916
982
  */
917
983
  async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
918
984
  const toolCtx = hostDomains(context).tool
@@ -921,8 +987,6 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
921
987
  return false
922
988
  }
923
989
 
924
- let registered = false
925
-
926
990
  try {
927
991
  // "execute.before" — read-only enforcement. Throwing from this handler
928
992
  // DENIES the tool call and the host surfaces the message to the session,
@@ -945,61 +1009,70 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
945
1009
  // propagating to the host, not be swallowed as a scanner failure.
946
1010
  await v2EnforcementGuard(input, output)
947
1011
 
948
- // High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
1012
+ // In-process high-confidence secret scan. This is the
949
1013
  // ONLY hard block on the V2 surface, and it exists here — rather than via
950
1014
  // `src/plugins/pantheon-hooks.ts` — because that module is a separate
951
1015
  // plugin that is never loaded on a V2-only install: opencode.json
952
- // `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
953
- // therefore inert by construction, not by misconfiguration.
1016
+ // `plugins` declares only `src/plugin-v2`. Both V1 and V2 import the same
1017
+ // scanner module so this remains active on either install surface.
954
1018
  //
955
- // exit 2 → block. exit 1 (low-confidence header/KEY names) → advisory,
956
- // logged only. A scanner failure (missing script, timeout) is fail-OPEN:
957
- // runHook never rejects, and an infrastructure error must not deny
958
- // unrelated tool calls.
1019
+ // Clean → allow. Low-confidence match → advisory. High-confidence match
1020
+ // or malformed/non-serializable input → deny this tool call.
959
1021
  let blockError: Error | null = null
960
1022
  try {
961
- const payload: HookPayload = {
1023
+ const payload: SecretScanPayload = {
962
1024
  tool_name: input.tool,
963
- tool_input: output.args ?? {},
1025
+ tool_input: output.args,
964
1026
  agent_id: typeof agent === 'string' ? agent : '',
965
1027
  session_id: input.sessionID,
966
1028
  }
967
- const scan = await runHook('scan-secrets.sh', payload)
968
- if (scan.code === 2) {
1029
+ const scan = await secretScanRunner(payload)
1030
+ const decision = evaluateSecretScanResult(scan)
1031
+ if (decision.action === 'block') {
969
1032
  appendV2HookLog(SECRET_BLOCK_MESSAGE)
970
1033
  appendV2HookLog(scan.stderr)
971
1034
  blockError = new Error(SECRET_BLOCK_MESSAGE)
972
- } else if (scan.code === 1) {
973
- // Advisory: log only, never block.
1035
+ } else if (decision.action === 'advisory') {
1036
+ // The pure scanner reports masked matches only.
974
1037
  appendV2HookLog(scan.stderr)
1038
+ } else if (decision.action === 'fail-closed') {
1039
+ // Never persist scanner output from an invalid verdict.
1040
+ appendV2HookLog(`${SECRET_SCAN_UNAVAILABLE_MESSAGE} (${decision.reason})`)
1041
+ blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
975
1042
  }
976
1043
  } catch {
977
- // Never let the scanner take down the session — runHook resolves a
978
- // structured result instead of rejecting, so this is belt-and-braces.
1044
+ // Keep the enforcement fail-closed if the scanner unexpectedly throws.
1045
+ appendV2HookLog(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1046
+ blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
979
1047
  }
980
1048
  if (blockError !== null) throw blockError
981
1049
  })
982
- registered = true
983
1050
  } catch {
984
- // Tool before-hook not supported
1051
+ // Never proceed to Pantheon tool registration without this guard. The host
1052
+ // may still expose its own built-in tools; this marker does not claim to
1053
+ // block or scan native host tool paths.
1054
+ return false
985
1055
  }
986
1056
 
987
1057
  try {
988
- // "execute.after" — hashline read enhance + context sandbox.
1058
+ // "execute.after" — the V1 parity chain (task-result-guard →
1059
+ // context-sandbox → read-enhancer) rebuilt on the V2 event shape.
989
1060
  //
990
- // Registration point only: there is no V2-side behaviour here. The
991
- // enhancer/sandbox live in the V1 path (`pantheon-hooks.ts` +
992
- // `context-sandbox.ts`), a separate plugin instance that is not loaded when
993
- // only `plugin-v2` is configured.
994
- await toolCtx.hook('execute.after', (_event: unknown) => {
995
- // Intentionally empty.
1061
+ // This IS behaviour, not a registration point: on a V2-only install the V1
1062
+ // `pantheon-hooks` plugin is not loaded, so this handler is the only thing
1063
+ // that truncates oversized output and tags `read` with hashline refs.
1064
+ // The sandbox config comes from `ctx.options.context_sandbox` (the V2 copy
1065
+ // the installer writes — V2.0.25 drops the V1 top-level key), resolved with
1066
+ // the same resolver V1 uses.
1067
+ const v2ExecuteAfter = createV2ExecuteAfter({
1068
+ sandbox: resolveV2SandboxConfig(context.options),
996
1069
  })
997
- registered = true
1070
+ await toolCtx.hook('execute.after', (event: unknown) => v2ExecuteAfter(event))
998
1071
  } catch {
999
- // Tool after-hook not supported
1072
+ markUnsupported('tool-execute-after-hook')
1000
1073
  }
1001
1074
 
1002
- return registered
1075
+ return true
1003
1076
  }
1004
1077
 
1005
1078
  // ─── V2 Permission Hook ─────────────────────────────────────────────────
@@ -1008,12 +1081,13 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
1008
1081
  * Register permission hook via V2 ctx.permission.hook.
1009
1082
  *
1010
1083
  * The V2 permission.hook API (when available) provides:
1011
- * ctx.permission.hook("evaluate", handler) — custom permission logic
1084
+ * ctx.permission.hook("evaluate", handler) — host permission evaluation
1012
1085
  *
1013
1086
  * Returns true if the hook was registered.
1014
1087
  */
1015
1088
  async function registerV2PermissionHook(context: PluginContext): Promise<boolean> {
1016
- const permCtx = hostDomains(context).permission
1089
+ const domains = hostDomains(context)
1090
+ const permCtx = domains.permission
1017
1091
 
1018
1092
  if (!permCtx?.hook) {
1019
1093
  markUnsupported('permission-hook')
@@ -1021,12 +1095,79 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
1021
1095
  }
1022
1096
 
1023
1097
  try {
1024
- await permCtx.hook('evaluate', (_event: unknown) => {
1025
- // Registration point only — no V2-side permission logic. Read-only
1026
- // enforcement does NOT run here: it runs in the `execute.before` handler
1027
- // above, which is the hook the host consults before a tool executes. Do
1028
- // not read this as "the V1 guards cover it": V1 is a different plugin
1029
- // instance and is not loaded when only `plugin-v2` is configured.
1098
+ await permCtx.hook('evaluate', async (rawEvent: unknown, rawOutput?: unknown) => {
1099
+ const event = rawEvent as V2PermissionEvent | null
1100
+ const output =
1101
+ rawOutput !== null && typeof rawOutput === 'object'
1102
+ ? (rawOutput as { status?: unknown })
1103
+ : undefined
1104
+ const statusBefore = output?.status
1105
+ const writeProof = (statusAfter: unknown): void => {
1106
+ const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
1107
+ if (!proofPath || !event || !Array.isArray(event.resources)) return
1108
+ try {
1109
+ appendFileSync(
1110
+ proofPath,
1111
+ `permission.evaluate ${JSON.stringify({
1112
+ resources: event.resources,
1113
+ agent: event.agent,
1114
+ sessionID: event.sessionID,
1115
+ effect: event.effect,
1116
+ eventFields: ['resources', 'agent', 'sessionID', 'effect'].filter((key) =>
1117
+ Object.hasOwn(event, key),
1118
+ ),
1119
+ outputPresent: output !== undefined,
1120
+ statusBefore: statusBefore ?? null,
1121
+ statusAfter: statusAfter ?? null,
1122
+ })}\n`,
1123
+ )
1124
+ } catch {
1125
+ // Instrumentation is best-effort and must not affect permission flow.
1126
+ }
1127
+ }
1128
+ const deny = (): void => {
1129
+ if (!output) {
1130
+ writeProof(undefined)
1131
+ throw new Error('permission output is unavailable; denying V2 permission evaluation')
1132
+ }
1133
+ output.status = 'deny'
1134
+ writeProof(output.status)
1135
+ }
1136
+
1137
+ if (output?.status === 'deny') {
1138
+ writeProof(output.status)
1139
+ return // explicit host deny is final
1140
+ }
1141
+ if (event == null || !Array.isArray(event.resources)) {
1142
+ deny()
1143
+ return
1144
+ }
1145
+ const targets = event.resources.map(v2ResourceTarget)
1146
+ if (targets.some((target) => target === undefined)) {
1147
+ deny()
1148
+ return
1149
+ }
1150
+ const hasPantheonTarget = targets.some((target) => PANTHEON_AGENTS.has(target ?? ''))
1151
+ let session: AuthoritativeSession | undefined
1152
+ if (hasPantheonTarget) {
1153
+ try {
1154
+ const sessionID = typeof event.sessionID === 'string' ? event.sessionID : ''
1155
+ if (!sessionID || !domains.session?.get) throw new Error('session lookup unavailable')
1156
+ const retrieved = await domains.session.get(sessionID)
1157
+ if (retrieved === null || typeof retrieved !== 'object')
1158
+ throw new Error('invalid session')
1159
+ session = retrieved as AuthoritativeSession
1160
+ } catch {
1161
+ deny()
1162
+ return
1163
+ }
1164
+ }
1165
+
1166
+ // Native-only resources remain under host policy. If a Pantheon target
1167
+ // appears anywhere, the shared policy evaluates the complete resource set.
1168
+ const decision = evaluateV2Delegation(event, session, output?.status)
1169
+ if (decision === 'deny') deny()
1170
+ else writeProof(output?.status)
1030
1171
  })
1031
1172
  return true
1032
1173
  } catch {
@@ -1115,13 +1256,30 @@ export const plugin = define({
1115
1256
  }
1116
1257
  })
1117
1258
 
1118
- // ─── Phase 2: V2 Tool Registration (best-effort) ─────────────────
1119
- const toolsRegistered = await registerV2Tools(context).catch(() => false)
1120
- if (!toolsRegistered) {
1259
+ // ─── Phase 2: Required V2 execute.before enforcement ──────────────
1260
+ // Register the scanner/read-only gate before any Pantheon V2 tools can be
1261
+ // added. A missing/rejected hook disables the custom Pantheon tool surface;
1262
+ // native host built-ins may still remain available and are not claimed to
1263
+ // be covered by this plugin guard.
1264
+ const toolHooksRegistered = await registerV2ToolHooks(context).catch(() => false)
1265
+ if (!toolHooksRegistered) {
1266
+ markUnsupported('tool-execute-hooks')
1267
+ markUnsupported('secret-scan-hook')
1268
+ markUnsupported('pantheon-tools-disabled')
1269
+ console.error(
1270
+ '[Pantheon V2] Secret-scanning enforcement is unavailable; Pantheon V2 tools were not registered. Native host tools may remain available and are not covered by this guard.',
1271
+ )
1272
+ }
1273
+
1274
+ // ─── Phase 3: V2 Tool Registration (guarded) ─────────────────────
1275
+ const toolsRegistered = toolHooksRegistered
1276
+ ? await registerV2Tools(context).catch(() => false)
1277
+ : false
1278
+ if (!toolsRegistered && toolHooksRegistered) {
1121
1279
  markUnsupported('tool-transform')
1122
1280
  }
1123
1281
 
1124
- // ─── Phase 3: V2 Event Subscription (best-effort) ────────────────
1282
+ // ─── Phase 4: V2 Event Subscription (best-effort) ────────────────
1125
1283
  const eventCleanup = subscribeV2Events(context)
1126
1284
  if (eventCleanup) {
1127
1285
  activeCleanups.push(eventCleanup)
@@ -1129,18 +1287,12 @@ export const plugin = define({
1129
1287
  markUnsupported('event-stream')
1130
1288
  }
1131
1289
 
1132
- // ─── Phase 4: V2 Session Hooks (best-effort) ─────────────────────
1290
+ // ─── Phase 5: V2 Session Hooks (best-effort) ─────────────────────
1133
1291
  const sessionHooksRegistered = await registerV2SessionHooks(context).catch(() => false)
1134
1292
  if (!sessionHooksRegistered) {
1135
1293
  markUnsupported('session-hooks')
1136
1294
  }
1137
1295
 
1138
- // ─── Phase 5: V2 Tool Hooks (best-effort) ────────────────────────
1139
- const toolHooksRegistered = await registerV2ToolHooks(context).catch(() => false)
1140
- if (!toolHooksRegistered) {
1141
- markUnsupported('tool-execute-hooks')
1142
- }
1143
-
1144
1296
  // ─── Phase 6: V2 Permission Hook (best-effort) ───────────────────
1145
1297
  await registerV2PermissionHook(context).catch(() => false)
1146
1298