pantheon-opencode 1.6.0-beta.7 → 1.6.0-beta.8

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 (49) hide show
  1. package/AGENTS.md +58 -15
  2. package/CHANGELOG.md +37 -100
  3. package/README.md +78 -102
  4. package/README.pt-BR.md +19 -37
  5. package/package.json +1 -2
  6. package/plugin.json +2 -2
  7. package/pyproject.toml +1 -1
  8. package/scripts/build-agents-md.mjs +2 -3
  9. package/scripts/doctor.mjs +22 -104
  10. package/scripts/hooks/scan-secrets.sh +97 -0
  11. package/scripts/install/config-migration.mjs +9 -42
  12. package/scripts/install/interactive.mjs +1 -1
  13. package/scripts/install/opencode.mjs +114 -43
  14. package/scripts/install/strings.mjs +0 -8
  15. package/scripts/probe-pantheon-cost.mjs +3 -16
  16. package/scripts/test-opencode-v2-sandbox.sh +203 -786
  17. package/scripts/validate-sandbox-install.mjs +138 -0
  18. package/skills-lock.json +3 -4
  19. package/src/agents/aphrodite.md +2 -9
  20. package/src/agents/demeter.md +0 -9
  21. package/src/agents/gaia.md +1 -0
  22. package/src/agents/hephaestus.md +0 -9
  23. package/src/agents/hermes.md +2 -9
  24. package/src/agents/mnemosyne.md +14 -42
  25. package/src/agents/prometheus.md +0 -9
  26. package/src/agents/themis.md +0 -1
  27. package/src/agents/zeus.md +180 -19
  28. package/src/instructions/agent-return-format.instructions.md +48 -3
  29. package/src/instructions/memory-protocol.instructions.md +8 -9
  30. package/src/instructions/zeus-anti-stall.instructions.md +54 -6
  31. package/src/instructions/zeus-council-synthesis.instructions.md +71 -0
  32. package/src/instructions/zeus-memory-operations.instructions.md +4 -4
  33. package/src/instructions/zeus-timeout-retry.instructions.md +81 -7
  34. package/src/pantheon/v2-delegation-enforce.ts +2 -1
  35. package/src/plugin-v2.ts +251 -168
  36. package/src/plugins/pantheon-hooks.ts +35 -82
  37. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  38. package/src/plugins/tui/dist/tui.js +26 -50
  39. package/src/plugins/tui/package-lock.json +2 -2
  40. package/src/plugins/tui/package.json +1 -1
  41. package/src/plugins/tui/src/index.tsx +25 -60
  42. package/src/routing.yml +4 -2
  43. package/src/skills/README.md +7 -12
  44. package/src/skills/agent-coordination/SKILL.md +83 -13
  45. package/src/skills/auto-continue/SKILL.md +13 -14
  46. package/src/skills/context-compression/SKILL.md +26 -879
  47. package/src/pantheon/secret-scanner.ts +0 -489
  48. package/src/plugins/tui/README.md +0 -14
  49. package/src/skills/council-synthesis/SKILL.md +0 -29
package/src/plugin-v2.ts CHANGED
@@ -12,8 +12,8 @@
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 scans serialized tool input in-process and
16
- * throws on a high-confidence token match. This is the
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
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
@@ -88,7 +88,6 @@ import {
88
88
  type ToolExecuteBeforeInput,
89
89
  type ToolExecuteBeforeOutput,
90
90
  } from './pantheon/delegation-enforce.ts'
