You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/07/03 01:12:27 UTC
(magpie) branch main updated: feat(agent-guard): add OpenCode harness adapter — closes a Claude-coupled gap (#686)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git
The following commit(s) were added to refs/heads/main by this push:
new b72df9fa feat(agent-guard): add OpenCode harness adapter — closes a Claude-coupled gap (#686)
b72df9fa is described below
commit b72df9fa6f126a1a86560b80ee61f2280040426e
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Fri Jul 3 03:12:21 2026 +0200
feat(agent-guard): add OpenCode harness adapter — closes a Claude-coupled gap (#686)
agent-guard was one of five substrate tools coupled to the Claude Code
harness on the LLM-integration (agent-harness) axis of the
vendor-neutrality score. Its guard *decisions* were already
harness-agnostic — `dispatch(command, cwd)` inspects a shell command and
returns a deny reason — and only the I/O shell (`main()`, reading Claude's
PreToolUse JSON) was Claude-specific.
This adds a second, genuine harness integration so the same guard core
enforces an identical rule set under OpenCode:
- `opencode_main()` (`agent-guard.py --opencode`): a harness-neutral entry
that reads `{"command","cwd"}` on stdin, runs the shared `dispatch()`,
and signals deny via a non-zero exit (fail-open otherwise).
- `opencode/plugin.js`: an OpenCode plugin on the `tool.execute.before`
hook that forwards each `bash` command to that entry and throws with the
deny reason to abort the call — the OpenCode equivalent of a Claude
`PreToolUse` deny. It defaults to the adopter tree's existing
`.claude/hooks/agent-guard.py`, so a repo already wired for Claude needs
no second copy.
Because both harnesses call `dispatch()`, every bundled and
skill-contributed guard, the `MAGPIE_*` overrides, and the deny reasons
are byte-for-byte identical across them.
`**Harness:**` becomes `Claude Code, OpenCode`, flipping agent-guard from
coupled to portable: the agent-harness score rises 15/20 → 16/20 (75% →
80%). Adds tests for the OpenCode adapter (asserting its decisions match
`dispatch`), the wiring docs, and the regenerated score block.
Co-authored-by: Tester <t...@example.com>
---
docs/labels-and-capabilities.md | 2 +-
docs/vendor-neutrality.md | 5 +-
tools/agent-guard/README.md | 45 +++++++++--
tools/agent-guard/opencode/plugin.js | 69 +++++++++++++++++
tools/agent-guard/src/agent_guard/__init__.py | 69 +++++++++++++++--
tools/agent-guard/tests/test_opencode.py | 103 ++++++++++++++++++++++++++
6 files changed, 280 insertions(+), 13 deletions(-)
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 1d2d9881..174d5c3a 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -239,7 +239,7 @@ it implements multiple contracts (e.g. `tools/gmail` provides both
| Tool | Capability / capabilities | Role |
|---|---|---|
-| [`tools/agent-guard`](../tools/agent-guard/) | `substrate:action-guard` | Deterministic `PreToolUse` guard dispatcher: blocks `gh`/`git` commands that would ping maintainers, carry a `Co-Authored-By` trailer, mark-ready prematurely, leak security language publicly, or empty a PR via force-push. Extensible — skills contribute guards via `guards.d` |
+| [`tools/agent-guard`](../tools/agent-guard/) | `substrate:action-guard` | Deterministic pre-execution guard dispatcher (harness-neutral core behind a Claude Code `PreToolUse` hook and an OpenCode `tool.execute.before` plugin): blocks `gh`/`git` commands that would ping maintainers, carry a `Co-Authored-By` trailer, mark-ready prematurely, leak security language publicly, or empty a PR via force-push. Extensible — skills contribute guards via `guards.d` |
| [`tools/agent-isolation`](../tools/agent-isolation/) | `substrate:sandbox` | Secure-agent sandbox helpers |
| [`tools/apache-projects`](../tools/apache-projects/) | `contract:project-metadata` | ASF project-metadata substrate (`apache/comdev` `apache-projects-mcp`); read-only `projects.apache.org/json` rosters / people / releases. Backs `contributor-nomination` and the security roster-resolution paths; tracked at `main`, not pinned |
| [`tools/asf-svn`](../tools/asf-svn/) | `contract:source-control` | ASF SVN tool adapter: source-control binding for `svn.apache.org` working copies (centralized model), `svn` CLI operation catalogue, `dist.apache.org` release-distribution helpers (stage/promote/prune), ASF committer/PMC authorization, and optional svnpubsub site publishing. The SVN counterpart to `tools/github/` for ASF projects. Also the `land` delegate for the `jira-patch` and `mail-patch` change-request backends (`s [...]
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 632e4071..93872507 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -571,15 +571,15 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
**LLM / agent-integration neutrality**
-**Agent harness: 15/20 substrate tools run under any harness unchanged (75%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
+**Agent harness: 16/20 substrate tools run under any harness unchanged (80%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
| Substrate tool | Substrate | Harness support | Verdict |
|---|---|---|---|
-| `agent-guard` | action-guard | Claude Code | ❌ coupled |
| `agent-isolation` | sandbox | Claude Code | ❌ coupled |
| `permission-audit` | sandbox | Claude Code | ❌ coupled |
| `sandbox-lint` | sandbox | Claude Code | ❌ coupled |
| `spec-loop` | framework-dev | Claude Code | ❌ coupled |
+| `agent-guard` | action-guard | Claude Code, OpenCode | ✅ portable |
| `dashboard-generator` | analytics | any | ✅ agnostic |
| `dev` | framework-dev | any | ✅ agnostic |
| `egress-gateway` | sandbox | any | ✅ agnostic |
@@ -599,6 +599,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
Harness → substrate tools it supports:
- **Claude Code** (5): `agent-guard`, `agent-isolation`, `permission-audit`, `sandbox-lint`, `spec-loop`
+- **OpenCode** (1): `agent-guard`
- **any harness** (15): `dashboard-generator`, `dev`, `egress-gateway`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`
**Model endpoint: neutral by construction — 4 default-approved endpoint classes across independent trust domains, plus adopter opt-in.** From the [`privacy-llm` registry](../tools/privacy-llm/models.md): the framework keys approval on *endpoint identity*, not on who hosts the model, so no single LLM vendor is privileged.
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index e6698785..b237aa68 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -7,6 +7,7 @@
- [Guards](#guards)
- [Per-command overrides](#per-command-overrides)
- [Wiring](#wiring)
+ - [OpenCode](#opencode)
- [Contributing guards](#contributing-guards)
- [Tests](#tests)
@@ -19,12 +20,22 @@
**Capability:** substrate:action-guard
-**Harness:** Claude Code
+**Harness:** Claude Code, OpenCode
-A deterministic Claude Code [`PreToolUse`](https://code.claude.com/docs/en/hooks)
-guard dispatcher. It inspects every `Bash` command **before it runs** and
-**denies** the ones that would break a hard framework rule — protections that
-must not depend on the model remembering a `SKILL.md` instruction.
+A deterministic pre-execution guard dispatcher. It inspects every shell command
+**before it runs** and **denies** the ones that would break a hard framework
+rule — protections that must not depend on the model remembering a `SKILL.md`
+instruction.
+
+The guard *decisions* live in one harness-agnostic core (`dispatch()`); a thin
+adapter per harness translates that harness's pre-tool hook to/from the core,
+so every wired harness enforces an identical rule set from one source of truth:
+
+- **Claude Code** — a [`PreToolUse`](https://code.claude.com/docs/en/hooks)
+ hook on the `Bash` matcher (the default, no-argument invocation).
+- **OpenCode** — a [plugin](https://opencode.ai/docs/plugins/) on the
+ `tool.execute.before` hook for the `bash` tool, which blocks a call by
+ throwing (`agent-guard.py --opencode`). See [Wiring](#wiring).
It is **stdlib-only** and is invoked directly as
`python3 <path>/agent_guard/__init__.py` (never via `uv run`) so it returns in a
@@ -103,6 +114,30 @@ secure setup (`~/.claude/scripts/agent-guard.py`); `/magpie-setup upgrade`,
`verify`, and the `setup-isolated-setup-install` / `…-update` skills keep it and
the settings.json entry in sync. See those skills for the exact steps.
+### OpenCode
+
+The same engine backs OpenCode via the plugin in
+[`opencode/plugin.js`](opencode/plugin.js). OpenCode aborts a tool call whose
+[`tool.execute.before`](https://opencode.ai/docs/plugins/) handler throws, so
+the plugin forwards each `bash` command to `agent-guard.py --opencode` and
+throws with the deny reason when the shared core denies it — the OpenCode
+equivalent of a Claude `PreToolUse` deny.
+
+Drop the plugin into OpenCode's plugin directory (`.opencode/plugin/` in the
+project, or `~/.config/opencode/plugin/` globally):
+
+```bash
+mkdir -p .opencode/plugin
+ln -s "<framework>/tools/agent-guard/opencode/plugin.js" .opencode/plugin/agent-guard.js
+```
+
+The plugin locates the engine at `.claude/hooks/agent-guard.py` under the
+worktree by default — so a repo already wired for Claude Code needs no second
+copy of the script — and honours `MAGPIE_AGENT_GUARD=/abs/path/agent-guard.py`
+to point elsewhere. Because both harnesses call `dispatch()`, the bundled and
+skill-contributed guards, the `MAGPIE_*` overrides, and the deny reasons are
+byte-for-byte identical across the two; nothing about a guard is harness-aware.
+
## Contributing guards
The hook is **wired once**. Beyond the two bundled guards, additional guards are
diff --git a/tools/agent-guard/opencode/plugin.js b/tools/agent-guard/opencode/plugin.js
new file mode 100644
index 00000000..a65f1b92
--- /dev/null
+++ b/tools/agent-guard/opencode/plugin.js
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// agent-guard for OpenCode.
+//
+// This is the OpenCode counterpart of the Claude Code `PreToolUse` hook: it
+// registers on OpenCode's `tool.execute.before` hook, inspects every `bash`
+// command *before it runs*, and blocks the ones a framework guard denies by
+// throwing — OpenCode aborts a tool call whose `tool.execute.before` handler
+// throws, and surfaces the message to the model, mirroring a Claude deny.
+//
+// The guard *decisions* are not reimplemented here. This plugin is a thin
+// adapter that forwards the command to the very same Python engine the Claude
+// hook uses (`agent-guard.py --opencode`), so both harnesses enforce an
+// identical rule set from one source of truth. Exit code 2 = deny (reason on
+// stdout); anything else = allow (fail-open, exactly like the Claude path, so a
+// guard glitch never wedges the session).
+//
+// Wiring: drop this file in `.opencode/plugin/` (project) or
+// `~/.config/opencode/plugin/` (global). Point it at the engine with
+// `MAGPIE_AGENT_GUARD=/abs/path/to/agent-guard.py`; it otherwise defaults to
+// the adopter tree's `.claude/hooks/agent-guard.py`, so a repo already set up
+// for Claude Code needs no extra copy of the script.
+
+import { spawnSync } from "node:child_process";
+import { join } from "node:path";
+
+/** @type {import("@opencode-ai/plugin").Plugin} */
+export const AgentGuardPlugin = async ({ worktree }) => {
+ const engine =
+ process.env.MAGPIE_AGENT_GUARD ||
+ join(worktree ?? process.cwd(), ".claude", "hooks", "agent-guard.py");
+
+ return {
+ "tool.execute.before": async (input, output) => {
+ if (input.tool !== "bash") return;
+ const command = output?.args?.command;
+ if (!command) return;
+
+ const result = spawnSync("python3", [engine, "--opencode"], {
+ input: JSON.stringify({ command, cwd: worktree }),
+ encoding: "utf8",
+ });
+
+ // 2 = deny (reason printed to stdout). Any other status — success, a
+ // missing engine, a Python error — is treated as allow so the guard can
+ // never hard-block the user on its own failure.
+ if (result.status === 2) {
+ throw new Error(
+ result.stdout.trim() || "agent-guard denied this command",
+ );
+ }
+ },
+ };
+};
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py
index f9a1d4fe..68aa7a19 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -15,11 +15,19 @@
# specific language governing permissions and limitations
# under the License.
-"""Deterministic ``PreToolUse`` guard dispatcher for apache-magpie.
+"""Deterministic pre-execution guard dispatcher for apache-magpie.
-Reads a Claude Code ``PreToolUse`` hook event on stdin, inspects the ``Bash``
-command, and **denies** the ones that would violate a hard framework rule
-that should never depend on the model remembering a SKILL.md instruction.
+Inspects a shell command before it runs and **denies** the ones that would
+violate a hard framework rule that should never depend on the model remembering
+a SKILL.md instruction. The guard decisions live in one harness-agnostic core
+(:func:`dispatch`); a thin per-harness adapter translates that harness's
+pre-tool hook to/from the core, so every wired harness enforces one rule set:
+
+* :func:`main` — Claude Code ``PreToolUse`` hook (reads the event on stdin,
+ emits a deny decision as JSON). The default, no-argument invocation.
+* :func:`opencode_main` — OpenCode ``tool.execute.before`` plugin adapter
+ (``--opencode``): reads ``{"command", "cwd"}`` on stdin, signals deny via a
+ non-zero exit so the plugin can throw and abort the tool call.
The engine ships two **bundled** guards — the universal ``git`` hygiene rules
that apply to every project:
@@ -493,6 +501,7 @@ def _emit_deny(reason: str) -> None:
def main() -> int:
+ """Claude Code ``PreToolUse`` entry point (the default invocation)."""
try:
event = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
@@ -507,5 +516,55 @@ def main() -> int:
return 0
+# Exit codes for the harness-neutral entry point (``--opencode`` and any future
+# harness whose hook blocks on a non-zero child exit): 0 = allow, DENY = block.
+ALLOW_EXIT = 0
+DENY_EXIT = 2
+
+
+def opencode_main() -> int:
+ """Harness-neutral entry point used by the OpenCode plugin.
+
+ Reads a minimal ``{"command": "...", "cwd": "..."}`` JSON object on stdin —
+ the shape the OpenCode ``tool.execute.before`` plugin forwards for the
+ ``bash`` tool — and runs the **same** :func:`dispatch` core that backs the
+ Claude Code hook. On a deny it writes the reason to stdout and exits
+ ``DENY_EXIT``; the plugin turns that non-zero exit into a thrown error that
+ aborts the tool call. On allow (or any malformed input) it exits
+ ``ALLOW_EXIT`` — fail-open, exactly like the Claude path, so a guard glitch
+ never wedges the user's session.
+
+ The guard *decisions* are therefore identical across harnesses; only this
+ thin I/O shell differs from :func:`main`.
+ """
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return ALLOW_EXIT
+ if not isinstance(event, dict):
+ return ALLOW_EXIT
+ command = str(event.get("command", ""))
+ cwd = event.get("cwd")
+ reason = dispatch(command, cwd if isinstance(cwd, str) else None)
+ if reason:
+ sys.stdout.write(reason + "\n")
+ return DENY_EXIT
+ return ALLOW_EXIT
+
+
+def cli(argv: list[str] | None = None) -> int:
+ """Route to the harness adapter named on the command line.
+
+ No argument → the Claude Code ``PreToolUse`` hook (:func:`main`); the
+ ``--opencode`` flag → the OpenCode adapter (:func:`opencode_main`). A single
+ self-contained file thus serves every wired harness, so ``/magpie-setup``
+ ships one script and each harness points its own hook at it.
+ """
+ args = sys.argv[1:] if argv is None else argv
+ if args and args[0] == "--opencode":
+ return opencode_main()
+ return main()
+
+
if __name__ == "__main__":
- raise SystemExit(main())
+ raise SystemExit(cli())
diff --git a/tools/agent-guard/tests/test_opencode.py b/tools/agent-guard/tests/test_opencode.py
new file mode 100644
index 00000000..8167e350
--- /dev/null
+++ b/tools/agent-guard/tests/test_opencode.py
@@ -0,0 +1,103 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for the OpenCode adapter entry point.
+
+The point of the OpenCode integration is that it enforces the *same* guard
+decisions as the Claude Code hook by sharing :func:`agent_guard.dispatch`.
+These tests drive the ``--opencode`` I/O shell and assert the exit-code
+protocol the OpenCode plugin relies on (2 = deny, 0 = allow).
+"""
+
+from __future__ import annotations
+
+import io
+import json
+
+import pytest
+
+import agent_guard
+from agent_guard import ALLOW_EXIT, DENY_EXIT, cli, opencode_main
+
+
+def _feed(monkeypatch: pytest.MonkeyPatch, payload: object) -> None:
+ text = payload if isinstance(payload, str) else json.dumps(payload)
+ monkeypatch.setattr("sys.stdin", io.StringIO(text))
+
+
+def test_denied_command_exits_deny_with_reason(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+):
+ # A Co-Authored-By trailer is one of the bundled deny rules.
+ _feed(monkeypatch, {"command": "git commit -m 'x\n\nCo-Authored-By: A <a...@b.c>'"})
+ rc = opencode_main()
+ out = capsys.readouterr().out
+ assert rc == DENY_EXIT
+ assert "commit-trailer" in out
+
+
+def test_allowed_command_exits_allow_silently(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+):
+ _feed(monkeypatch, {"command": "git status"})
+ rc = opencode_main()
+ assert rc == ALLOW_EXIT
+ assert capsys.readouterr().out == ""
+
+
+def test_non_git_gh_command_is_fast_path_allow(monkeypatch: pytest.MonkeyPatch):
+ _feed(monkeypatch, {"command": "ls -la && echo hi"})
+ assert opencode_main() == ALLOW_EXIT
+
+
+def test_malformed_stdin_fails_open(monkeypatch: pytest.MonkeyPatch):
+ _feed(monkeypatch, "not json {{{")
+ assert opencode_main() == ALLOW_EXIT
+
+
+def test_non_object_event_fails_open(monkeypatch: pytest.MonkeyPatch):
+ _feed(monkeypatch, ["a", "list"])
+ assert opencode_main() == ALLOW_EXIT
+
+
+def test_missing_command_key_allows(monkeypatch: pytest.MonkeyPatch):
+ _feed(monkeypatch, {"cwd": "/tmp"})
+ assert opencode_main() == ALLOW_EXIT
+
+
+def test_decision_matches_claude_dispatch(monkeypatch: pytest.MonkeyPatch):
+ # The OpenCode adapter must deny exactly what dispatch() denies — same core.
+ command = "git commit -m 'x\n\nCo-Authored-By: A <a...@b.c>'"
+ _feed(monkeypatch, {"command": command})
+ opencode_denies = opencode_main() == DENY_EXIT
+ dispatch_denies = agent_guard.dispatch(command) is not None
+ assert opencode_denies is dispatch_denies is True
+
+
+def test_cli_routes_opencode_flag(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
+ _feed(monkeypatch, {"command": "git commit -m 'x\n\nCo-Authored-By: A <a...@b.c>'"})
+ rc = cli(["--opencode"])
+ assert rc == DENY_EXIT
+ assert "agent-guard" in capsys.readouterr().out
+
+
+def test_cli_no_args_routes_claude(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
+ # No flag → the Claude PreToolUse shape; a benign event is allowed silently.
+ _feed(monkeypatch, {"tool_name": "Bash", "tool_input": {"command": "git status"}})
+ rc = cli([])
+ assert rc == 0
+ assert capsys.readouterr().out == ""