From 8bdb2c1675ac1e55eae5046891c9800bae21d5d6 Mon Sep 17 00:00:00 2001 From: xoai Date: Fri, 17 Jul 2026 18:21:36 +0700 Subject: [PATCH 1/2] docs(attestation): opencode Tier-A capabilities proven (probe, 2026-07-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finished the 2026-07-12 investigation's open probes. All three Tier-A capabilities attested with an instrumented transcript (each firing logged, so nothing-happened vs it-happened is decidable): pre-tool-veto tool.execute.before → spec-gate exit 2 → edit BLOCKED post-tool-events tool.execute.after fires with the result payload subagent-dispatch the task tool spawns an independent reviewer session Plus the load-bearing bonus: hooks fire INSIDE the dispatched subagent (the reviewer's own read fired before/after in the child session), so a subagent is not an enforcement escape hatch — the veto and config-gate apply within it. Under ADR-11 opencode's capabilities support Tier A. NOT flipping the contract here: that requires Sage to SHIP the adapter that uses these hooks (the generator currently writes only AGENTS.md + commands), else the contract would advertise enforcement sage init does not deliver — the false claim ADR-11 forbids. The adapter-shipping build is the next step, tracked separately. --- .../opencode-tier-a-2026-07-17.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/attestations/opencode-tier-a-2026-07-17.md diff --git a/docs/attestations/opencode-tier-a-2026-07-17.md b/docs/attestations/opencode-tier-a-2026-07-17.md new file mode 100644 index 0000000..ea08b59 --- /dev/null +++ b/docs/attestations/opencode-tier-a-2026-07-17.md @@ -0,0 +1,60 @@ +# opencode Tier-A capabilities — attested 2026-07-17 + +Follow-up to the 2026-07-12 investigation, which left opencode at Tier C with +`pre-tool-veto` proven "up to the last link". All three Tier-A capabilities are +now attested with an instrumented transcript (raw log: +`.sage/docs/development/opencode-tierA-attested-2026-07-17.log`). Every probe +logs each firing, so nothing-happened and it-happened are distinguishable — the +rule the July investigation was built on. + +Probed with opencode 1.15.3, plugin API `@opencode-ai/plugin`. The capability +attestations are model-independent (they concern whether the platform's plugin +API fires hooks, not model quality); the probe session ran on the platform's +configured model. + +## 1. pre-tool-veto — attested +`.opencode/plugin/sage.js` bridges `tool.execute.before` → `sage-spec-gate.sh` +(paths parsed out of `apply_patch`'s `patchText`, the gap the July probe found). +In a pre-spec cycle, asked to edit source: + + tool.execute.before fires → sage-spec-gate.sh exit 2 → throw → EDIT BLOCKED + +The edit-being-blocked transcript C15 requires. (Prior log: +`opencode-veto-attested-2026-07-17.log`.) + +## 2. post-tool-events — attested +`tool.execute.after` fires after every tool with the result payload: + + before tool=read + AFTER tool=read callID=call_00_… outputLen=148 + +The payload (tool, callID, args, output) is the PostToolUse shape Sage's +recorders need — manifest-sync, degradation-log, verify-tracker. + +## 3. subagent-dispatch — attested +A `reviewer` subagent (`.opencode/agent/reviewer.md`, `mode: subagent`) was +dispatched via the built-in `task` tool: + + before tool=task + session.next.agent.switched → agent="reviewer" (a NEW session id) + AFTER tool=task outputLen=3770 + +The reviewer ran in its own session and returned an independent review — Sage's +ADR-10 quality chain (independent reviewer) has a mechanism here. + +## The load-bearing bonus: hooks fire INSIDE the subagent +Between task-dispatch and task-return, the reviewer's OWN read fired the hooks: + + before tool=read / AFTER tool=read outputLen=148 (child session) + +So a dispatched subagent is NOT an enforcement escape hatch — the veto and the +config-gate apply inside it. This is the exact property the claude-code +"hooks-in-subagents" attestation turns on, and it holds on opencode too. + +## Derivation +ADR-11: Tier A = pre-tool-veto ∧ post-tool-events ∧ subagent-dispatch. All three +attested. opencode's platform capabilities support Tier A. (Delivery is gated on +Sage shipping the adapter that uses them — the generator work, tracked +separately.) + +verified: 2026-07-17 · expires-release: "1.5" · re-probe on opencode major bumps From 5106d5f7b49a8f369a047a3d10a0a8b3f3422f19 Mon Sep 17 00:00:00 2001 From: xoai Date: Fri, 17 Jul 2026 19:04:26 +0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(opencode):=20ship=20the=20enforcement?= =?UTF-8?q?=20adapter=20=E2=80=94=20opencode=20is=20Tier=20A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production port. sage init (opencode) now writes .opencode/plugin/sage.js and copies the gate scripts to .opencode/sage-hooks/. The adapter bridges opencode's plugin API to Sage's platform-agnostic gates: tool.execute.before → map tool (edit/write/apply_patch→Edit/Write, paths parsed from patchText; bash→Bash) → run PreToolUse gates → throw on exit 2 (opencode blocks the call) tool.execute.after → run PostToolUse recorders (verify-tracker, degradation- log, manifest-sync), non-blocking Proven: capabilities attested with an instrumented transcript (docs/attestations/opencode-tier-a-2026-07-17.md — veto blocks an edit, after fires with the result, task dispatches an independent reviewer, and hooks fire INSIDE the subagent so it is not an escape hatch). The shipped adapter is tested deterministically against the real gates (setup/adapter-test.mjs, 5/5: pre-spec veto, spec-approved allow, config self-disable veto, secrets veto, read untouched) — independent of opencode's model backend, which was flaky during the probe. platform.yaml: pre-tool-veto/post-tool-events/subagent-dispatch → attested with evidence + expiry; derived tier flips C→A (contract --check passes). STATUS.md, README truth table, and generator output all updated. session-events stays false — the platform's event hook fires but the adapter ships no session-init that uses it. The debugging that mattered: the first live edit 'landed' not because the veto failed but because a prior run's manifest-sync had advanced the cycle past pre-spec, where the gate correctly allows. On a clean pre-spec manifest the gate exits 2 — confirmed directly and in the deterministic test. Instrument the mechanism, not the outcome. --- README.md | 2 +- .../platforms/community/opencode/STATUS.md | 32 +++-- .../community/opencode/platform.yaml | 113 +++++++++++------ .../community/opencode/setup/adapter-test.mjs | 56 ++++++++ .../opencode/setup/generate-opencode.sh | 39 ++++++ .../community/opencode/setup/sage-plugin.js | 120 ++++++++++++++++++ 6 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 runtime/platforms/community/opencode/setup/adapter-test.mjs create mode 100644 runtime/platforms/community/opencode/setup/sage-plugin.js diff --git a/README.md b/README.md index 44df6cd..cd23fde 100644 --- a/README.md +++ b/README.md @@ -855,7 +855,7 @@ spec-gate hook) and end-to-end CI: | antigravity *(community)* | **C** | — | — | — | — | ✅ | [2026-07-12](develop/conformance/reports/antigravity-2026-07-12.md) | | codex *(community)* | **C** | — | — | — | — | ✅ | [2026-07-12](develop/conformance/reports/codex-2026-07-12.md) | | gemini-cli *(community)* | **C** | — | — | — | — | ✅ | [2026-07-12](develop/conformance/reports/gemini-cli-2026-07-12.md) | -| opencode *(community)* | **C** | — | — | — | — | ✅ | [2026-07-12](develop/conformance/reports/opencode-2026-07-12.md) | +| opencode *(community)* | **A** | 📝 | 📝 | 📝 | — | ✅ | [2026-07-12](develop/conformance/reports/opencode-2026-07-12.md) | ✅ checked · 📝 attested (evidence + expiry) · — not available diff --git a/runtime/platforms/community/opencode/STATUS.md b/runtime/platforms/community/opencode/STATUS.md index 26277c7..e5f94f0 100644 --- a/runtime/platforms/community/opencode/STATUS.md +++ b/runtime/platforms/community/opencode/STATUS.md @@ -1,16 +1,22 @@ -# opencode — community-maintained (experimental) +# opencode — community-maintained, Tier A (2026-07-17) -**Tier:** community. Not a first-class Sage platform. +**Tier:** A — mechanically enforced. `sage init` ships an enforcement adapter +(`.opencode/plugin/sage.js`) that vetoes edits, records the audit trail, and +dispatches independent subagent reviews, all through opencode's plugin API. -- **Generation-tested only.** CI checks that this platform's generator produces - files and that its shell parses. It is not exercised end-to-end. -- **Quality chain unavailable.** The sub-agent reviews (auto-review, auto-QA, - independent Gate 3) and the PreToolUse spec-gate hook are Claude Code only. - On this platform, Rule 3 and Rule 5 are enforced by prose alone, and any - skipped review degrades loudly (announced + logged). See the per-platform - enforcement table in the root README. -- **First-class platforms** — full quality chain and end-to-end CI — are - `claude-code` and `generic`. +- **Enforced, not prose.** The adapter bridges `tool.execute.before` to Sage's + gate scripts and `throw`s on exit 2 — opencode blocks the call. The spec-gate, + tdd-gate, secrets-gate, config-gate and verify-gate all apply, including + **inside dispatched subagents** (proven: the reviewer's own tool calls fire the + hooks). Rules 3 and 5 are mechanical here, not advisory. +- **Proven.** Capabilities attested with an instrumented transcript + (`docs/attestations/opencode-tier-a-2026-07-17.md`); the shipped adapter is + tested deterministically against the real gates + (`setup/adapter-test.mjs`, 5/5), independent of opencode's model backend. +- **The honest edge.** No native skill discovery (system skills are inlined into + `AGENTS.md`), and opencode's model backend was flaky during the probe — which + is why the load-bearing proof is the deterministic adapter test, not a single + live session. -Contributions welcome. If you rely on this platform, help keep its generator -current; the core team does not test it per release. +Maintainer: sage-core. Re-probe on opencode major version bumps (attestations +expire at release 1.5). diff --git a/runtime/platforms/community/opencode/platform.yaml b/runtime/platforms/community/opencode/platform.yaml index 66c6b94..a6f89c3 100644 --- a/runtime/platforms/community/opencode/platform.yaml +++ b/runtime/platforms/community/opencode/platform.yaml @@ -1,58 +1,99 @@ --- name: opencode description: > - OpenCode — community-maintained. Sage generates an instructions file and commands. - It enforces nothing: no hook surface, so every Sage rule here is prose, and the - v1.2.1 eval measured what prose alone is worth. + OpenCode — Tier A as of 2026-07-17. Sage ships an enforcement adapter + (.opencode/plugin/sage.js) that bridges opencode's plugin hooks to Sage's + gate scripts: tool.execute.before vetoes an edit, tool.execute.after records + the audit trail, and the task tool dispatches an independent reviewer — with + hooks firing inside the subagent, so it is not an escape hatch. contract-version: 2 -maintainer: "unclaimed" # R113: nobody answers when this goes red. +maintainer: "sage-core" capabilities: - context-injection: true # an instructions file is generated - command-delivery: true # commands are generated - native-skill-discovery: false # the generator emits skills; nothing has verified they are ever triggered - pre-tool-veto: false # no hook surface. Nothing blocks an edit. - post-tool-events: false - subagent-dispatch: false # no evidence Sage has ever dispatched one here - session-events: false + context-injection: true # AGENTS.md at project root + command-delivery: true # .opencode/commands/ → the / menu + native-skill-discovery: false # no discovery; system skills are INLINED into AGENTS.md + pre-tool-veto: attested # tool.execute.before → gate exit 2 → throw → edit blocked + post-tool-events: attested # tool.execute.after fires with the tool result + subagent-dispatch: attested # the task tool spawns an independent reviewer session + session-events: false # the platform's event hook fires session.*, but the adapter ships no session-init that uses it + +attestations: + - capability: pre-tool-veto + evidence: docs/attestations/opencode-tier-a-2026-07-17.md + verified: 2026-07-17 + expires-release: "1.5" + note: > + The July 2026 investigation proved the chain up to the last link and left + it false. The 2026-07-17 probe closed it: paths parsed out of apply_patch's + patchText, sage-spec-gate.sh returned exit 2 in a pre-spec cycle, the + adapter threw, and opencode BLOCKED the edit. The shipped adapter is tested + deterministically (setup/adapter-test.mjs, 5/5) driving the real gates — + pre-spec veto, config self-disable veto, secrets veto — independent of + opencode's model backend. Undocumented platform behaviour that can regress + silently, so it expires and is re-probed. + + - capability: post-tool-events + evidence: docs/attestations/opencode-tier-a-2026-07-17.md + verified: 2026-07-17 + expires-release: "1.5" + note: > + tool.execute.after fires after every tool with {tool, callID, args, + output} — the PostToolUse payload sage-verify-tracker, sage-degradation-log + and sage-manifest-sync need. Logged firing on a real session. + + - capability: subagent-dispatch + evidence: docs/attestations/opencode-tier-a-2026-07-17.md + verified: 2026-07-17 + expires-release: "1.5" + note: > + The built-in task tool dispatched a `reviewer` subagent into its OWN + session, which returned an independent review. The load-bearing half: the + reviewer's own read fired tool.execute.before/after in the child session, + so the veto and config-gate apply inside dispatched subagents — a subagent + is not an enforcement escape hatch. This is the property ADR-10's quality + argument rests on. supported-os: [linux, macos] command-prefix: "/" -install-method: manual-copy +install-method: generated artifacts: instructions: AGENTS.md commands-dir: .opencode/commands + plugin: .opencode/plugin/sage.js + hooks-dir: .opencode/sage-hooks --- -# Tier C, and honestly so - -Every `false` above means nobody has checked. That is what an unchecked -capability is worth under ADR-11 — not a benefit of the doubt. +# Tier A — mechanically enforced, and proven so -To improve this: run `develop/conformance/run-conformance.sh opencode`, make the -checks pass, attach transcripts for what cannot be checked for free, and claim -`maintainer:`. That is the whole contribution surface. +The three capabilities that decide the tier are attested with an instrumented +transcript (`docs/attestations/opencode-tier-a-2026-07-17.md`) and the shipped +adapter is tested deterministically against the real gate scripts +(`setup/adapter-test.mjs`). Every claim above is either checkable by conformance +or backed by evidence with an expiry — nothing is typed on faith. -# Why `pre-tool-veto: false` — the chain is proven except its last link +## How enforcement reaches opencode -Investigated 2026-07-12. Full transcript of the investigation is kept with -the maintainers; the findings that matter are below. +`sage init` (opencode) writes `.opencode/plugin/sage.js` and copies the gate +scripts to `.opencode/sage-hooks/`. The adapter: -**OpenCode's veto is documented and the mechanism demonstrably works:** +- **vetoes** — `tool.execute.before` maps the opencode tool (`edit`/`write`/ + `apply_patch` → `Edit`/`Write`, paths parsed from `patchText`; `bash` → + `Bash`) to the gate payload, runs the PreToolUse gates, and `throw`s on exit 2. + opencode honours the throw by blocking the call. +- **records** — `tool.execute.after` runs the PostToolUse recorders + (verify-tracker, degradation-log, manifest-sync) for their side effects. -1. The plugin LOADS (`.opencode/plugin/` and `.opencode/plugins/` are both scanned). -2. `tool.execute.before` FIRES — an instrumented pass captured every tool. -3. Sage's gate RAN, spawned from inside the hook. -4. And it correctly ALLOWED — because opencode's edit tool is **`apply_patch`, - whose only argument is `patchText`. There is no `filePath`.** The adapter handed - the gate an empty path, and a gate with no file to gate allows. That is the - ADAPTER failing, not the veto. +The gates are the same platform-agnostic scripts Claude Code runs; they read +`.sage/`, not `.claude/`, so a single set enforces on both platforms. -**What was never observed: opencode honouring the `throw` by blocking an edit.** -Without that transcript the capability cannot be `attested` (C15), so it is `false`. +## The honest edge -**To finish:** parse the paths out of `patchText` (`*** Update File: `), run -one probe against a `pre-spec` cycle, and check BOTH that the edit did not land AND -that the hook log shows the gate returning 2. Checking only the file is how you -record a crashed run as a successful veto. +`native-skill-discovery` stays `false` — opencode has no description-triggered +discovery, so the system skills are inlined into `AGENTS.md` (Tier A is about +enforcement, not discovery). And opencode's model backend was flaky during the +probe (frequent startup hangs), which is why the load-bearing proof is the +deterministic `adapter-test.mjs` driving the real hooks, not a single live +session. The live probe supplies the one thing the harness cannot: that opencode +honours the `throw`. diff --git a/runtime/platforms/community/opencode/setup/adapter-test.mjs b/runtime/platforms/community/opencode/setup/adapter-test.mjs new file mode 100644 index 0000000..c392ef0 --- /dev/null +++ b/runtime/platforms/community/opencode/setup/adapter-test.mjs @@ -0,0 +1,56 @@ +// Deterministic test of the production opencode adapter — drives its hooks +// directly against the real gate scripts. No opencode model backend needed. +import { SagePlugin } from "./.opencode/plugin/sage.js" +import { writeFileSync, mkdirSync } from "fs" +import { join } from "path" + +const root = process.cwd() +let pass = 0, fail = 0 +const ok = (name, cond) => { cond ? pass++ : fail++; console.log(` ${cond?"✓":"✗"} ${name}`) } + +function setManifest(state) { + mkdirSync(join(root, ".sage/work/001-demo"), { recursive: true }) + writeFileSync(join(root, ".sage/work/001-demo/manifest.md"), + `---\ncycle_id: "001-demo"\ngate_state: ${state}\nstatus: in-progress\n---\n# demo\n`) +} +function setConfig(enforce) { + writeFileSync(join(root, ".sage/config.yaml"), + `sage-version: "1.3.8"\nhard_enforcement: ${enforce}\n`) +} +async function threw(hooks, tool, args) { + try { await hooks["tool.execute.before"]({ tool }, { args }); return false } + catch { return true } +} + +const hooks = await SagePlugin({ directory: root }) + +// 1. pre-spec source edit → VETO +setConfig("true"); setManifest("pre-spec") +ok("pre-spec source edit is VETOED", + await threw(hooks, "edit", { filePath: join(root, "src/calc.py"), + oldString: "def add", newString: "def add\ndef multiply(a,b): return a*b" })) + +// 2. spec-approved → allowed +setManifest("spec-approved") +ok("spec-approved source edit is ALLOWED", + !(await threw(hooks, "edit", { filePath: join(root, "src/calc.py"), + newString: "def multiply(a,b): return a*b" }))) + +// 3. config self-disable → VETO (the meta-gate, through the adapter) +setManifest("spec-approved") +ok("flipping hard_enforcement off is VETOED", + await threw(hooks, "edit", { filePath: join(root, ".sage/config.yaml"), + oldString: "hard_enforcement: true", newString: "hard_enforcement: false" })) + +// 4. hardcoded secret → VETO +setManifest("spec-approved") +ok("a hardcoded live key is VETOED", + await threw(hooks, "write", { filePath: join(root, "src/keys.py"), + content: 'API = "payco_live_4eC39HqLyjWDarjtT"' })) + +// 5. a plain read → never gated +ok("a read is never gated", + !(await threw(hooks, "read", { filePath: join(root, "src/calc.py") }))) + +console.log(`\n adapter: ${pass} pass · ${fail} fail`) +process.exit(fail ? 1 : 0) diff --git a/runtime/platforms/community/opencode/setup/generate-opencode.sh b/runtime/platforms/community/opencode/setup/generate-opencode.sh index 36eed81..d657c85 100644 --- a/runtime/platforms/community/opencode/setup/generate-opencode.sh +++ b/runtime/platforms/community/opencode/setup/generate-opencode.sh @@ -251,6 +251,43 @@ Classification only. AGENT_EOF echo " ✓ sage-classifier.md" +# ═══════════════════════════════════════════════════════════════ +# Enforcement adapter — the Tier-A port (attested 2026-07-17) +# opencode's plugin API vetoes (tool.execute.before → throw), records +# (tool.execute.after), and both fire inside dispatched subagents. This ships +# the bridge from those hooks to Sage's platform-agnostic gate scripts. +# ═══════════════════════════════════════════════════════════════ +echo "" +echo "🛡 Installing enforcement adapter..." +mkdir -p "$OC_DIR/plugin" "$OC_DIR/sage-hooks" + +# The gate scripts the adapter calls. They are platform-agnostic (they read +# .sage/, not .claude/) — copied to a stable path so the adapter never depends +# on where the framework is vendored. +GATE_SRC="$SAGE_DIR/runtime/platforms/claude-code/hooks" +GATE_CORE="$CORE/gates/scripts" +for g in sage-spec-gate.sh sage-tdd-gate.sh sage-bookkeeping-gate.sh \ + sage-secrets-gate.sh sage-config-gate.sh sage-verify-gate.sh \ + sage-verify-tracker.sh sage-degradation-log.sh sage-manifest-sync.sh; do + if [ -f "$GATE_SRC/$g" ]; then + cp "$GATE_SRC/$g" "$OC_DIR/sage-hooks/$g" && chmod +x "$OC_DIR/sage-hooks/$g" + fi +done +# The gate scripts that delegate to core check-scripts need those too. +for c in sage-verify.sh sage-spec-check.sh sage-hallucination-check.sh; do + [ -f "$GATE_CORE/$c" ] && cp "$GATE_CORE/$c" "$OC_DIR/sage-hooks/$c" && \ + chmod +x "$OC_DIR/sage-hooks/$c" +done + +# The adapter itself (static — resolves paths at runtime from the project root). +if [ -f "$(dirname "$0")/sage-plugin.js" ]; then + cp "$(dirname "$0")/sage-plugin.js" "$OC_DIR/plugin/sage.js" + echo " ✓ .opencode/plugin/sage.js (veto + audit, incl. inside subagents)" + echo " ✓ .opencode/sage-hooks/ (gate scripts)" +else + echo " ⚠ sage-plugin.js not found — enforcement adapter NOT installed" +fi + # ═══════════════════════════════════════════════════════════════ # Project state — .sage/ initialization # ═══════════════════════════════════════════════════════════════ @@ -272,6 +309,8 @@ echo "Files written:" echo " AGENTS.md (read by Opencode as system context)" echo " .opencode/commands/ (markdown command definitions)" echo " .opencode/agents/ (markdown sub-agent definitions)" +echo " .opencode/plugin/sage.js (enforcement adapter — the Tier-A port)" +echo " .opencode/sage-hooks/ (gate scripts the adapter runs)" echo "" echo "Next steps:" echo " - Run \`opencode\` to start a session" diff --git a/runtime/platforms/community/opencode/setup/sage-plugin.js b/runtime/platforms/community/opencode/setup/sage-plugin.js new file mode 100644 index 0000000..fbcd1fa --- /dev/null +++ b/runtime/platforms/community/opencode/setup/sage-plugin.js @@ -0,0 +1,120 @@ +// ═══════════════════════════════════════════════════════════════ +// sage.js — the Sage enforcement adapter for opencode (production) +// +// Bridges opencode's plugin hooks to Sage's platform-agnostic gate scripts, so +// the same enforcement Claude Code gets from PreToolUse/PostToolUse hooks runs +// on opencode. Attested 2026-07-17 (docs/attestations/opencode-tier-a-*): +// tool.execute.before vetoes, tool.execute.after records, and both fire INSIDE +// dispatched subagents — so a subagent is not an escape hatch. +// +// HOW IT MAPS. opencode names tools and args differently from Claude Code; the +// gates speak Claude Code's payload. This translates: +// edit/patch/apply_patch → Edit (file_path, new_string; path parsed from +// apply_patch's patchText, which has no +// filePath field of its own) +// write → Write (file_path, content) +// bash → Bash (command) +// and hands each gate {tool_name, tool_input, cwd} on stdin, reading exit 2 as +// a veto (thrown → opencode blocks the call). +// +// FAIL OPEN. A missing gate, a spawn error, an unparseable arg — none of it +// blocks the user. Guards are guards: the cost of a false block is a broken +// session, so every uncertainty resolves to allow. The gates themselves also +// fail open, so this is belt-and-braces. +// ═══════════════════════════════════════════════════════════════ +import { spawnSync } from "child_process" +import { existsSync } from "fs" +import { join } from "path" + +// PreToolUse gates, in the order Claude Code runs them. Each is a script under +// .opencode/sage-hooks/ that reads a payload on stdin and exits 0/2. +const PRE_EDIT_GATES = [ + "sage-spec-gate.sh", + "sage-tdd-gate.sh", + "sage-bookkeeping-gate.sh", + "sage-secrets-gate.sh", + "sage-config-gate.sh", +] +const PRE_BASH_GATES = ["sage-verify-gate.sh", "sage-config-gate.sh"] +// PostToolUse recorders — never block; run for their side effects. +const POST_HOOKS = ["sage-verify-tracker.sh", "sage-degradation-log.sh", + "sage-manifest-sync.sh"] + +// opencode tool + args → the Claude-Code payload the gates read. Returns null +// when the tool is not one the gates care about (→ allow without spawning). +function toPayload(tool, args, root) { + const t = (tool || "").toLowerCase() + const a = args || {} + if (t === "bash" || t === "shell") { + const command = a.command || a.cmd || a.script || "" + return { kind: "bash", tool_name: "Bash", tool_input: { command }, cwd: root } + } + if (t === "write") { + return { kind: "edit", tool_name: "Write", + tool_input: { file_path: a.filePath || a.file_path || a.path || "", + content: a.content || a.text || "" }, cwd: root } + } + if (t === "edit") { + return { kind: "edit", tool_name: "Edit", + tool_input: { file_path: a.filePath || a.file_path || a.path || "", + old_string: a.oldString || a.old_string || "", + new_string: a.newString || a.new_string || + a.content || "" }, cwd: root } + } + if (t === "patch" || t === "apply_patch" || t === "applypatch") { + const body = a.patchText || a.patch || a.input || "" + const m = String(body).match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/m) + return { kind: "edit", tool_name: "Edit", + tool_input: { file_path: m ? m[1].trim() : "", new_string: body }, + cwd: root } + } + return null +} + +function runGate(root, script, payload) { + const path = join(root, ".opencode", "sage-hooks", script) + if (!existsSync(path)) return { ran: false, blocked: false, reason: "" } + try { + const r = spawnSync("bash", [path], { + input: JSON.stringify(payload), encoding: "utf8", timeout: 15000, + env: { ...process.env, CLAUDE_PROJECT_DIR: root }, + }) + return { ran: true, blocked: r.status === 2, reason: (r.stderr || "").trim() } + } catch { + return { ran: false, blocked: false, reason: "" } // fail open + } +} + +export const SagePlugin = async ({ directory }) => { + const root = directory || process.cwd() + // Nothing to enforce if this is not a Sage project. + if (!existsSync(join(root, ".sage"))) return {} + + return { + "tool.execute.before": async (input, output) => { + const payload = toPayload(input?.tool, output?.args, root) + if (!payload) return + const gates = payload.kind === "bash" ? PRE_BASH_GATES : PRE_EDIT_GATES + for (const g of gates) { + const res = runGate(root, g, payload) + if (res.blocked) { + throw new Error(res.reason || + `Sage ${g} blocked this ${input?.tool} call.`) + } + } + }, + "tool.execute.after": async (input, output) => { + // Record for the audit trail / verify-tracker. Non-blocking by contract. + const t = (input?.tool || "").toLowerCase() + let payload = toPayload(input?.tool, input?.args, root) + // Bash "after" needs the command that ran; opencode carries args on input. + if (!payload && t === "bash") { + payload = { tool_name: "Bash", + tool_input: { command: (input?.args || {}).command || "" }, + cwd: root } + } + if (!payload) return + for (const h of POST_HOOKS) runGate(root, h, payload) + }, + } +}