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.
- package/AGENTS.md +27 -820
- package/CHANGELOG.md +130 -0
- package/README.md +205 -30
- package/README.pt-BR.md +47 -23
- package/package.json +10 -5
- package/plugin.json +1 -1
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +162 -28
- package/scripts/doctor.mjs +104 -22
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +154 -7
- package/scripts/install/opencode.mjs +202 -40
- package/scripts/install/shared.mjs +115 -7
- package/scripts/install/strings.mjs +8 -0
- package/scripts/probe-pantheon-cost.mjs +16 -3
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +820 -86
- package/skills-lock.json +6 -5
- package/src/agents/aphrodite.md +17 -28
- package/src/agents/athena.md +1 -0
- package/src/agents/demeter.md +8 -18
- package/src/agents/hephaestus.md +10 -11
- package/src/agents/hermes.md +15 -30
- package/src/agents/iris.md +1 -0
- package/src/agents/nyx.md +1 -0
- package/src/agents/prometheus.md +16 -10
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +5 -3
- package/src/agents/zeus.md +17 -280
- package/src/instructions/agent-return-format.instructions.md +4 -50
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -121
- package/src/instructions/zeus-anti-stall.instructions.md +8 -111
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
- package/src/pantheon/secret-scanner.ts +489 -0
- package/src/pantheon/v2-delegation-enforce.ts +86 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +254 -102
- package/src/plugins/pantheon-hooks.ts +82 -35
- package/src/plugins/tui/README.md +14 -0
- package/src/plugins/tui/dist/tui.d.ts +11 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -62
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +163 -107
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +12 -9
- package/src/skills/README.md +9 -4
- package/src/skills/agent-coordination/SKILL.md +13 -83
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +38 -28
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/council-synthesis/SKILL.md +29 -0
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/scripts/hooks/scan-secrets.sh +0 -97
- package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
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
|
|
16
|
-
*
|
|
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,
|
|
23
|
-
*
|
|
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
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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?: (
|
|
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
|
|
723
|
-
*
|
|
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
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
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
|
|
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
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
|
953
|
-
//
|
|
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
|
-
//
|
|
956
|
-
//
|
|
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:
|
|
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
|
|
968
|
-
|
|
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 (
|
|
973
|
-
//
|
|
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
|
-
//
|
|
978
|
-
|
|
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
|
-
//
|
|
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" —
|
|
1058
|
+
// "execute.after" — the V1 parity chain (task-result-guard →
|
|
1059
|
+
// context-sandbox → read-enhancer) rebuilt on the V2 event shape.
|
|
989
1060
|
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1070
|
+
await toolCtx.hook('execute.after', (event: unknown) => v2ExecuteAfter(event))
|
|
998
1071
|
} catch {
|
|
999
|
-
|
|
1072
|
+
markUnsupported('tool-execute-after-hook')
|
|
1000
1073
|
}
|
|
1001
1074
|
|
|
1002
|
-
return
|
|
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) —
|
|
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
|
|
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', (
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
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
|
|
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
|
|