91
- import { scanSecretPayload } from './pantheon/secret-scanner.ts'
92
91
  import {
93
92
  getV2BridgeFromContext,
94
93
  type PantheonV2Bridge,
@@ -104,6 +103,7 @@ import {
104
103
  import { createV2ExecuteAfter, resolveV2SandboxConfig } from './pantheon/v2-execute-after.ts'
105
104
  import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
106
105
  import { V2_UNSUPPORTED_FEATURE_SEED } from './pantheon/v2-unsupported.mjs'
106
+ import { type HookPayload, type HookResult, runHook } from './plugins/hook-runner.ts'
107
107
 
108
108
  // ─── Unsupported Features Registry ───────────────────────────────────────
109
109
 
@@ -240,15 +240,14 @@ interface V2PermissionDomain {
240
240
  }
241
241
 
242
242
  /**
243
- * The minimal shape of `ctx.session`.
244
- *
245
- * Only `hook(name, handler)`. The host accepts any string and silently drops an
246
- * unknown one — which is why the hook names below were a live defect once and
247
- * why the host-backed hook canary exists.
243
+ * The minimal shape of `ctx.session` used by this module. `get` follows the V2
244
+ * Promise API's object input (`{ sessionID }`); session hooks accept any string
245
+ * and silently drop an unknown one, which is why the host-backed hook canary
246
+ * exists.
248
247
  */
249
248
  interface V2SessionDomain {
250
249
  hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
251
- get?: (sessionID: string) => Promise<unknown>
250
+ get?: (input: { sessionID: string }) => Promise<unknown>
252
251
  }
253
252
 
254
253
  /** `PluginContext` plus the four domains 1.18.x omits. See the module header. */
@@ -731,12 +730,15 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
731
730
  // ─── V2 Secret Scan ─────────────────────────────────────────────────────
732
731
 
733
732
  /**
734
- * High-confidence secret token formats used to redact unrelated runtime logs.
735
- * Detection itself is shared with V1 in `pantheon/secret-scanner.ts`.
733
+ * High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
734
+ * src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
735
+ * scripts/hooks/scan-secrets.sh.
736
736
  *
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.
737
+ * Duplicated rather than imported because the V1 module is a separate plugin
738
+ * that is NOT loaded on a V2-only install (see the module header), and pulling
739
+ * it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
740
+ * remove. The drift risk is real and is asserted by a test in
741
+ * tests/pantheon/plugin-v2-contract.test.ts.
740
742
  *
741
743
  * Applied to every log line this module writes, so a hook trail can never
742
744
  * contain a raw credential. Header/KEY names (the Bifrost header alone,
@@ -751,7 +753,7 @@ function maskSecret(text: string): string {
751
753
  }
752
754
 
753
755
  /**
754
- * Message thrown when a high-confidence token match is detected.
756
+ * Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
755
757
  *
756
758
  * This is the only deliberate throw on the V2 surface. opencode's
757
759
  * Plugin.trigger has no catch around hook invocations, so the rejection
@@ -770,8 +772,17 @@ const SECRET_BLOCK_MESSAGE =
770
772
  '[plugin-v2] Blocked: high-confidence secret detected in tool input — see .pantheon/logs/hooks.log'
771
773
 
772
774
  /**
773
- * Message thrown when the scanner cannot validate or serialize the tool input.
774
- * Invalid scanner input is a deliberate fail-CLOSED denial.
775
+ * Message thrown when the secret scanner cannot produce a trustworthy verdict —
776
+ * the script is missing/unexecutable, the child timed out or was killed, the
777
+ * payload could not be serialized, the report was malformed, or the exit status
778
+ * was unexpected.
779
+ *
780
+ * This is a deliberate fail-CLOSED denial. The previous handler blocked only
781
+ * `code === 2` and treated every other outcome as advisory — but the runner
782
+ * reports a missing script, a spawn failure and a payload-serialization failure
783
+ * as `code: 1` too (src/plugins/hook-runner.ts), so a broken scanner read as a
784
+ * clean advisory and the tool ran, silently disabling the only hard secret block
785
+ * on the V2 surface.
775
786
  *
776
787
  * English and user-facing, like {@link SECRET_BLOCK_MESSAGE}. It carries no
777
788
  * scanner output on purpose: a scan that failed mid-flight may still be holding
@@ -780,80 +791,6 @@ const SECRET_BLOCK_MESSAGE =
780
791
  const SECRET_SCAN_UNAVAILABLE_MESSAGE =
781
792
  '[plugin-v2] Blocked: the secret scanner could not complete, so this tool call was denied (fail-closed) — see .pantheon/logs/hooks.log'
782
793
 
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
-
857
794
  /**
858
795
  * Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
859
796
  * working directory, and return whether it landed.
@@ -889,6 +826,98 @@ function appendV2HookLog(line: string): boolean {
889
826
  }
890
827
  }
891
828
 
829
+ // ─── V2 Secret-Scan Verdict ─────────────────────────────────────────────
830
+
831
+ /**
832
+ * The scanner's own report marker, written to stderr on every non-clean run
833
+ * (exit 1 advisory, exit 2 block). It is the discriminator between a genuine
834
+ * low-confidence advisory and a runner infrastructure failure, BOTH of which
835
+ * surface as `code === 1`: the runner's own failures (missing/unexecutable
836
+ * script, spawn error, payload-serialization error) resolve a structured
837
+ * `HookResult` with `code: 1` and no scan report, while the real scanner's
838
+ * advisory always carries this marker. Requiring it means a scanner that stops
839
+ * reporting fails toward a denial, never toward a silent allow.
840
+ */
841
+ const SECRET_SCAN_MARKER = '[SECRET SCAN]'
842
+
843
+ /** Sanitized failure classes — logged in place of raw scanner output. */
844
+ type SecretScanFailureReason =
845
+ | 'scanner-timeout'
846
+ | 'scanner-signal'
847
+ | 'scanner-spawn-failure'
848
+ | 'scanner-serialization-failure'
849
+ | 'scanner-malformed-output'
850
+ | 'scanner-unexpected-exit'
851
+
852
+ type SecretScanDecision =
853
+ | { action: 'allow' }
854
+ | { action: 'advisory' }
855
+ | { action: 'block' }
856
+ | { action: 'fail-closed'; reason: SecretScanFailureReason }
857
+
858
+ /**
859
+ * The runner's own infrastructure-failure messages (see hook-runner.ts), matched
860
+ * at the start of stderr where the runner is the only writer. Matching a prefix
861
+ * rather than a substring keeps a scan report that merely happens to quote one
862
+ * of these phrases from flipping a valid advisory into a denial.
863
+ */
864
+ const SCANNER_INFRA_FAILURES: ReadonlyArray<[string, SecretScanFailureReason]> = [
865
+ ['payload serialization failed:', 'scanner-serialization-failure'],
866
+ ['spawn failed:', 'scanner-spawn-failure'],
867
+ ['hook script failed to spawn:', 'scanner-spawn-failure'],
868
+ ]
869
+
870
+ /**
871
+ * Classify one secret-scan {@link HookResult} into an enforcement decision.
872
+ *
873
+ * fail-closed by construction: `allow` and `advisory` are reachable only from a
874
+ * result that is unambiguously a completed scanner run. Everything else —
875
+ * timeout, signal, spawn/serialization failure, missing report, unknown or
876
+ * unexpected exit status — denies. `advisory` additionally requires the
877
+ * scanner's own report marker, which is what separates an exit-1 advisory from
878
+ * the runner's exit-1 infrastructure failure.
879
+ *
880
+ * `block` covers the high-confidence match (exit 2); the caller turns it into
881
+ * the user-facing block.
882
+ */
883
+ function evaluateSecretScanResult(scan: HookResult): SecretScanDecision {
884
+ if (scan.timedOut) return { action: 'fail-closed', reason: 'scanner-timeout' }
885
+ if (scan.signal !== null) return { action: 'fail-closed', reason: 'scanner-signal' }
886
+ if (!Number.isInteger(scan.code)) {
887
+ return { action: 'fail-closed', reason: 'scanner-malformed-output' }
888
+ }
889
+ for (const [signature, reason] of SCANNER_INFRA_FAILURES) {
890
+ if (scan.stderr.startsWith(signature)) return { action: 'fail-closed', reason }
891
+ }
892
+ if (scan.code === 0) return { action: 'allow' }
893
+ if (scan.code === 2) return { action: 'block' }
894
+ if (scan.code === 1) {
895
+ return scan.stderr.includes(SECRET_SCAN_MARKER)
896
+ ? { action: 'advisory' }
897
+ : { action: 'fail-closed', reason: 'scanner-malformed-output' }
898
+ }
899
+ return { action: 'fail-closed', reason: 'scanner-unexpected-exit' }
900
+ }
901
+
902
+ /** Invocation of scripts/hooks/scan-secrets.sh used by the tool guard. */
903
+ type SecretScanRunner = (payload: HookPayload) => Promise<HookResult>
904
+
905
+ const defaultSecretScanRunner: SecretScanRunner = (payload) => runHook('scan-secrets.sh', payload)
906
+
907
+ let secretScanRunner: SecretScanRunner = defaultSecretScanRunner
908
+
909
+ /**
910
+ * Test seam — override (or, with `null`, restore) the V2 secret-scan runner.
911
+ *
912
+ * Exported only for tests/pantheon/plugin-v2-contract.test.ts, which injects
913
+ * structured {@link HookResult}s (missing scanner, timeout, malformed output)
914
+ * that the real script cannot be made to produce on demand. There is no
915
+ * production caller; the shipped handler always uses `defaultSecretScanRunner`.
916
+ */
917
+ export function setV2SecretScanRunnerForTests(runner: SecretScanRunner | null): void {
918
+ secretScanRunner = runner ?? defaultSecretScanRunner
919
+ }
920
+
892
921
  // ─── V2 Tool Hooks ──────────────────────────────────────────────────────
893
922
 
894
923
  /**
@@ -925,11 +954,41 @@ interface V2ToolExecuteBeforeEvent {
925
954
  * tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
926
955
  * reason they are denied in V1.
927
956
  *
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.
957
+ * Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`.
958
+ * There are TWO distinct reasons, and conflating them is what produced a wrong
959
+ * causal chain here once already — keep them apart:
960
+ *
961
+ * 1. MISSING `getSessionAgent` (wiring). This is what would deny every
962
+ * `task()` call. V2 learns the active agent from the `execute.before` event
963
+ * itself and keeps no session→agent map, so there is no lookup to hand the
964
+ * guard. The guard would call `isDelegationAllowed(undefined, target)`,
965
+ * which returns `false`, and throw "caller agent is unavailable" — for
966
+ * every caller, including Zeus. Note what is NOT happening: an unseeded
967
+ * `isRootSession` would *pass* the root gate (`isRoot` reports `true` for an
968
+ * unknown session), so the hierarchy is not what denies here.
969
+ *
970
+ * 2. UNWIRED HIERARCHY (design). `SessionHierarchyRegistry` is what gates
971
+ * exactly two things, and nothing else: the depth-2 child deny
972
+ * (`isChildSession`) and the root-session gate (`isRootSession`). Left
973
+ * unseeded, `isChild` is `false` for every session, so depth-2 would never
974
+ * fire on a genuine child — a hierarchy that cannot tell a child from a root
975
+ * is not a trustworthy input to either check.
976
+ *
977
+ * "Unwired", not "impossible": the V2 `session.created` event carries
978
+ * `properties.info: Session` including `parentID?`, the same field V1 seeds
979
+ * from on the same event, so one line in `onSessionCreated` would seed live
980
+ * sessions (see its docstring). What has no V2 equivalent is V1's SECOND
981
+ * source, the `client.session.list()` startup seed — V2's `PluginContext`
982
+ * exposes no `client`. Whether a live 2.0.22 host populates `info.parentID`
983
+ * is UNVERIFIED; no canary reads an event payload. So the correct summary is
984
+ * "seeded for nothing", not "unseedable", and the guard's omission is right
985
+ * today for the weaker reason that nothing seeds it at all.
986
+ *
987
+ * Omitting all three skips the branch wholesale: the guard only enters it when
988
+ * `options?.isRootSession !== undefined`. `task` is still denied inside a
989
+ * read-only session via the blocked-tool list, so depth-2 holds for
990
+ * apollo/gaia; the caller/target matrix itself is NOT enforced on V2. That is
991
+ * a known gap, not a covered case.
933
992
  */
934
993
  const v2EnforcementGuard = createEnforcementGuard({
935
994
  getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
@@ -976,9 +1035,7 @@ function adaptV2ExecuteBeforeEvent(event: unknown): {
976
1035
  * ctx.tool.hook("execute.before", handler) — pre-execution guard
977
1036
  * ctx.tool.hook("execute.after", handler) — post-execution augment
978
1037
  *
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.
1038
+ * Returns true if any hooks were registered.
982
1039
  */
983
1040
  async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
984
1041
  const toolCtx = hostDomains(context).tool
@@ -987,6 +1044,8 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
987
1044
  return false
988
1045
  }
989
1046
 
1047
+ let registered = false
1048
+
990
1049
  try {
991
1050
  // "execute.before" — read-only enforcement. Throwing from this handler
992
1051
  // DENIES the tool call and the host surfaces the message to the session,
@@ -1009,20 +1068,25 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
1009
1068
  // propagating to the host, not be swallowed as a scanner failure.
1010
1069
  await v2EnforcementGuard(input, output)
1011
1070
 
1012
- // In-process high-confidence secret scan. This is the
1071
+ // High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
1013
1072
  // ONLY hard block on the V2 surface, and it exists here — rather than via
1014
1073
  // `src/plugins/pantheon-hooks.ts` — because that module is a separate
1015
1074
  // plugin that is never loaded on a V2-only install: opencode.json
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.
1075
+ // `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
1076
+ // therefore inert by construction, not by misconfiguration.
1018
1077
  //
1019
- // Clean → allow. Low-confidence match → advisory. High-confidence match
1020
- // or malformed/non-serializable input → deny this tool call.
1078
+ // exit 0 → allow. exit 1 with a valid scanner report → advisory, logged
1079
+ // only. exit 2 (high-confidence match) → deny. A scanner that does NOT
1080
+ // produce a trustworthy verdict — missing/unexecutable script, timeout,
1081
+ // killed child, spawn/serialization failure, malformed or unexpected
1082
+ // output — is fail-CLOSED: this pending tool call is denied, the session
1083
+ // is not. (runHook resolves a structured result instead of rejecting, so
1084
+ // the exit-code-only reading below cannot lean on an exception.)
1021
1085
  let blockError: Error | null = null
1022
1086
  try {
1023
- const payload: SecretScanPayload = {
1087
+ const payload: HookPayload = {
1024
1088
  tool_name: input.tool,
1025
- tool_input: output.args,
1089
+ tool_input: output.args ?? {},
1026
1090
  agent_id: typeof agent === 'string' ? agent : '',
1027
1091
  session_id: input.sessionID,
1028
1092
  }
@@ -1033,25 +1097,28 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
1033
1097
  appendV2HookLog(scan.stderr)
1034
1098
  blockError = new Error(SECRET_BLOCK_MESSAGE)
1035
1099
  } else if (decision.action === 'advisory') {
1036
- // The pure scanner reports masked matches only.
1100
+ // Advisory: log only, never block.
1037
1101
  appendV2HookLog(scan.stderr)
1038
1102
  } else if (decision.action === 'fail-closed') {
1039
- // Never persist scanner output from an invalid verdict.
1103
+ // Sanitized failure category only — never the scanner's raw stderr,
1104
+ // which on a failure path is unverified and may carry the very
1105
+ // payload the scan exists to keep out of logs.
1040
1106
  appendV2HookLog(`${SECRET_SCAN_UNAVAILABLE_MESSAGE} (${decision.reason})`)
1041
1107
  blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1042
1108
  }
1043
1109
  } catch {
1044
- // Keep the enforcement fail-closed if the scanner unexpectedly throws.
1110
+ // The runner is contracted to resolve a structured result, so this only
1111
+ // guards an unexpected throw (including a future change to the runner).
1112
+ // A scanner that cannot run is fail-CLOSED: deny this call rather than
1113
+ // let it through with no scan at all.
1045
1114
  appendV2HookLog(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1046
1115
  blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1047
1116
  }
1048
1117
  if (blockError !== null) throw blockError
1049
1118
  })
1119
+ registered = true
1050
1120
  } catch {
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
1121
+ // Tool before-hook not supported
1055
1122
  }
1056
1123
 
1057
1124
  try {
@@ -1068,11 +1135,12 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
1068
1135
  sandbox: resolveV2SandboxConfig(context.options),
1069
1136
  })
1070
1137
  await toolCtx.hook('execute.after', (event: unknown) => v2ExecuteAfter(event))
1138
+ registered = true
1071
1139
  } catch {
1072
- markUnsupported('tool-execute-after-hook')
1140
+ // Tool after-hook not supported
1073
1141
  }
1074
1142
 
1075
- return true
1143
+ return registered
1076
1144
  }
