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:32:11 UTC

(magpie) branch main updated: feat(harness): OpenCode support for spec-loop + agent-isolation; rename to agent-iso.sh (#687)

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 d98602b5 feat(harness): OpenCode support for spec-loop + agent-isolation; rename to agent-iso.sh (#687)
d98602b5 is described below

commit d98602b535609b5f5c7cf1d40e08805de5608238
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Fri Jul 3 03:32:06 2026 +0200

    feat(harness): OpenCode support for spec-loop + agent-isolation; rename to agent-iso.sh (#687)
    
    Closes two more Claude-Code-coupled substrate tools on the agent-harness
    axis by giving each a genuine OpenCode integration over its already
    harness-agnostic core.
    
    spec-loop:
    - loop.sh builds the headless-run argv per harness. SPEC_LOOP_HARNESS
      (defaulted from the agent basename) selects `claude` (`claude -p
      --dangerously-skip-permissions --output-format …`, prompt on stdin) or
      `opencode` (`opencode run --auto --model … "<prompt>"`, prompt as a
      positional arg; --auto is OpenCode's auto-approve, --format json matches
      stream-json). OpenCode has no per-invocation --disallowedTools; the
      push/gh denial is left to the OS sandbox + the agent-guard OpenCode
      plugin + opencode.json permission config, as the SECURITY header already
      documents the OS sandbox as the real guard.
    
    agent-isolation:
    - The clean-env launcher is renamed claude-iso.sh -> agent-iso.sh and its
      core (agent_iso_run) is parameterized by agent. It exposes two entry
      points sharing one `env -i` credential strip: `claude-iso` (default) and
      `opencode-iso`. The Claude-only in-process `--settings` sandbox grant is
      gated to the claude agent; OpenCode takes filesystem isolation from the
      OS-level sandbox. The `claude-iso`/`opencode-iso` shell function names
      are unchanged, so `alias claude=claude-iso` keeps working.
    - setup-isolated-setup-update gains a rename-migration step: when a
      pre-rename claude-iso.sh copy is found, it surfaces removing the stale
      file + installing agent-iso.sh (read-only posture — user confirms).
    
    Both tools: **Harness:** -> `Claude Code, OpenCode`. Tests cover the
    OpenCode path (env-strip parity + no --settings for OpenCode; the
    spec-loop argv branch by inspection). Regenerated the score block; the
    agent-harness score rises to 17/20 here (18/20 once the agent-guard
    OpenCode PR also lands). All references to the renamed file swept across
    docs, skills, and eval fixtures.
    
    Co-authored-by: Tester <t...@example.com>
---
 CONTRIBUTING.md                                    |   2 +-
 docs/rfcs/RFC-AI-0002.md                           |   8 +-
 docs/setup/secure-agent-internals.md               |   2 +-
 docs/setup/secure-agent-setup.md                   |  36 ++---
 docs/vendor-neutrality.md                          |   8 +-
 skills/setup-isolated-setup-install/SKILL.md       |   2 +-
 skills/setup-isolated-setup-update/SKILL.md        |  24 ++-
 tools/agent-isolation/README.md                    |  18 ++-
 .../{claude-iso.sh => agent-iso.sh}                |  79 +++++++---
 tools/agent-isolation/tests/test_claude_iso.py     |   8 +-
 tools/agent-isolation/tests/test_opencode_iso.py   | 174 +++++++++++++++++++++
 .../fixtures/case-1-all-clean/report.md            |   2 +-
 .../fixtures/case-2-framework-behind/report.md     |   2 +-
 .../fixtures/case-1-all-pass/expected.json         |   2 +-
 .../fixtures/case-1-all-pass/report.md             |   2 +-
 .../fixtures/case-2-sandbox-disabled/expected.json |   2 +-
 .../fixtures/case-2-sandbox-disabled/report.md     |   2 +-
 .../fixtures/case-3-missing-scripts/expected.json  |   2 +-
 .../fixtures/case-3-missing-scripts/report.md      |   2 +-
 .../fixtures/case-4-version-drift/expected.json    |   2 +-
 .../fixtures/case-4-version-drift/report.md        |   2 +-
 .../case-5-project-root-missing/expected.json      |   2 +-
 .../fixtures/case-5-project-root-missing/report.md |   2 +-
 tools/spec-loop/README.md                          |  20 ++-
 tools/spec-loop/loop.sh                            |  56 +++++--
 25 files changed, 375 insertions(+), 86 deletions(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3c41c0ec..f00c8dd1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -481,7 +481,7 @@ contract that matters is the CLI surface and the JSON output shape.
 ### Shell scripts
 
 - [`tools/agent-isolation/`](tools/agent-isolation/) holds the
-  shell scripts (`claude-iso.sh`, sandbox status-line helpers,
+  shell scripts (`agent-iso.sh`, sandbox status-line helpers,
   the placeholder pre-commit script) that wire the agent into
   the bubblewrap sandbox. POSIX bash.
 - [`tools/dev/`](tools/dev/) holds the local pre-commit checkers
diff --git a/docs/rfcs/RFC-AI-0002.md b/docs/rfcs/RFC-AI-0002.md
index ffc4a7a4..6307901c 100644
--- a/docs/rfcs/RFC-AI-0002.md
+++ b/docs/rfcs/RFC-AI-0002.md
@@ -91,7 +91,7 @@ Layers 1, 2, and 3 are configured by the same project-scope `.claude/settings.j
 
 ### Layer 0 — Clean-env wrapper
 
-A shell wrapper that strips credential-shaped environment variables from the parent shell before invoking the agent. The reference implementation ships [`tools/agent-isolation/claude-iso.sh`](https://github.com/apache/magpie/blob/main/tools/agent-isolation/claude-iso.sh).
+A shell wrapper that strips credential-shaped environment variables from the parent shell before invoking the agent. The reference implementation ships [`tools/agent-isolation/agent-iso.sh`](https://github.com/apache/magpie/blob/main/tools/agent-isolation/agent-iso.sh).
 
 The wrapper hard-allows a tiny passthrough list (`HOME`, `PATH`, `SHELL`, `TERM`, `LANG`, `XDG_*`, `DISPLAY`, `SSH_AUTH_SOCK`, `USER`, `LOGNAME`, `PWD`); everything else from the parent shell is dropped via `env -i`.
 
@@ -291,7 +291,7 @@ npm install -g --no-save @anthropic-ai/claude-code@2.1.123
 # sandbox / permissions.deny / permissions.ask / allowedDomains
 # blocks into your tracker repo's `.claude/settings.json`.
 
-# 3. The clean-env wrapper. Source `claude-iso.sh` from your rc
+# 3. The clean-env wrapper. Source `agent-iso.sh` from your rc
 # file, optionally alias `claude=claude-iso`.
 
 # 4. User-scope hooks. Copy `sandbox-bypass-warn.sh` and
@@ -371,7 +371,7 @@ Operators working on more than one machine keep the user-scope pieces in lockste
 |---|---|
 | `CLAUDE.md` (personal collaboration prefs) | `~/.claude/.credentials.json` — ⚠ secret, never commit |
 | `scripts/sandbox-bypass-warn.sh`, `scripts/sandbox-status-line.sh`, and any other hooks | `~/.claude/sessions/`, `~/.claude/history.jsonl` — session state |
-| `agent-isolation/claude-iso.sh` (if globally installed) | `~/.claude/projects/` — per-project memory and tasks |
+| `agent-isolation/agent-iso.sh` (if globally installed) | `~/.claude/projects/` — per-project memory and tasks |
 | Custom slash commands (`commands/<name>.md`) | `~/.claude/settings.json` — typically differs per host |
 | Audited MCP servers | `~/.claude/settings.local.json` — by design machine-specific |
 
@@ -438,7 +438,7 @@ When the eventual filesystem access is **opaque to lexical analysis** — here
 - **macOS chained-curl gap.** The `permissions.deny` patterns match against the *first* command of a Bash invocation, not every command in a chain. On Linux, socat's SNI proxy closes the gap regardless. On macOS there is no socat — Seatbelt enforces filesystem isolation but the framework's setup does not currently wrap network egress on macOS, so a chained `curl` to an arbitrary host therefore reaches the network on macOS even when the same call in the same session would be blocked on Linux.
 - **Schema-fragile hooks.** Both the bypass-warn hook and the status-line script read fields from runtime-supplied JSON. A future Claude Code release that renames a field will silently stop firing the hook until the regex is updated. The setup-verification ritual after every Claude Code upgrade is the canary, but it is human-driven.
 - **Settings-level truth, not session-level truth.** The status-line script reads `sandbox.enabled` from the file system. It cannot see CLI flags (`--bypass-permissions`, equivalent runtime overrides) — those still display as `[sandbox]` even though the running session is unprotected. Pair the indicator with the bypass-warn hook so per-call bypass attempts also surface in real time.
-- **Dotfile-sync drift.** The user-scope global install of `claude-iso.sh` (and the synced hook scripts) decouples each host's copy from the framework's source-of-truth. Operators must `diff` and re-`cp` periodically, or the setup quietly ages.
+- **Dotfile-sync drift.** The user-scope global install of `agent-iso.sh` (and the synced hook scripts) decouples each host's copy from the framework's source-of-truth. Operators must `diff` and re-`cp` periodically, or the setup quietly ages.
 
 ## Alternatives considered
 
diff --git a/docs/setup/secure-agent-internals.md b/docs/setup/secure-agent-internals.md
index 0c530b3b..9ccb3b7f 100644
--- a/docs/setup/secure-agent-internals.md
+++ b/docs/setup/secure-agent-internals.md
@@ -75,7 +75,7 @@ It does **not** defend against:
 
 | Layer | Mechanism | What it stops |
 |---|---|---|
-| **0. Clean env** | `claude-iso` shell wrapper (`tools/agent-isolation/claude-iso.sh`) | Inherited credential-shaped env vars (`$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`, …). |
+| **0. Clean env** | `claude-iso` shell wrapper (`tools/agent-isolation/agent-iso.sh`) | Inherited credential-shaped env vars (`$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`, …). |
 | **1. Filesystem sandbox** | Claude Code's `sandbox.enabled: true` + bubblewrap (Linux) / Seatbelt (macOS) | Bash subprocess reads outside the project tree. |
 | **2. Tool permissions** | Claude Code's `permissions.deny` for Read/Edit/Write/Bash | The agent's own tools cat-ing dotfiles or running `aws`/`curl`. |
 | **3. Forced confirmation** | Claude Code's `permissions.ask` | Visible-to-others writes (`git push`, `gh pr create`, …) without an explicit yes. |
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 4ed2666e..4e60dcb5 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -165,7 +165,7 @@ npm install -g --no-save @anthropic-ai/claude-code@2.1.197
 #    blocks into your tracker repo's `.claude/settings.json`.
 #    Section: "The framework's own .claude/settings.json" below.
 
-# 3. The clean-env wrapper. Source `claude-iso.sh` from your rc
+# 3. The clean-env wrapper. Source `agent-iso.sh` from your rc
 #    file, optionally alias `claude=claude-iso`. Section: "The
 #    clean-env wrapper" below.
 
@@ -776,7 +776,7 @@ the global `post-checkout` keeps everything aligned going forward.
 
 Layer 0 — strip credential-shaped env vars from the parent shell
 before invoking `claude` — is implemented by
-[`tools/agent-isolation/claude-iso.sh`](../../tools/agent-isolation/claude-iso.sh).
+[`tools/agent-isolation/agent-iso.sh`](../../tools/agent-isolation/agent-iso.sh).
 
 There are two valid ways to make `claude-iso` available on your
 shell. Pick whichever matches how you use Claude Code; the wrapper
@@ -789,7 +789,7 @@ but only works on hosts where the framework path resolves.
 
 ```bash
 # ~/.bashrc or ~/.zshrc
-source /path/to/magpie/tools/agent-isolation/claude-iso.sh
+source /path/to/magpie/tools/agent-isolation/agent-iso.sh
 ```
 
 **Global (user-scope) install** — copy the script into
@@ -802,12 +802,12 @@ out on a given host.
 ```bash
 # one-time install (re-run to pick up an upstream wrapper change)
 mkdir -p ~/.claude/agent-isolation
-cp /path/to/magpie/tools/agent-isolation/claude-iso.sh \
-    ~/.claude/agent-isolation/claude-iso.sh
+cp /path/to/magpie/tools/agent-isolation/agent-iso.sh \
+    ~/.claude/agent-isolation/agent-iso.sh
 
 # ~/.bashrc or ~/.zshrc — guarded so it's a no-op until the file exists
-[ -f "$HOME/.claude/agent-isolation/claude-iso.sh" ] \
-    && . "$HOME/.claude/agent-isolation/claude-iso.sh"
+[ -f "$HOME/.claude/agent-isolation/agent-iso.sh" ] \
+    && . "$HOME/.claude/agent-isolation/agent-iso.sh"
 ```
 
 Trade-off: the global install decouples the wrapper from the
@@ -1487,7 +1487,7 @@ shell's pty via `$PPID` and writes there.)
 
 The user-scope pieces of the secure setup —
 `~/.claude/scripts/sandbox-bypass-warn.sh`, an optional global copy
-of `claude-iso.sh` (per the
+of `agent-iso.sh` (per the
 [Global (user-scope) install](#the-clean-env-wrapper) trade-off),
 your personal `~/.claude/CLAUDE.md`, plus any other custom hooks —
 only protect a host once they are installed there. Working on more
@@ -1507,7 +1507,7 @@ paths). Track the artifacts you want shared, symlink them into
 |---|---|
 | `CLAUDE.md` (personal collaboration prefs) | `~/.claude/.credentials.json` — ⚠ secret, never commit |
 | `scripts/sandbox-bypass-warn.sh`, `scripts/sandbox-error-hint.sh`, `scripts/sandbox-status-line.sh`, and any other hooks | `~/.claude/sessions/`, `~/.claude/history.jsonl` — session state |
-| `agent-isolation/claude-iso.sh` (if you globally installed it per the wrapper section) | `~/.claude/projects/<key>/` — per-project session state and tasks (the `memory/` subdir is optionally sharable, see [Extending `sync.sh`: share project memory across machines](#extending-syncsh-share-project-memory-across-machines)) |
+| `agent-isolation/agent-iso.sh` (if you globally installed it per the wrapper section) | `~/.claude/projects/<key>/` — per-project session state and tasks (the `memory/` subdir is optionally sharable, see [Extending `sync.sh`: share project memory across machines](#extending-syncsh-share-project-memory-across-machines)) |
 | Custom slash commands (`commands/<name>.md`) | `~/.claude/settings.json` — typically differs per host (plugins, statusLine paths, voice) |
 | MCP servers you've audited and want everywhere (`.mcp.json` shape, by hand) | `~/.claude/settings.local.json` — by design machine-specific |
 
@@ -1532,7 +1532,7 @@ A minimal repo layout:
 │   ├── sandbox-bypass-warn.sh          # symlinked → ~/.claude/scripts/sandbox-bypass-warn.sh
 │   └── sandbox-status-line.sh          # symlinked → ~/.claude/scripts/sandbox-status-line.sh
 ├── agent-isolation/
-│   └── claude-iso.sh                   # symlinked → ~/.claude/agent-isolation/claude-iso.sh
+│   └── agent-iso.sh                   # symlinked → ~/.claude/agent-isolation/agent-iso.sh
 ├── README.md                           # what's in the repo, install steps per machine
 └── sync.sh                             # the pull/commit/push helper
 ```
@@ -1560,13 +1560,13 @@ ln -sfn ~/.claude-config/scripts/sandbox-status-line.sh \
 
 # (Optional) global claude-iso wrapper — see the wrapper section
 mkdir -p ~/.claude/agent-isolation
-ln -sfn ~/.claude-config/agent-isolation/claude-iso.sh \
-    ~/.claude/agent-isolation/claude-iso.sh
+ln -sfn ~/.claude-config/agent-isolation/agent-iso.sh \
+    ~/.claude/agent-isolation/agent-iso.sh
 ```
 
 Then wire the per-machine bits one time, per the install snippets
 in the relevant sections (the hook entry in
-`~/.claude/settings.json`, the `source …/claude-iso.sh` line in
+`~/.claude/settings.json`, the `source …/agent-iso.sh` line in
 `~/.bashrc` / `~/.zshrc`, etc.).
 
 ### A minimal `sync.sh`
@@ -1785,7 +1785,7 @@ sub-section that follows.
    [The clean-env wrapper](#the-clean-env-wrapper). When the
    framework is consumed via the standard snapshot path, the
    per-repo source path is
-   `<your-tracker>/.apache-magpie/tools/agent-isolation/claude-iso.sh`.
+   `<your-tracker>/.apache-magpie/tools/agent-isolation/agent-iso.sh`.
 4. Decide whether to gitignore `.claude/settings.local.json` in your
    tracker repo — Claude Code does this by default; verify with
    `git check-ignore .claude/settings.local.json`.
@@ -1800,7 +1800,7 @@ sub-section that follows.
    `.claude/settings.json`.
 6. **Optional (multi-machine workflow):** keep the user-scope
    pieces (the hook scripts, the status-line script, your personal
-   `CLAUDE.md`, an optional global `claude-iso.sh`) in a private
+   `CLAUDE.md`, an optional global `agent-iso.sh`) in a private
    dotfile-style repo per
    [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines).
 
@@ -1851,7 +1851,7 @@ Then walk through:
 
 3. **Clean-env wrapper.** Surface the line to add to my
    `~/.bashrc` or `~/.zshrc` to source
-   `<magpie>/tools/agent-isolation/claude-iso.sh`. Ask
+   `<magpie>/tools/agent-isolation/agent-iso.sh`. Ask
    whether I want it as the default `claude` (alias) or
    on-demand only. Print the line; do not edit my shell rc
    yourself.
@@ -2023,8 +2023,8 @@ synchronised is a periodic operation, not a one-time install.
        /path/to/magpie/tools/agent-isolation/sandbox-bypass-warn.sh
    diff ~/.claude/scripts/sandbox-status-line.sh \
        /path/to/magpie/tools/agent-isolation/sandbox-status-line.sh
-   diff ~/.claude/agent-isolation/claude-iso.sh \
-       /path/to/magpie/tools/agent-isolation/claude-iso.sh
+   diff ~/.claude/agent-isolation/agent-iso.sh \
+       /path/to/magpie/tools/agent-isolation/agent-iso.sh
    ```
 
 4. **comdev MCP checkouts.** If you registered the `ponymail`
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 93872507..22443031 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -571,15 +571,14 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
 
 **LLM / agent-integration neutrality**
 
-**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.
+**Agent harness: 18/20 substrate tools run under any harness unchanged (90%).** 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-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 |
+| `agent-isolation` | sandbox | Claude Code, OpenCode | ✅ portable |
 | `dashboard-generator` | analytics | any | ✅ agnostic |
 | `dev` | framework-dev | any | ✅ agnostic |
 | `egress-gateway` | sandbox | any | ✅ agnostic |
@@ -591,6 +590,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
 | `security-tracker-stats-dashboard` | analytics | any | ✅ agnostic |
 | `skill-and-tool-validator` | framework-dev | any | ✅ agnostic |
 | `skill-evals` | framework-dev | any | ✅ agnostic |
+| `spec-loop` | framework-dev | Claude Code, OpenCode | ✅ portable |
 | `spec-status-index` | framework-dev, analytics | any | ✅ agnostic |
 | `spec-validator` | framework-dev | any | ✅ agnostic |
 | `symlink-lint` | framework-dev | any | ✅ agnostic |
@@ -599,7 +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`
+- **OpenCode** (3): `agent-guard`, `agent-isolation`, `spec-loop`
 - **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/skills/setup-isolated-setup-install/SKILL.md b/skills/setup-isolated-setup-install/SKILL.md
index bfb5236a..67c93992 100644
--- a/skills/setup-isolated-setup-install/SKILL.md
+++ b/skills/setup-isolated-setup-install/SKILL.md
@@ -86,7 +86,7 @@ Drift severity:
   for the user to copy-paste into their own terminal. The skill
   never invokes `sudo` itself.
 - **Do not edit shell rc files without approval.** `~/.bashrc` /
-  `~/.zshrc` modifications (sourcing `claude-iso.sh`, the optional
+  `~/.zshrc` modifications (sourcing `agent-iso.sh`, the optional
   `alias claude='claude-iso'`) are surfaced as the exact line to
   add; the user pastes it themselves. The skill confirms the rc
   file path with the user first; it does not assume.
diff --git a/skills/setup-isolated-setup-update/SKILL.md b/skills/setup-isolated-setup-update/SKILL.md
index 6041f66b..9e09b9e9 100644
--- a/skills/setup-isolated-setup-update/SKILL.md
+++ b/skills/setup-isolated-setup-update/SKILL.md
@@ -99,9 +99,9 @@ Drift severity:
   "wait for a feature you actually want or a security fix",
   not "always run latest".
 - **Distinguish framework changes from local drift.** "The
-  framework's `tools/agent-isolation/claude-iso.sh` has new
+  framework's `tools/agent-isolation/agent-iso.sh` has new
   comments" is a *framework update* (resolved by `git pull`).
-  "The user's `~/.claude/agent-isolation/claude-iso.sh` no longer
+  "The user's `~/.claude/agent-isolation/agent-iso.sh` no longer
   matches the framework's copy" is *local drift* (resolved by
   re-`cp` or, for sync-repo users, by syncing the framework
   changes into `~/.claude-config/scripts/`). Report each
@@ -139,7 +139,7 @@ Walk each:
    (`~/.claude/scripts/sandbox-bypass-warn.sh`,
    `~/.claude/scripts/sandbox-status-line.sh` or whatever the
    user's actual statusLine command resolves to,
-   `~/.claude/agent-isolation/claude-iso.sh` for the global
+   `~/.claude/agent-isolation/agent-iso.sh` for the global
    wrapper install,
    `~/.claude/scripts/sandbox-add-project-root.sh` for the
    issue-#197 project-root helper, **and** —
@@ -166,6 +166,24 @@ Walk each:
    from the user's `guards.d` is the most common drift once the hook
    is wired — re-syncing `guards.d` activates it with **no
    `settings.json` change**.
+
+   **Rename migration — `claude-iso.sh` → `agent-iso.sh`.** The
+   clean-env launcher was renamed (it now isolates **OpenCode** as
+   well as Claude Code, exposing both a `claude-iso` and an
+   `opencode-iso` entry point from one file). If a **pre-rename copy
+   exists** at `~/.claude/agent-isolation/claude-iso.sh` (or wherever
+   the adopter installed the wrapper), surface it as a migration
+   candidate: recommend installing the new `agent-iso.sh` (the Step P
+   re-install path above) **and removing the stale
+   `claude-iso.sh`**, plus updating any
+   `source …/claude-iso.sh` line in the shell rc to `agent-iso.sh`.
+   The `claude-iso` shell **function/alias** name is unchanged, so
+   `alias claude=claude-iso` keeps working once the `source` path is
+   fixed. Consistent with this skill's read-only posture, **do not
+   delete the old file automatically** — list it as a candidate the
+   user confirms, and show the two commands they would run:
+   `cp tools/agent-isolation/agent-iso.sh ~/.claude/agent-isolation/agent-iso.sh`
+   then `rm ~/.claude/agent-isolation/claude-iso.sh`.
 4. **Settings.json shape drift.** Diff the user's project
    `.claude/settings.json` against the framework's dogfooded
    one — the framework occasionally adds new `denyRead` paths
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 51614c4a..a1af8aea 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -17,7 +17,7 @@
 
 **Capability:** substrate:sandbox
 
-**Harness:** Claude Code
+**Harness:** Claude Code, OpenCode
 
 This directory ships the moving pieces the framework's
 [`docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md) document
@@ -26,11 +26,21 @@ under `tools/cve-tool-vulnogram/` and `tools/gmail/oauth-draft/`) — these are
 plain shell scripts plus a TOML manifest of pinned upstream
 versions.
 
+The clean-environment launcher [`agent-iso.sh`](agent-iso.sh) is
+agent-agnostic at its core: it exposes a `claude-iso` entry point (the
+default) and an `opencode-iso` entry point that launch **Claude Code** and
+**OpenCode** respectively under the same `env -i` credential strip. Only the
+Claude path adds the in-process `--settings` sandbox grant; OpenCode takes its
+filesystem isolation from the OS-level sandbox of the secure setup. Isolate
+OpenCode by sourcing the script and calling `opencode-iso`, or when running it
+directly, `AGENT_ISO_AGENT=opencode bash agent-iso.sh …` (or a symlink named
+`opencode-iso`).
+
 ## Prerequisites
 
 - **Runtime:** Bash + coreutils — this directory is plain shell scripts plus a TOML manifest, not a Python project (the `pyproject.toml` ships only the test harness, which runs under Python 3.11+ via `uv`). `claude-term-bg.sh` uses `python3` / `python` for one heuristic and falls back to calm when absent.
 - **CLIs:** `jq` (required by `check-tool-updates.sh` and the status-line scripts), `curl` (the update check), `git` (status line / git hooks), and `gh` (optional — status-line PR title). The secure setup itself installs the pinned `bubblewrap` and `socat` (via `apt-get`) and `@anthropic-ai/claude-code` (via `npm`).
-- **Credentials / auth:** None for these helpers; the wrapped `claude` session authenticates on its own (and `claude-iso.sh` deliberately strips credential-shaped env vars).
+- **Credentials / auth:** None for these helpers; the wrapped `claude` session authenticates on its own (and `agent-iso.sh` deliberately strips credential-shaped env vars).
 - **Network:** `api.github.com` and `www.dest-unreach.org` (the release checks in `check-tool-updates.sh`); the install step also reaches the apt and npm registries.
 
 ## Files
@@ -39,7 +49,7 @@ versions.
 |---|---|
 | [`pinned-versions.toml`](pinned-versions.toml) | Machine-readable manifest of pinned upstream versions for `bubblewrap`, `socat`, and `claude-code`. Each entry carries a `released` date that satisfies the framework's 7-day cooldown convention. |
 | [`check-tool-updates.sh`](check-tool-updates.sh) | Reads the manifest and reports upstream releases that are newer than the pin AND have themselves aged past the 7-day cooldown. Side-effect-free — no installs, no edits, no PRs. |
-| [`claude-iso.sh`](claude-iso.sh) | Shell function to launch Claude Code with `env -i` and a tiny passthrough list, stripping every credential-shaped environment variable from the parent shell. The framework's "layer 0" of the secure setup. |
+| [`agent-iso.sh`](agent-iso.sh) | Shell function to launch Claude Code with `env -i` and a tiny passthrough list, stripping every credential-shaped environment variable from the parent shell. The framework's "layer 0" of the secure setup. |
 | [`sandbox-bypass-warn.sh`](sandbox-bypass-warn.sh) | Claude Code `PreToolUse` hook (Bash matcher). Prints a bold-red banner to stderr whenever the model invokes the Bash tool with `dangerouslyDisableSandbox: true`. Belt-and-braces visibility for the sandbox-bypass permission prompt. Recommended user-scope (`~/.claude/settings.json`) so it fires across every session on the host. |
 | [`sandbox-error-hint.sh`](sandbox-error-hint.sh) | Claude Code `PostToolUse` hook (Bash matcher). Scans the tool's stdout + stderr for the three known sandbox-shaped error signatures (SSH agent / Yubikey unreachable, loopback port-bind blocked, docker / podman socket denied) and prints a `[sandbox-hint]` line pointing at the matching entry in [`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md). Fail-open: any unexpected JSON shape exits silent. Recomm [...]
 | [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code `statusLine` helper. Renders `<model> [sandbox]` (green) or `<model> [NO SANDBOX]` (bold red) based on `sandbox.enabled` in the active settings — project `settings.local.json` first, then project `settings.json`, then user-scope, mirroring Claude Code's own precedence. Reflects in-session `/sandbox` toggles (which persist to project `settings.local.json`). Recommended user-scope. |
@@ -56,7 +66,7 @@ sudo apt-get install --no-install-recommends bubblewrap=0.11.1-* socat=1.8.1.1-*
 npm install -g --no-save @anthropic-ai/claude-code@2.1.117
 
 # Source the wrapper into your shell:
-source /path/to/magpie/tools/agent-isolation/claude-iso.sh
+source /path/to/magpie/tools/agent-isolation/agent-iso.sh
 
 # Optional: make claude-iso the default `claude` (see docs/setup/secure-agent-setup.md
 # for the trade-off — the alias also strips env in non-tracker sessions):
diff --git a/tools/agent-isolation/claude-iso.sh b/tools/agent-isolation/agent-iso.sh
similarity index 72%
rename from tools/agent-isolation/claude-iso.sh
rename to tools/agent-isolation/agent-iso.sh
index a7269988..cf341615 100755
--- a/tools/agent-isolation/claude-iso.sh
+++ b/tools/agent-isolation/agent-iso.sh
@@ -16,14 +16,20 @@
 # specific language governing permissions and limitations
 # under the License.
 #
-# claude-iso.sh — launch Claude Code with a clean environment.
+# agent-iso.sh — launch an agent CLI with a clean environment.
 #
 # This is layer 0 of the secure-agent setup (see
 # `docs/setup/secure-agent-setup.md`): strip every credential-shaped
-# environment variable from the parent shell before exec'ing
-# Claude Code, so the agent never sees `$AWS_*`, `$GH_TOKEN`,
-# `$ANTHROPIC_API_KEY`, etc. that an unrelated terminal session
-# may have exported into your interactive shell.
+# environment variable from the parent shell before exec'ing the
+# agent, so it never sees `$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`,
+# etc. that an unrelated terminal session may have exported into your
+# interactive shell.
+#
+# The clean-env strip is agent-agnostic. Two entry points share the core:
+# `claude-iso` (the default) launches Claude Code, and `opencode-iso`
+# launches OpenCode. Only the Claude Code path additionally injects the
+# in-process `--settings` sandbox grant below; OpenCode gets its
+# filesystem isolation from the OS-level sandbox of the secure setup.
 #
 # Filesystem-level isolation (the bigger lift) is enforced by
 # Claude Code's `sandbox` feature — see the `.claude/settings.json`
@@ -32,9 +38,13 @@
 #
 # Usage:
 #   - Source it from your shell rc:
-#       source /path/to/claude-iso.sh
-#     and then invoke `claude-iso` instead of `claude`.
-#   - Or invoke directly: `bash claude-iso.sh [claude args ...]`.
+#       source /path/to/agent-iso.sh
+#     and then invoke `claude-iso` (or `opencode-iso`) instead of the
+#     agent CLI.
+#   - Or invoke directly: `bash agent-iso.sh [claude args ...]`. To
+#     isolate OpenCode when executing directly, either set
+#     `AGENT_ISO_AGENT=opencode` or invoke via a symlink named
+#     `opencode-iso`.
 #
 # To inject a single credential explicitly for one session:
 #   GH_TOKEN="$(gh auth token)" claude-iso
@@ -64,21 +74,28 @@
 #   Claude merges into the loaded settings stack at startup,
 #   before the sandbox is initialised.
 
-claude_iso_main() {
-  # Resolve the claude binary on PATH before clobbering the env so
+# Core: launch agent CLI "$1" (claude / opencode / …) in a clean environment.
+# The env-stripping below is identical for every agent — only the injected
+# settings differ (the `--settings` sandbox grant is Claude-specific). The
+# `claude-iso` / `opencode-iso` entry points are thin wrappers over this.
+agent_iso_run() {
+  local agent="$1"
+  shift
+
+  # Resolve the agent binary on PATH before clobbering the env so
   # the lookup uses the user's normal $PATH. Use a path-only lookup
   # (bash `type -P`, zsh `whence -p`) instead of `command -v`: with
   # `command -v`, an `alias claude=claude-iso` in the user's rc file
   # (a documented setup option — see `docs/setup/secure-agent-setup.md`) would
   # resolve back to the alias and recurse.
-  local claude_bin
+  local agent_bin
   if [[ -n "${ZSH_VERSION-}" ]]; then
-    claude_bin="$(whence -p claude 2>/dev/null || true)"
+    agent_bin="$(whence -p "$agent" 2>/dev/null || true)"
   else
-    claude_bin="$(type -P claude 2>/dev/null || true)"
+    agent_bin="$(type -P "$agent" 2>/dev/null || true)"
   fi
-  if [[ -z "$claude_bin" ]]; then
-    echo "claude-iso: 'claude' not found on PATH. Install per docs/setup/secure-agent-setup.md." >&2
+  if [[ -z "$agent_bin" ]]; then
+    echo "${agent}-iso: '${agent}' not found on PATH. Install per docs/setup/secure-agent-setup.md." >&2
     return 127
   fi
 
@@ -208,7 +225,12 @@ claude_iso_main() {
     [[ "$seen" -eq 0 ]] && allow_read_paths+=("$candidate")
   done
 
-  if (( ${#allow_read_paths[@]} > 0 )); then
+  # The `--settings` sandbox-allowRead injection is Claude Code's in-process
+  # sandbox feature and is applied only for that agent. Other harnesses (e.g.
+  # OpenCode) get their filesystem isolation from the OS-level sandbox of the
+  # secure-agent setup, which is configured out of band — so the clean-env
+  # launch below still applies, we just skip the Claude-only settings grant.
+  if [[ "$agent" == "claude" ]] && (( ${#allow_read_paths[@]} > 0 )); then
     # Hand-roll the JSON array literal (escape backslashes and
     # double quotes) so a pathological repo path can't break out
     # of the string literal. Keeping it dependency-free — no jq.
@@ -234,18 +256,29 @@ claude_iso_main() {
   # Print a one-line banner on stderr (dim if a TTY) so it's obvious
   # which mode the agent is starting in.
   if [[ -t 2 ]]; then
-    printf '\033[2m[claude-iso] running in isolated env (%s)\033[0m\n' "$claude_bin" >&2
+    printf '\033[2m[%s-iso] running in isolated env (%s)\033[0m\n' "$agent" "$agent_bin" >&2
   else
-    printf '[claude-iso] running in isolated env (%s)\n' "$claude_bin" >&2
+    printf '[%s-iso] running in isolated env (%s)\n' "$agent" "$agent_bin" >&2
   fi
 
-  exec env -i "${env_args[@]}" "$claude_bin" "$@"
+  exec env -i "${env_args[@]}" "$agent_bin" "$@"
 }
 
-# When sourced, expose `claude-iso` as a function. When executed
-# directly, just dispatch.
+# Back-compat wrapper: the historical entry point defaults to `claude`, and
+# still honours AGENT_ISO_AGENT for callers that set it.
+claude_iso_main() { agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@"; }
+
+# When sourced, expose one launcher per agent as a shell function (so a user
+# can `alias claude=claude-iso` and/or `alias opencode=opencode-iso`). When
+# executed directly, pick the agent from the invoked name (a symlink such as
+# `opencode-iso` selects OpenCode) or from AGENT_ISO_AGENT, defaulting to
+# `claude` so existing `bash agent-iso.sh …` invocations are unchanged.
 if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
-  claude-iso() { claude_iso_main "$@"; }
+  claude-iso()   { agent_iso_run claude "$@"; }
+  opencode-iso() { agent_iso_run opencode "$@"; }
 else
-  claude_iso_main "$@"
+  case "$(basename "${0}")" in
+    opencode-iso*) agent_iso_run opencode "$@" ;;
+    *)             agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@" ;;
+  esac
 fi
diff --git a/tools/agent-isolation/tests/test_claude_iso.py b/tools/agent-isolation/tests/test_claude_iso.py
index 08ba3798..e2b68022 100644
--- a/tools/agent-isolation/tests/test_claude_iso.py
+++ b/tools/agent-isolation/tests/test_claude_iso.py
@@ -15,10 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Tests for claude-iso.sh — the clean-environment wrapper.
+"""Tests for agent-iso.sh — the clean-environment wrapper.
 
 Strategy: put a fake ``claude`` binary (``printenv``) on $PATH and run
-claude-iso.sh against it.  Verify which variables survive the
+agent-iso.sh against it.  Verify which variables survive the
 ``env -i`` filter and which are stripped.
 """
 
@@ -30,7 +30,7 @@ import stat
 import subprocess
 from pathlib import Path
 
-SCRIPT = Path(__file__).parent.parent / "claude-iso.sh"
+SCRIPT = Path(__file__).parent.parent / "agent-iso.sh"
 
 # Use the absolute path so tests that restrict PATH still launch bash correctly.
 BASH = shutil.which("bash") or "/bin/bash"
@@ -50,7 +50,7 @@ def _make_fake_claude(tmp_path: Path) -> Path:
 
 
 def _run(tmp_path: Path, extra_env: dict | None = None, extra_args: list | None = None) -> subprocess.CompletedProcess:
-    """Run claude-iso.sh with a fake claude in a non-git temp directory."""
+    """Run agent-iso.sh with a fake claude in a non-git temp directory."""
     bin_dir = _make_fake_claude(tmp_path)
     env: dict[str, str] = {
         "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}",
diff --git a/tools/agent-isolation/tests/test_opencode_iso.py b/tools/agent-isolation/tests/test_opencode_iso.py
new file mode 100644
index 00000000..9386c06e
--- /dev/null
+++ b/tools/agent-isolation/tests/test_opencode_iso.py
@@ -0,0 +1,174 @@
+# 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 entry point of agent-iso.sh (`opencode-iso`).
+
+Same strategy as ``test_claude_iso.py``: put a fake ``opencode`` binary on
+$PATH that prints its argv and its env, then launch the wrapper in
+OpenCode mode. Verify the clean-env strip is identical to the Claude path
+*and* that the Claude-only ``--settings`` sandbox injection is not applied.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import stat
+import subprocess
+from pathlib import Path
+
+SCRIPT = Path(__file__).parent.parent / "agent-iso.sh"
+BASH = shutil.which("bash") or "/bin/bash"
+
+
+def _make_fake_opencode(tmp_path: Path) -> Path:
+    """A fake 'opencode' that prints ARG: lines for its argv, then its env."""
+    fake = tmp_path / "opencode"
+    fake.write_text('#!/bin/sh\nfor a in "$@"; do echo "ARG:$a"; done\nprintenv\n')
+    fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+    return tmp_path
+
+
+def _run_opencode(
+    tmp_path: Path,
+    extra_env: dict | None = None,
+    extra_args: list | None = None,
+    via_symlink: bool = False,
+) -> subprocess.CompletedProcess:
+    bin_dir = _make_fake_opencode(tmp_path)
+    env: dict[str, str] = {
+        "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}",
+        "HOME": "/tmp/testhome",
+        "USER": "testuser",
+        "SHELL": "/bin/sh",
+        "TERM": "xterm",
+        "LANG": "en_US.UTF-8",
+    }
+    if not via_symlink:
+        env["AGENT_ISO_AGENT"] = "opencode"
+    if extra_env:
+        env.update(extra_env)
+
+    script: Path = SCRIPT
+    if via_symlink:
+        # A symlink named `opencode-iso.sh` must select OpenCode by its basename
+        # without any env var.
+        link = tmp_path / "opencode-iso.sh"
+        if not link.exists():
+            link.symlink_to(SCRIPT)
+        script = link
+
+    return subprocess.run(
+        [BASH, str(script)] + (extra_args or []),
+        env=env,
+        cwd=str(tmp_path),
+        capture_output=True,
+        text=True,
+    )
+
+
+def _parse_env(stdout: str) -> dict[str, str]:
+    result: dict[str, str] = {}
+    for line in stdout.splitlines():
+        if line.startswith("ARG:"):
+            continue
+        if "=" in line:
+            key, _, val = line.partition("=")
+            result[key] = val
+    return result
+
+
+def _argv(stdout: str) -> list[str]:
+    return [line[len("ARG:") :] for line in stdout.splitlines() if line.startswith("ARG:")]
+
+
+class TestOpenCodeAgent:
+    def test_launches_opencode_binary(self, tmp_path: Path) -> None:
+        res = _run_opencode(tmp_path)
+        assert res.returncode == 0
+        assert "[opencode-iso] running in isolated env" in res.stderr
+
+    def test_credentials_stripped(self, tmp_path: Path) -> None:
+        res = _run_opencode(
+            tmp_path,
+            extra_env={"GH_TOKEN": "ghp_secret", "ANTHROPIC_API_KEY": "sk-ant-secret"},
+        )
+        assert res.returncode == 0
+        env = _parse_env(res.stdout)
+        assert "GH_TOKEN" not in env
+        assert "ANTHROPIC_API_KEY" not in env
+
+    def test_passthrough_preserved(self, tmp_path: Path) -> None:
+        res = _run_opencode(tmp_path, extra_env={"HOME": "/home/testuser"})
+        assert res.returncode == 0
+        assert _parse_env(res.stdout).get("HOME") == "/home/testuser"
+
+    def test_no_claude_settings_injection(self, tmp_path: Path) -> None:
+        # The Claude-only --settings sandbox grant must NOT be added for OpenCode,
+        # even when args are passed (which would be prepended for Claude).
+        res = _run_opencode(tmp_path, extra_args=["run", "hello"])
+        assert res.returncode == 0
+        argv = _argv(res.stdout)
+        assert "--settings" not in argv
+        assert argv == ["run", "hello"]
+
+    def test_symlink_name_selects_opencode(self, tmp_path: Path) -> None:
+        # Invoked as `opencode-iso.sh`, no AGENT_ISO_AGENT — basename decides.
+        res = _run_opencode(tmp_path, via_symlink=True)
+        assert res.returncode == 0
+        assert "[opencode-iso] running in isolated env" in res.stderr
+
+    def test_missing_opencode_exits_127(self, tmp_path: Path) -> None:
+        result = subprocess.run(
+            [BASH, str(SCRIPT)],
+            env={
+                "PATH": str(tmp_path),  # no opencode binary here
+                "HOME": "/tmp",
+                "USER": "testuser",
+                "SHELL": "/bin/sh",
+                "AGENT_ISO_AGENT": "opencode",
+            },
+            cwd=str(tmp_path),
+            capture_output=True,
+            text=True,
+        )
+        assert result.returncode == 127
+        assert "opencode-iso: 'opencode' not found" in result.stderr
+
+
+class TestClaudeStillDefault:
+    def test_default_invocation_still_launches_claude(self, tmp_path: Path) -> None:
+        # No AGENT_ISO_AGENT, invoked as agent-iso.sh → must still be claude.
+        fake = tmp_path / "claude"
+        fake.write_text("#!/bin/sh\nprintenv\n")
+        fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+        res = subprocess.run(
+            [BASH, str(SCRIPT)],
+            env={
+                "PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}",
+                "HOME": "/tmp/testhome",
+                "USER": "testuser",
+                "SHELL": "/bin/sh",
+                "TERM": "xterm",
+                "LANG": "en_US.UTF-8",
+            },
+            cwd=str(tmp_path),
+            capture_output=True,
+            text=True,
+        )
+        assert res.returncode == 0
+        assert "[claude-iso] running in isolated env" in res.stderr
diff --git a/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-1-all-clean/report.md b/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-1-all-clean/report.md
index 57ffab06..fdf06cea 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-1-all-clean/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-1-all-clean/report.md
@@ -9,7 +9,7 @@ Check 3 (user-scope scripts): diff shows no differences between installed
 scripts and framework source-of-truth.
   ~/.claude/scripts/sandbox-bypass-warn.sh — identical
   ~/.claude/scripts/sandbox-status-line.sh — identical
-  ~/.claude/agent-isolation/claude-iso.sh — identical
+  ~/.claude/agent-isolation/agent-iso.sh — identical
   ~/.claude/scripts/sandbox-add-project-root.sh — identical
 
 Check 4 (settings.json shape): no new framework entries detected.
diff --git a/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-2-framework-behind/report.md b/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-2-framework-behind/report.md
index 9a59d6ab..ee375d12 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-2-framework-behind/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-update/step-after-report/fixtures/case-2-framework-behind/report.md
@@ -1,6 +1,6 @@
 Check 1 (framework checkout): git fetch completed. Local checkout is 3
 commits behind origin/main. Changes since last pull include:
-  tools/agent-isolation/claude-iso.sh — 2 lines updated (new deny path)
+  tools/agent-isolation/agent-iso.sh — 2 lines updated (new deny path)
   docs/setup/secure-agent-setup.md — 1 section added (Step P.0 clarification)
 
 Snapshot drift check: .apache-magpie.lock ref is v0.9.5 but
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/expected.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/expected.json
index d9d2061d..5549a38d 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/expected.json
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/expected.json
@@ -4,7 +4,7 @@
     {"n": 1, "status": "✓", "evidence": "sandbox.enabled: true; deny and ask lists present; network and filesystem allowlists configured"},
     {"n": 2, "status": "✓", "evidence": "PreToolUse Bash → sandbox-bypass-warn.sh; PostToolUse Bash → sandbox-error-hint.sh; statusLine → sandbox-status-line.sh"},
     {"n": 3, "status": "✓", "evidence": "sandbox-bypass-warn.sh ✓ executable, sandbox-error-hint.sh ✓ executable, sandbox-status-line.sh ✓ executable"},
-    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/claude-iso.sh in ~/.bashrc; alias claude='claude-iso' set"},
+    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/agent-iso.sh in ~/.bashrc; alias claude='claude-iso' set"},
     {"n": 5, "status": "✓", "evidence": "claude-code 2.1.150 installed matches pinned-versions.toml 2.1.150"},
     {"n": 6, "status": "✓", "evidence": "effective sandbox.enabled: true (from .claude/settings.json)"},
     {"n": 7, "status": "✓", "evidence": "cat ~/.aws/credentials → Operation not permitted; $AWS_ACCESS_KEY_ID empty; curl denied at permission layer"},
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/report.md b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/report.md
index d0d1d150..ad88ed6e 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-1-all-pass/report.md
@@ -82,7 +82,7 @@ ls -la ~/.claude/scripts/:
 ## Check 4 — claude-iso sourced
 
 grep claude-iso ~/.bashrc:
-  source ~/.claude/scripts/claude-iso.sh
+  source ~/.claude/scripts/agent-iso.sh
 
 grep "alias claude=" ~/.bashrc:
   alias claude='claude-iso'
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/expected.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/expected.json
index 6a684a77..bc5613dd 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/expected.json
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/expected.json
@@ -4,7 +4,7 @@
     {"n": 1, "status": "✗", "evidence": "sandbox.enabled: false in .claude/settings.json"},
     {"n": 2, "status": "✓", "evidence": "PreToolUse Bash → sandbox-bypass-warn.sh; PostToolUse Bash → sandbox-error-hint.sh; statusLine → sandbox-status-line.sh"},
     {"n": 3, "status": "✓", "evidence": "sandbox-bypass-warn.sh ✓ executable, sandbox-error-hint.sh ✓ executable, sandbox-status-line.sh ✓ executable"},
-    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/claude-iso.sh in ~/.bashrc; alias claude='claude-iso' not set (optional)"},
+    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/agent-iso.sh in ~/.bashrc; alias claude='claude-iso' not set (optional)"},
     {"n": 5, "status": "✓", "evidence": "claude-code 2.1.150 installed matches pinned-versions.toml 2.1.150"},
     {"n": 6, "status": "✗", "evidence": "effective sandbox.enabled: false (from .claude/settings.json)"},
     {"n": 7, "status": "✗", "evidence": "cat ~/.aws/credentials: no denial; curl https://example.com: succeeded (returned HTML)"},
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/report.md b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/report.md
index 33c8124b..2903ec3b 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-2-sandbox-disabled/report.md
@@ -73,7 +73,7 @@ ls -la ~/.claude/scripts/:
 ## Check 4 — claude-iso sourced
 
 grep claude-iso ~/.bashrc:
-  source ~/.claude/scripts/claude-iso.sh
+  source ~/.claude/scripts/agent-iso.sh
 
 grep "alias claude=" ~/.bashrc:
   (no match — alias not set)
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/expected.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/expected.json
index c22ce920..d4da6280 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/expected.json
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/expected.json
@@ -4,7 +4,7 @@
     {"n": 1, "status": "✓", "evidence": "sandbox.enabled: true; deny=[Bash(cat ~/.aws/*:*), Bash(curl:*)]; ask=[Bash(git push:*)]; filesystem and network allowlists present"},
     {"n": 2, "status": "⚠", "evidence": "PreToolUse Bash → sandbox-bypass-warn.sh configured; PostToolUse hook for sandbox-error-hint.sh not configured; statusLine → sandbox-status-line.sh configured"},
     {"n": 3, "status": "✗", "evidence": "~/.claude/scripts/ directory does not exist — all three hook scripts missing"},
-    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/claude-iso.sh in ~/.bashrc; alias claude='claude-iso' set"},
+    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/agent-iso.sh in ~/.bashrc; alias claude='claude-iso' set"},
     {"n": 5, "status": "✓", "evidence": "claude-code 2.1.150 installed matches pinned-versions.toml 2.1.150"},
     {"n": 6, "status": "✓", "evidence": "effective sandbox.enabled: true (from .claude/settings.json)"},
     {"n": 7, "status": "✓", "evidence": "cat ~/.aws/credentials → Operation not permitted; $AWS_ACCESS_KEY_ID empty; curl denied at permission layer"},
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/report.md b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/report.md
index f2cd03ae..56e76d9d 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-3-missing-scripts/report.md
@@ -67,7 +67,7 @@ ls -la ~/.claude/scripts/:
 ## Check 4 — claude-iso sourced
 
 grep claude-iso ~/.bashrc:
-  source ~/.claude/scripts/claude-iso.sh
+  source ~/.claude/scripts/agent-iso.sh
 
 grep "alias claude=" ~/.bashrc:
   alias claude='claude-iso'
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/expected.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/expected.json
index 47e87d70..ef93b20f 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/expected.json
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/expected.json
@@ -4,7 +4,7 @@
     {"n": 1, "status": "✓", "evidence": "sandbox.enabled: true; deny and ask lists present; network and filesystem allowlists configured"},
     {"n": 2, "status": "✓", "evidence": "PreToolUse Bash → sandbox-bypass-warn.sh; PostToolUse Bash → sandbox-error-hint.sh; statusLine → sandbox-status-line.sh"},
     {"n": 3, "status": "✓", "evidence": "sandbox-bypass-warn.sh ✓ executable, sandbox-error-hint.sh ✓ executable, sandbox-status-line.sh ✓ executable"},
-    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/claude-iso.sh in ~/.zshrc; alias claude='claude-iso' set"},
+    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/agent-iso.sh in ~/.zshrc; alias claude='claude-iso' set"},
     {"n": 5, "status": "⚠", "evidence": "claude-code 2.2.0 installed, pinned-versions.toml pins 2.1.150 — newer than pin"},
     {"n": 6, "status": "✓", "evidence": "effective sandbox.enabled: true (from .claude/settings.json)"},
     {"n": 7, "status": "✓", "evidence": "cat ~/.aws/credentials → Operation not permitted; $AWS_ACCESS_KEY_ID empty; curl denied at permission layer"},
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/report.md b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/report.md
index 6b354369..4a6b0676 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-4-version-drift/report.md
@@ -73,7 +73,7 @@ ls -la ~/.claude/scripts/:
 ## Check 4 — claude-iso sourced
 
 grep claude-iso ~/.zshrc:
-  source ~/.claude/scripts/claude-iso.sh
+  source ~/.claude/scripts/agent-iso.sh
 
 grep "alias claude=" ~/.zshrc:
   alias claude='claude-iso'
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/expected.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/expected.json
index ea2fc9e4..5c0387d8 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/expected.json
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/expected.json
@@ -4,7 +4,7 @@
     {"n": 1, "status": "✓", "evidence": "sandbox.enabled: true; deny=[Bash(cat ~/.aws/*:*), Bash(curl:*)]; ask=[Bash(git push:*)]; allowlists configured"},
     {"n": 2, "status": "✓", "evidence": "PreToolUse Bash → sandbox-bypass-warn.sh; PostToolUse Bash → sandbox-error-hint.sh; statusLine → sandbox-status-line.sh"},
     {"n": 3, "status": "✓", "evidence": "sandbox-bypass-warn.sh ✓ executable, sandbox-error-hint.sh ✓ executable, sandbox-status-line.sh ✓ executable"},
-    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/claude-iso.sh in ~/.bashrc; alias claude='claude-iso' not set (optional)"},
+    {"n": 4, "status": "✓", "evidence": "source ~/.claude/scripts/agent-iso.sh in ~/.bashrc; alias claude='claude-iso' not set (optional)"},
     {"n": 5, "status": "✓", "evidence": "claude-code 2.1.150 installed matches pinned-versions.toml 2.1.150"},
     {"n": 6, "status": "✓", "evidence": "effective sandbox.enabled: true (from .claude/settings.json)"},
     {"n": 7, "status": "✓", "evidence": "cat ~/.aws/credentials → Operation not permitted; $AWS_ACCESS_KEY_ID empty; curl denied at permission layer"},
diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/report.md b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/report.md
index 9d034a3c..1ce5b05b 100644
--- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/report.md
+++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/case-5-project-root-missing/report.md
@@ -73,7 +73,7 @@ ls -la ~/.claude/scripts/:
 ## Check 4 — claude-iso sourced
 
 grep claude-iso ~/.bashrc:
-  source ~/.claude/scripts/claude-iso.sh
+  source ~/.claude/scripts/agent-iso.sh
 
 grep "alias claude=" ~/.bashrc:
   (no match)
diff --git a/tools/spec-loop/README.md b/tools/spec-loop/README.md
index b074469d..167917a7 100644
--- a/tools/spec-loop/README.md
+++ b/tools/spec-loop/README.md
@@ -5,7 +5,7 @@
 
 **Capability:** substrate:framework-dev
 
-**Harness:** Claude Code
+**Harness:** Claude Code, OpenCode
 
 A spec-driven build loop for this framework, in the general
 [Ralph](https://ghuntley.com/ralph/) style (run a fresh agent context
@@ -14,6 +14,24 @@ human-in-the-loop posture. The full write-up is in
 [`docs/spec-driven-development.md`](../../docs/spec-driven-development.md);
 this is the operator quickstart.
 
+The loop drives a **headless agent CLI** and is not tied to one harness.
+`SPEC_LOOP_AGENT` picks the CLI (default `claude`) and `SPEC_LOOP_HARNESS`
+picks its run convention — `claude` (`claude -p --dangerously-skip-permissions
+--output-format …`, prompt on stdin) or `opencode` (`opencode run --auto
+--model … "<prompt>"`, prompt as a positional argument). `SPEC_LOOP_HARNESS`
+defaults from the agent basename, so `SPEC_LOOP_AGENT=opencode` is usually all
+that is needed:
+
+```bash
+SPEC_LOOP_AGENT=opencode SPEC_LOOP_MODEL=anthropic/claude-sonnet-4-5 \
+  tools/spec-loop/loop.sh build 5
+```
+
+Both conventions run the agent non-interactively with permissions
+auto-approved; the loop's safety rails (never push, never open a PR) come from
+the OS sandbox and the loop's own guards, not from the harness — see the
+SECURITY notes in [`loop.sh`](loop.sh).
+
 ## Prerequisites
 
 - **Runtime:** Bash + coreutils (`loop.sh` is the runner); the
diff --git a/tools/spec-loop/loop.sh b/tools/spec-loop/loop.sh
index aa855bb5..1c1231ea 100755
--- a/tools/spec-loop/loop.sh
+++ b/tools/spec-loop/loop.sh
@@ -44,7 +44,14 @@
 #
 # Env overrides:
 #   SPEC_LOOP_BASE   branch to fork work items from (default: main)
-#   SPEC_LOOP_AGENT  Claude-compatible agent CLI to run (default: claude)
+#   SPEC_LOOP_AGENT  agent CLI to run (default: claude). Any headless agent
+#                    CLI works; the invocation convention is chosen by
+#                    SPEC_LOOP_HARNESS below.
+#   SPEC_LOOP_HARNESS  which harness's headless-run convention to use when
+#                    invoking SPEC_LOOP_AGENT: `claude` (default) or
+#                    `opencode`. Defaults from the agent basename (a CLI named
+#                    `opencode` selects the opencode convention), so setting
+#                    SPEC_LOOP_AGENT=opencode is usually enough.
 #   SPEC_LOOP_MODEL  model passed to the agent CLI (default: sonnet)
 #   SPEC_LOOP_PR_LIMIT  open PRs to list for duplicate-work checks (default: 100)
 #   SPEC_LOOP_PLAN_MAX  plan line count that triggers ONE consolidation
@@ -85,6 +92,13 @@ BASE="${SPEC_LOOP_BASE:-main}"
 TOOLING_REF="$(current_branch)"
 TOOLING_REF="${TOOLING_REF:-HEAD}"
 AGENT="${SPEC_LOOP_AGENT:-claude}"
+# Which harness's headless-run convention to use for $AGENT. Defaults from the
+# agent basename so `SPEC_LOOP_AGENT=opencode` is enough; override explicitly
+# with SPEC_LOOP_HARNESS when the CLI name doesn't match the convention.
+case "${SPEC_LOOP_HARNESS:-$(basename "$AGENT")}" in
+    *opencode*) HARNESS=opencode ;;
+    *)          HARNESS=claude ;;
+esac
 MODEL="${SPEC_LOOP_MODEL:-sonnet}"
 PR_LIMIT="${SPEC_LOOP_PR_LIMIT:-100}"
 # Agent output format. Default `text` is what the spinner expects; switch to
@@ -149,7 +163,8 @@ fi
 
 if ! command -v "$AGENT" >/dev/null 2>&1; then
     echo "Error: agent CLI '$AGENT' not found on PATH." >&2
-    echo "Set SPEC_LOOP_AGENT to a Claude-compatible CLI or wrapper." >&2
+    echo "Set SPEC_LOOP_AGENT to a headless agent CLI (claude / opencode / a wrapper)" >&2
+    echo "and SPEC_LOOP_HARNESS to its run convention if the name doesn't imply it." >&2
     exit 1
 fi
 
@@ -430,14 +445,35 @@ while true; do
     #                                   remote even with permissions skipped.
     # Claude CLI requires --verbose with -p when output-format is stream-json;
     # add it only in that case to keep the default `text` run quiet.
-    VERBOSE_ARGS=()
-    [ "$OUTPUT_FORMAT" = "stream-json" ] && VERBOSE_ARGS=(--verbose)
-    "$AGENT" -p \
-        --dangerously-skip-permissions \
-        --disallowedTools "Bash(git push:*)" "Bash(gh:*)" \
-        --output-format="$OUTPUT_FORMAT" \
-        ${VERBOSE_ARGS[@]+"${VERBOSE_ARGS[@]}"} \
-        --model "$MODEL" < "$PROMPT_WITH_CONTEXT" &
+    if [ "$HARNESS" = "opencode" ]; then
+        # OpenCode headless: `opencode run <message>` takes the prompt as a
+        # positional argument (no stdin), `--model provider/model`, `--auto`
+        # auto-approves permissions not explicitly denied (the OpenCode
+        # equivalent of Claude's --dangerously-skip-permissions), and
+        # `--format json` matches the stream-json request. OpenCode has no
+        # per-invocation --disallowedTools; the push/gh denial that flag
+        # provides as defense-in-depth is instead enforced by the OS sandbox
+        # (the real guard — see the SECURITY header) plus, when wired, the
+        # agent-guard OpenCode plugin and the project's opencode.json
+        # `permission` config.
+        OC_FORMAT_ARGS=()
+        [ "$OUTPUT_FORMAT" = "stream-json" ] && OC_FORMAT_ARGS=(--format json)
+        "$AGENT" run \
+            --auto \
+            --model "$MODEL" \
+            ${OC_FORMAT_ARGS[@]+"${OC_FORMAT_ARGS[@]}"} \
+            "$(cat "$PROMPT_WITH_CONTEXT")" &
+    else
+        # Claude Code headless (the default).
+        VERBOSE_ARGS=()
+        [ "$OUTPUT_FORMAT" = "stream-json" ] && VERBOSE_ARGS=(--verbose)
+        "$AGENT" -p \
+            --dangerously-skip-permissions \
+            --disallowedTools "Bash(git push:*)" "Bash(gh:*)" \
+            --output-format="$OUTPUT_FORMAT" \
+            ${VERBOSE_ARGS[@]+"${VERBOSE_ARGS[@]}"} \
+            --model "$MODEL" < "$PROMPT_WITH_CONTEXT" &
+    fi
     AGENT_PID=$!
     spinner "$AGENT_PID" & SPINNER_PID=$!
     wait "$AGENT_PID"