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.
- package/AGENTS.md +58 -15
- package/CHANGELOG.md +37 -100
- package/README.md +78 -102
- package/README.pt-BR.md +19 -37
- package/package.json +1 -2
- package/plugin.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +2 -3
- package/scripts/doctor.mjs +22 -104
- package/scripts/hooks/scan-secrets.sh +97 -0
- package/scripts/install/config-migration.mjs +9 -42
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode.mjs +114 -43
- package/scripts/install/strings.mjs +0 -8
- package/scripts/probe-pantheon-cost.mjs +3 -16
- package/scripts/test-opencode-v2-sandbox.sh +203 -786
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/skills-lock.json +3 -4
- package/src/agents/aphrodite.md +2 -9
- package/src/agents/demeter.md +0 -9
- package/src/agents/gaia.md +1 -0
- package/src/agents/hephaestus.md +0 -9
- package/src/agents/hermes.md +2 -9
- package/src/agents/mnemosyne.md +14 -42
- package/src/agents/prometheus.md +0 -9
- package/src/agents/themis.md +0 -1
- package/src/agents/zeus.md +180 -19
- package/src/instructions/agent-return-format.instructions.md +48 -3
- package/src/instructions/memory-protocol.instructions.md +8 -9
- package/src/instructions/zeus-anti-stall.instructions.md +54 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +71 -0
- package/src/instructions/zeus-memory-operations.instructions.md +4 -4
- package/src/instructions/zeus-timeout-retry.instructions.md +81 -7
- package/src/pantheon/v2-delegation-enforce.ts +2 -1
- package/src/plugin-v2.ts +251 -168
- package/src/plugins/pantheon-hooks.ts +35 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +26 -50
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +25 -60
- package/src/routing.yml +4 -2
- package/src/skills/README.md +7 -12
- package/src/skills/agent-coordination/SKILL.md +83 -13
- package/src/skills/auto-continue/SKILL.md +13 -14
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/pantheon/secret-scanner.ts +0 -489
- package/src/plugins/tui/README.md +0 -14
- 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
|
|
16
|
-
* throws on
|
|
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
|
-
*
|
|
246
|
-
*
|
|
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
|
|
735
|
-
*
|
|
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
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
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
|
|
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
|
|
774
|
-
*
|
|
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
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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`.
|
|
1017
|
-
//
|
|
1075
|
+
// `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
|
|
1076
|
+
// therefore inert by construction, not by misconfiguration.
|
|
1018
1077
|
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
1100
|
+
// Advisory: log only, never block.
|
|
1037
1101
|
appendV2HookLog(scan.stderr)
|
|
1038
1102
|
} else if (decision.action === 'fail-closed') {
|
|
1039
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1140
|
+
// Tool after-hook not supported
|
|
1073
1141
|
}
|
|
1074
1142
|
|
|
1075
|
-
return
|
|
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
|
|
1168
|
+
await permCtx.hook('evaluate', async (rawEvent: unknown) => {
|
|
1099
1169
|
const event = rawEvent as V2PermissionEvent | null
|
|
1100
|
-
const
|
|
1101
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
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 (
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
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 (
|
|
1138
|
-
writeProof(
|
|
1210
|
+
if (event?.effect === 'deny') {
|
|
1211
|
+
writeProof()
|
|
1139
1212
|
return // explicit host deny is final
|
|
1140
1213
|
}
|
|
1141
|
-
if (event == null
|
|
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
|
-
|
|
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,
|
|
1169
|
-
if (decision === 'deny') deny()
|
|
1170
|
-
else writeProof(
|
|
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:
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
|
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
|
|
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
|
|