1077
1145
 
1078
1146
  // ─── V2 Permission Hook ─────────────────────────────────────────────────
@@ -1082,6 +1150,8 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
1082
1150
  *
1083
1151
  * The V2 permission.hook API (when available) provides:
1084
1152
  * ctx.permission.hook("evaluate", handler) — host permission evaluation
1153
+ * The handler receives one mutable PermissionEvaluation event; decisions are
1154
+ * written to event.effect/event.message (there is no separate V1-style output).
1085
1155
  *
1086
1156
  * Returns true if the hook was registered.
1087
1157
  */
@@ -1095,14 +1165,10 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
1095
1165
  }
1096
1166
 
1097
1167
  try {
1098
- await permCtx.hook('evaluate', async (rawEvent: unknown, rawOutput?: unknown) => {
1168
+ await permCtx.hook('evaluate', async (rawEvent: unknown) => {
1099
1169
  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 => {
1170
+ const effectBefore = event?.effect
1171
+ const writeProof = (): void => {
1106
1172
  const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
1107
1173
  if (!proofPath || !event || !Array.isArray(event.resources)) return
1108
1174
  try {
@@ -1113,61 +1179,89 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
1113
1179
  agent: event.agent,
1114
1180
  sessionID: event.sessionID,
1115
1181
  effect: event.effect,
1182
+ message: event.message,
1116
1183
  eventFields: ['resources', 'agent', 'sessionID', 'effect'].filter((key) =>
1117
1184
  Object.hasOwn(event, key),
1118
1185
  ),
1119
- outputPresent: output !== undefined,
1120
- statusBefore: statusBefore ?? null,
1121
- statusAfter: statusAfter ?? null,
1186
+ effectBefore: effectBefore ?? null,
1187
+ effectAfter: event.effect ?? null,
1122
1188
  })}\n`,
1123
1189
  )
1124
1190
  } catch {
1125
1191
  // Instrumentation is best-effort and must not affect permission flow.
1126
1192
  }
1127
1193
  }
1128
- const deny = (): void => {
1129
- if (!output) {
1130
- writeProof(undefined)
1131
- throw new Error('permission output is unavailable; denying V2 permission evaluation')
1194
+ const deny = (reason: string): void => {
1195
+ if (event === null || typeof event !== 'object') {
1196
+ throw new Error(
1197
+ `permission event is unavailable; denying V2 permission evaluation (${reason})`,
1198
+ )
1132
1199
  }
1133
- output.status = 'deny'
1134
- writeProof(output.status)
1200
+ event.effect = 'deny'
1201
+ event.message = `Pantheon delegation denied: ${reason}`
1202
+ if (event.effect !== 'deny') {
1203
+ throw new Error(
1204
+ `permission event is not mutable; denying V2 permission evaluation (${reason})`,
1205
+ )
1206
+ }
1207
+ writeProof()
1135
1208
  }
1136
1209
 
1137
- if (output?.status === 'deny') {
1138
- writeProof(output.status)
1210
+ if (event?.effect === 'deny') {
1211
+ writeProof()
1139
1212
  return // explicit host deny is final
1140
1213
  }
1141
- if (event == null || !Array.isArray(event.resources)) {
1142
- deny()
1214
+ if (event == null) {
1215
+ deny('permission event is missing')
1216
+ return
1217
+ }
1218
+ if (!Array.isArray(event.resources) || event.resources.length === 0) {
1219
+ deny('permission resources are missing or malformed')
1143
1220
  return
1144
1221
  }
1145
1222
  const targets = event.resources.map(v2ResourceTarget)
1146
1223
  if (targets.some((target) => target === undefined)) {
1147
- deny()
1224
+ deny('permission resource target is malformed')
1148
1225
  return
1149
1226
  }
1150
1227
  const hasPantheonTarget = targets.some((target) => PANTHEON_AGENTS.has(target ?? ''))
1151
1228
  let session: AuthoritativeSession | undefined
1152
1229
  if (hasPantheonTarget) {
1230
+ const sessionID = typeof event.sessionID === 'string' ? event.sessionID.trim() : ''
1231
+ if (!sessionID) {
1232
+ deny('caller session ID is missing')
1233
+ return
1234
+ }
1235
+
1236
+ const sessionDomain = domains.session
1237
+ if (!sessionDomain || typeof sessionDomain.get !== 'function') {
1238
+ deny('session lookup API is unavailable')
1239
+ return
1240
+ }
1241
+
1242
+ let retrieved: unknown
1153
1243
  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
1244
+ retrieved = await sessionDomain.get({ sessionID })
1160
1245
  } catch {
1161
- deny()
1246
+ deny('caller session lookup failed')
1162
1247
  return
1163
1248
  }
1249
+ if (retrieved === null || retrieved === undefined) {
1250
+ deny('caller session was not found')
1251
+ return
1252
+ }
1253
+ if (typeof retrieved !== 'object' || Array.isArray(retrieved)) {
1254
+ deny('caller session lookup returned invalid data')
1255
+ return
1256
+ }
1257
+ session = retrieved as AuthoritativeSession
1164
1258
  }
1165
1259
 
1166
1260
  // Native-only resources remain under host policy. If a Pantheon target
1167
1261
  // 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)
1262
+ const decision = evaluateV2Delegation(event, session, event.effect)
1263
+ if (decision === 'deny') deny('caller or target is not allowed by delegation policy')
1264
+ else writeProof()
1171
1265
  })
1172
1266
  return true
1173
1267
  } catch {
@@ -1256,30 +1350,13 @@ export const plugin = define({
1256
1350
  }
1257
1351
  })
1258
1352
 
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) {
1353
+ // ─── Phase 2: V2 Tool Registration (best-effort) ─────────────────
1354
+ const toolsRegistered = await registerV2Tools(context).catch(() => false)
1355
+ if (!toolsRegistered) {
1279
1356
  markUnsupported('tool-transform')
1280
1357
  }
1281
1358
 
1282
- // ─── Phase 4: V2 Event Subscription (best-effort) ────────────────
1359
+ // ─── Phase 3: V2 Event Subscription (best-effort) ────────────────
1283
1360
  const eventCleanup = subscribeV2Events(context)
1284
1361
  if (eventCleanup) {
1285
1362
  activeCleanups.push(eventCleanup)
@@ -1287,12 +1364,18 @@ export const plugin = define({
1287
1364
  markUnsupported('event-stream')
1288
1365
  }
1289
1366
 
1290
- // ─── Phase 5: V2 Session Hooks (best-effort) ─────────────────────
1367
+ // ─── Phase 4: V2 Session Hooks (best-effort) ─────────────────────
1291
1368
  const sessionHooksRegistered = await registerV2SessionHooks(context).catch(() => false)
1292
1369
  if (!sessionHooksRegistered) {
1293
1370
  markUnsupported('session-hooks')
1294
1371
  }
1295
1372
 
1373
+ // ─── Phase 5: V2 Tool Hooks (best-effort) ────────────────────────
1374
+ const toolHooksRegistered = await registerV2ToolHooks(context).catch(() => false)
1375
+ if (!toolHooksRegistered) {
1376
+ markUnsupported('tool-execute-hooks')
1377
+ }
1378
+
1296
1379
  // ─── Phase 6: V2 Permission Hook (best-effort) ───────────────────
1297
1380
  await registerV2PermissionHook(context).catch(() => false)
1298
1381