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"