From 988f98dae16f7fbe62c1982c8c9b16421276d79e Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Thu, 2 Jul 2026 09:50:03 +0200 Subject: [PATCH 01/14] Add swarm plugin scaffold with backend adapter layer (P1) - swarm 0.1.0: local mixture-of-agents code review (marketplace entry + plugin.json) - scripts/agents.sh: uniform adapter over claude/codex/grok with list/available/ready/run verbs; run enforces the shared findings schema (codex exec --output-schema, grok --json-schema) and prints uniform JSON on stdout - scripts/schema/finding.schema.json: shared findings shape all backends normalize into (failure_scenario required, falsifiable) - /swarm:agents skill: backend status table - E2E-verified against codex 0.128 and grok 0.2.77; fixes found in testing: close codex stdin (hangs on inherited non-TTY stdin) and keep the mktemp path global so the EXIT trap survives set -u Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- .claude-plugin/marketplace.json | 6 + .claude/knowledge/_index.md | 1 + .../features/swarm-backend-adapter.md | 54 ++++ README.md | 9 + plugins/swarm/.claude-plugin/plugin.json | 11 + plugins/swarm/README.md | 69 +++++ plugins/swarm/scripts/agents.sh | 252 ++++++++++++++++++ .../swarm/scripts/schema/finding.schema.json | 56 ++++ plugins/swarm/skills/agents/SKILL.md | 35 +++ 9 files changed, 493 insertions(+) create mode 100644 .claude/knowledge/features/swarm-backend-adapter.md create mode 100644 plugins/swarm/.claude-plugin/plugin.json create mode 100644 plugins/swarm/README.md create mode 100755 plugins/swarm/scripts/agents.sh create mode 100644 plugins/swarm/scripts/schema/finding.schema.json create mode 100644 plugins/swarm/skills/agents/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0fae09d..3c7d44f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -25,6 +25,12 @@ "source": "./plugins/pr-flow", "description": "PR review feedback loop for Claude Code. Create PRs with readiness checks, commit + push + trigger @claude review, inspect status, and work through review issues interactively.", "version": "1.2.2" + }, + { + "name": "swarm", + "source": "./plugins/swarm", + "description": "Local mixture-of-agents code review for Claude Code. Fans out to Claude subagents plus codex and grok CLIs in parallel, merges and verifies their findings, and presents one ranked report.", + "version": "0.1.0" } ] } diff --git a/.claude/knowledge/_index.md b/.claude/knowledge/_index.md index 2384125..76056e6 100644 --- a/.claude/knowledge/_index.md +++ b/.claude/knowledge/_index.md @@ -13,6 +13,7 @@ - `features/herdr-kickoff-automation.md` — herdr `herdr-launch.sh`: `launch` (`/kickoff`) + `resume` (`/continue ` reopens an `/exit`-closed tab) - `features/herdr-close-automation.md` — `/close` in herdr: cwd-tab teardown, plugin SessionEnd hook, the one TUI-exit primitive, detached self-exit onto idle - `features/task-archiving-on-close.md` — `/close` archives (not deletes) the task file; adaptive commit + ff-push to main +- `features/swarm-backend-adapter.md` — Verified codex/grok CLI facts (schema-enforced JSON, effort mapping, stdin hang) behind `swarm`'s adapter script ## Deployment - `deployment/ci-structure-checks.md` — `check-structure.py` as the single automated guard for a build-less repo diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md new file mode 100644 index 0000000..cb166e2 --- /dev/null +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -0,0 +1,54 @@ +--- +title: "Swarm Backend Adapter Layer" +createdAt: 2026-07-02 +createdFrom: "branch: task/add-swarm-plugin" +pluginVersion: 0.1.0 +prime: false +--- + +# Swarm Backend Adapter Layer + +The `swarm` plugin reviews locally with a mixture-of-agents ensemble: Claude +subagents plus the external `codex` and `grok` CLIs. All deterministic backend +logic lives in one script — `plugins/swarm/scripts/agents.sh` (verbs: `list`, +`available`, `ready`, `run`) — so skills never call an external CLI directly. +The script header documents the per-backend mechanics; this entry captures the +*verified* CLI behavior the adapter is built on and the gotchas that cost a +debugging round. + +## Verified CLI facts (codex 0.128 / grok 0.2.77, 2026-07) + +- **Uniform findings JSON** is achievable from both CLIs: `codex exec + --output-schema ` and `grok --json-schema ''` both enforce a + JSON Schema on the final answer. One bundled schema + (`scripts/schema/finding.schema.json`) feeds both — the ensemble merge never + parses free-form review prose. In strict structured-output modes all + properties must be `required`, so the schema requires every field and uses + honest defaults (`line: 0`, self-reported `confidence`) instead of optionals. +- **Where the JSON lands differs per CLI**: codex writes the pure JSON via + `--output-last-message ` (stdout carries the agent transcript, + stderr the progress log); grok prints a response **envelope** on stdout — + the validated object is its `.structuredOutput` field. +- **grok's default model rejects `--effort`** (`grok-composer-2.5-fast` errors + with "does not support parameter reasoningEffort"). The adapter must pin + `-m grok-build`. grok's effort ladder (low…max) matches code-review's; + codex has no `max` tier → map `max`→`xhigh` (`-c model_reasoning_effort=…`). +- **Headless tool execution**: both CLIs run read-only commands (e.g. + `git diff`) without extra approval flags — codex inside `-s read-only` + sandbox, grok headless `-p` auto-approves read-only tools. So lens prompts + may either inline the diff or instruct the agent to read it itself. + +## Gotchas (found in E2E testing, fixed in the adapter) + +- **codex hangs on inherited stdin.** With an open non-TTY stdin, `codex exec` + waits for "additional input from stdin" *in addition to* the positional + prompt — in a background shell this hangs forever. Always call it with + ` # installed? prints version +agents.sh ready # authenticated? hint on stderr if not +agents.sh run [--prompt-file f] [--effort E] [--model M] [--schema f] + # lens prompt in → findings JSON out +``` + +Backends: + +| Backend | Role | Mechanics | +|---------|------|-----------| +| `claude` | probe-only | reviews run in-session via the Agent tool | +| `codex` | external reviewer | `codex exec --output-schema` in a read-only sandbox; auth via `codex login status` | +| `grok` | external reviewer | headless `-p` with inline `--json-schema`; findings extracted from the response envelope | + +Unavailable backends drop silently from the ensemble — `claude` alone still +works. + +### Shared findings schema (`scripts/schema/finding.schema.json`) + +Both external CLIs enforce the same JSON schema on their output, so the +ensemble merge receives uniform findings: + +```json +{ "file", "line", "severity": "critical|warning|minor", + "summary", "failure_scenario", "confidence": "high|medium|low", + "recommendation" } +``` + +`failure_scenario` is required and must be falsifiable — it is what the +verifier tests in the confidence phase. + +## Requirements + +- `python3` on PATH (JSON handling in the adapter). +- `codex` and/or `grok` CLIs are optional — install and authenticate them to + widen the ensemble. diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh new file mode 100755 index 0000000..ec84475 --- /dev/null +++ b/plugins/swarm/scripts/agents.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# agents.sh — swarm backend adapter layer +# +# Uniform interface over the review backends (claude, codex, grok) so swarm +# skills never talk to an external CLI directly. +# +# Subcommands: +# list [--json] Probe all backends -> human table or JSON array +# available Exit 0 if the CLI is installed; prints its version +# ready Exit 0 if authenticated/usable; hint on stderr if not +# run [opts] Run a review prompt -> findings JSON on stdout +# --prompt-file Read the lens prompt from a file (default: stdin) +# --effort low|medium|high|xhigh|max (default: xhigh) +# --model Backend model override +# --schema JSON schema to enforce (default: bundled finding.schema.json) +# +# Backend notes (probed against codex 0.128 / grok 0.2.77, 2026-07): +# claude — probe-only: reviews run in-session via the Agent tool, so +# `run claude` is a usage error. available/ready/list include it. +# codex — `codex exec --output-schema` in a read-only sandbox; the pure +# schema JSON arrives via --output-last-message (stdout carries the +# agent transcript, which we discard). Auth: `codex login status`. +# Reasoning effort has no "max" tier -> max maps to xhigh. +# grok — headless `-p` with inline --json-schema; the validated object is +# the `.structuredOutput` field of a response envelope. Needs an +# explicit model (-m): the default grok model rejects --effort. +# Auth heuristic: non-empty ~/.grok/auth.json (no status command). +# +# Exit codes: 0 ok · 1 unavailable / not ready / run failed · 2 usage error + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_SCHEMA="$SCRIPT_DIR/schema/finding.schema.json" +GROK_DEFAULT_MODEL="grok-build" +GROK_AUTH_FILE="${GROK_AUTH_FILE:-$HOME/.grok/auth.json}" + +# Temp file for codex's --output-last-message; must be a global (not a +# function-local) so the EXIT trap still sees it under `set -u`. +TMP_OUT="" +cleanup() { if [[ -n "${TMP_OUT:-}" ]]; then rm -f "$TMP_OUT"; fi; } +trap cleanup EXIT + +usage() { + sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||' + exit 2 +} + +validate_backend() { + case "$1" in + claude|codex|grok) ;; + *) echo "Unknown backend: $1 (expected claude|codex|grok)" >&2; exit 2 ;; + esac +} + +# ---------- probes ---------- + +available_version() { + # Prints the CLI's version line; exit 1 if not installed. + local backend="$1" + command -v "$backend" >/dev/null || return 1 + "$backend" --version 2>/dev/null | head -1 +} + +ready_check() { + local backend="$1" + case "$backend" in + claude) return 0 ;; # in-session, no separate auth + codex) codex login status >/dev/null 2>&1 ;; + grok) [[ -s "$GROK_AUTH_FILE" ]] ;; + esac +} + +ready_hint() { + case "$1" in + claude) echo "install Claude Code" ;; + codex) echo "run: codex login" ;; + grok) echo "run: grok login" ;; + esac +} + +# ---------- subcommands ---------- + +subcmd_available() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + validate_backend "$backend" + available_version "$backend" +} + +subcmd_ready() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + validate_backend "$backend" + if ! available_version "$backend" >/dev/null; then + echo "$backend: not installed" >&2 + exit 1 + fi + if ready_check "$backend"; then + echo "ready" + else + echo "$backend: not ready — $(ready_hint "$backend")" >&2 + exit 1 + fi +} + +print_rows() { + # One TSV row per backend: backend, available, version, ready, hint + local b ver avail rdy hint + for b in claude codex grok; do + ver="" avail=no rdy=no hint="" + if ver="$(available_version "$b")"; then + avail=yes + if ready_check "$b"; then rdy=yes; else hint="$(ready_hint "$b")"; fi + else + hint="not installed" + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$b" "$avail" "$ver" "$rdy" "$hint" + done +} + +subcmd_list() { + if [[ "${1:-}" == "--json" ]]; then + print_rows | python3 -c ' +import json, sys +rows = [] +for line in sys.stdin: + b, avail, ver, rdy, hint = (line.rstrip("\n").split("\t") + [""] * 5)[:5] + rows.append({"backend": b, "available": avail == "yes", "version": ver, + "ready": rdy == "yes", "hint": hint}) +json.dump(rows, sys.stdout, indent=2) +print() +' + else + { printf 'BACKEND\tAVAILABLE\tVERSION\tREADY\tHINT\n'; print_rows; } \ + | column -t -s $'\t' + fi +} + +subcmd_run() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + shift + validate_backend "$backend" + if [[ "$backend" == "claude" ]]; then + echo "claude reviews run in-session via the Agent tool, not through this adapter" >&2 + exit 2 + fi + + local prompt_file="" effort="xhigh" model="" schema="$DEFAULT_SCHEMA" + while [[ $# -gt 0 ]]; do + case "$1" in + --prompt-file) prompt_file="$2"; shift 2 ;; + --effort) effort="$2"; shift 2 ;; + --model) model="$2"; shift 2 ;; + --schema) schema="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac + done + case "$effort" in + low|medium|high|xhigh|max) ;; + *) echo "Invalid effort: $effort (low|medium|high|xhigh|max)" >&2; exit 2 ;; + esac + [[ -f "$schema" ]] || { echo "Schema not found: $schema" >&2; exit 2; } + + local prompt + if [[ -n "$prompt_file" ]]; then + prompt="$(cat "$prompt_file")" + else + prompt="$(cat)" + fi + [[ -z "$prompt" ]] && { echo "Empty prompt (use --prompt-file or stdin)" >&2; exit 2; } + + if ! available_version "$backend" >/dev/null; then + echo "$backend: not installed" >&2 + exit 1 + fi + if ! ready_check "$backend"; then + echo "$backend: not ready — $(ready_hint "$backend")" >&2 + exit 1 + fi + + case "$backend" in + codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; + grok) run_grok "$prompt" "$effort" "$model" "$schema" ;; + esac +} + +run_codex() { + local prompt="$1" effort="$2" model="$3" schema="$4" + [[ "$effort" == "max" ]] && effort="xhigh" + + TMP_OUT="$(mktemp)" + + # The schema-validated JSON lands in $TMP_OUT; codex's stdout copy of the + # final message is discarded (its transcript goes to stderr = debug info). + # stdin must be closed: with an inherited open non-TTY stdin, codex waits + # for "additional input from stdin" and hangs. + if ! codex exec -s read-only --skip-git-repo-check \ + -c model_reasoning_effort="$effort" \ + ${model:+-m "$model"} \ + --output-schema "$schema" \ + --output-last-message "$TMP_OUT" \ + "$prompt" /dev/null; then + echo "codex exec failed" >&2 + exit 1 + fi + [[ -s "$TMP_OUT" ]] || { echo "codex produced no output" >&2; exit 1; } + python3 -c 'import json,sys; json.load(sys.stdin)' <"$TMP_OUT" 2>/dev/null \ + || { echo "codex returned invalid JSON" >&2; exit 1; } + cat "$TMP_OUT" + echo +} + +run_grok() { + local prompt="$1" effort="$2" model="$3" schema="$4" + local raw + if ! raw="$(grok -m "${model:-$GROK_DEFAULT_MODEL}" --effort "$effort" \ + --json-schema "$(cat "$schema")" \ + -p "$prompt" &2 + exit 1 + fi + printf '%s' "$raw" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +if d.get("type") == "error": + sys.stderr.write("grok error: %s\n" % d.get("message", "unknown")) + sys.exit(1) +out = d.get("structuredOutput") +if out is None: + sys.stderr.write("grok returned no structuredOutput\n") + sys.exit(1) +json.dump(out, sys.stdout) +print() +' +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + list) subcmd_list "$@" ;; + available) subcmd_available "$@" ;; + ready) subcmd_ready "$@" ;; + run) subcmd_run "$@" ;; + ""|-h|--help) usage ;; + *) echo "Unknown subcommand: $cmd" >&2; usage ;; + esac +} + +main "$@" diff --git a/plugins/swarm/scripts/schema/finding.schema.json b/plugins/swarm/scripts/schema/finding.schema.json new file mode 100644 index 0000000..b8ccba5 --- /dev/null +++ b/plugins/swarm/scripts/schema/finding.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Swarm review findings", + "description": "Shared findings shape every swarm backend normalizes into. Enforced on codex via `codex exec --output-schema` and on grok via `--json-schema`, so the ensemble merge receives uniform JSON. All item fields are required (strict structured-output modes reject optional properties); use line=0 and confidence as honest defaults rather than omitting.", + "type": "object", + "additionalProperties": false, + "required": ["findings"], + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "file", + "line", + "severity", + "summary", + "failure_scenario", + "confidence", + "recommendation" + ], + "properties": { + "file": { + "type": "string", + "description": "Repo-relative path of the file the finding is in" + }, + "line": { + "type": "integer", + "description": "1-indexed line the finding anchors to; 0 for file-level findings" + }, + "severity": { + "enum": ["critical", "warning", "minor"], + "description": "critical = correctness/security defect; warning = likely problem or risky pattern; minor = cleanup/style" + }, + "summary": { + "type": "string", + "description": "One-sentence statement of the defect" + }, + "failure_scenario": { + "type": "string", + "description": "Concrete inputs/state leading to wrong output or crash. Must be falsifiable — this is what the verifier tests. For style findings: the concrete maintenance cost." + }, + "confidence": { + "enum": ["high", "medium", "low"], + "description": "Reviewer's own confidence that the finding is real" + }, + "recommendation": { + "type": "string", + "description": "Suggested fix in one or two sentences" + } + } + } + } + } +} diff --git a/plugins/swarm/skills/agents/SKILL.md b/plugins/swarm/skills/agents/SKILL.md new file mode 100644 index 0000000..2ef4eb9 --- /dev/null +++ b/plugins/swarm/skills/agents/SKILL.md @@ -0,0 +1,35 @@ +--- +name: agents +description: | + Shows swarm backend status: which review agents (claude, codex, grok) are + installed and authenticated. + Trigger: "swarm agents", "which review backends are live", "agent status". +user_invocable: true +--- + +# Swarm Agent Status + +> Probe all review backends and report what `/swarm:review` would use right now. + +## Instructions + +1. Run: `bash "${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh" list --json` +2. Render the JSON array as a table: + + | Backend | Installed | Version | Ready | Notes | + |---------|-----------|---------|-------|-------| + + - `available: false` → Installed ❌, Notes = "not installed" + - `available: true, ready: false` → Ready ❌, Notes = the `hint` field (e.g. "run: codex login") + - both true → ✅ ✅, Notes empty +3. Close with one line stating which backends a review would fan out to right + now (all with `available && ready`), e.g.: + `Ensemble: claude + codex + grok — full swarm available.` + If only claude is live, note that reviews still work but without + cross-agent consensus. + +## Notes + +- Read-only, no side effects — safe to run anytime. +- `claude` is always ready when Claude Code runs (reviews happen in-session + via the Agent tool; the external CLIs are called through the adapter). From 1c05a63d3b345681ba7980546882d2493a3eaf51 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Thu, 2 Jul 2026 14:39:24 +0200 Subject: [PATCH 02/14] Document composer model limitations in adapter notes grok-composer-2.5-fast rejects --effort and does not enforce --json-schema (structuredOutput stays null), so grok-build is the only schema-capable grok voice; a second same-family voice would also dilute the consensus signal. Prefer grok's native --best-of-n at high effort. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- .claude/knowledge/features/swarm-backend-adapter.md | 7 +++++++ plugins/swarm/scripts/agents.sh | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md index cb166e2..cfe5b97 100644 --- a/.claude/knowledge/features/swarm-backend-adapter.md +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -33,6 +33,13 @@ debugging round. with "does not support parameter reasoningEffort"). The adapter must pin `-m grok-build`. grok's effort ladder (low…max) matches code-review's; codex has no `max` tier → map `max`→`xhigh` (`-c model_reasoning_effort=…`). +- **`grok-composer-2.5-fast` also does not enforce `--json-schema`**: it + returns plain text with `structuredOutput: null` + + `structuredOutputError: "model output was not valid JSON"`. So it cannot + serve as a second grok ensemble voice (besides being same-family-correlated, + which would dilute the ≥2-backend consensus signal). `grok-build` is the + only schema-capable grok model; for "more grok" at high effort, prefer its + native `--best-of-n N` over a second model voice. - **Headless tool execution**: both CLIs run read-only commands (e.g. `git diff`) without extra approval flags — codex inside `-s read-only` sandbox, grok headless `-p` auto-approves read-only tools. So lens prompts diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index ec84475..b5929a5 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -23,7 +23,9 @@ # Reasoning effort has no "max" tier -> max maps to xhigh. # grok — headless `-p` with inline --json-schema; the validated object is # the `.structuredOutput` field of a response envelope. Needs an -# explicit model (-m): the default grok model rejects --effort. +# explicit model (-m): the default model (grok-composer-2.5-fast) +# rejects --effort AND ignores --json-schema (structuredOutput +# stays null) — grok-build is the only schema-capable choice. # Auth heuristic: non-empty ~/.grok/auth.json (no status command). # # Exit codes: 0 ok · 1 unavailable / not ready / run failed · 2 usage error From 92f76ee8b51b46c888d207f539ef608f08333a42 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Thu, 2 Jul 2026 22:34:15 +0200 Subject: [PATCH 03/14] Fix 15 code-review findings in the swarm adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (silent ensemble drops): - Pass prompts hyphen-safe: `--` before codex positional, --single= for grok (leading "-" no longer parsed as a flag; E2E-verified on both) - claude availability is in-session by definition — no PATH gate - Usage-class errors in `run` now exit 2, not 1 (missing flag value, missing prompt file); 256 KiB prompt guard with a clear message instead of an opaque ARG_MAX exec failure Robustness/diagnostics: - Placeholder `-` for empty fields (BSD column collapses adjacent tabs) and tab-sanitized version strings in list output - list rejects unknown flags (exit 2) instead of silent table fallback - --help exits 0; usage block bounded by pattern, not line numbers - python3 preflight; grok/codex JSON parsing catches decode errors cleanly instead of tracebacks / masked "invalid JSON" - --effort only sent to grok-build (other models reject it) Docs/cleanup: - /swarm:agents no longer references the unshipped /swarm:review - plugin description marks phase 1 honestly (plugin.json + marketplace) - Shared require_usable gate (was duplicated in ready/run); dead claude ready_hint removed; knowledge entry pluginVersion corrected Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- .claude-plugin/marketplace.json | 2 +- .../features/swarm-backend-adapter.md | 2 +- plugins/swarm/.claude-plugin/plugin.json | 2 +- plugins/swarm/scripts/agents.sh | 134 +++++++++++++----- plugins/swarm/skills/agents/SKILL.md | 13 +- 5 files changed, 109 insertions(+), 44 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3c7d44f..c9bb844 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -29,7 +29,7 @@ { "name": "swarm", "source": "./plugins/swarm", - "description": "Local mixture-of-agents code review for Claude Code. Fans out to Claude subagents plus codex and grok CLIs in parallel, merges and verifies their findings, and presents one ranked report.", + "description": "Local mixture-of-agents code review for Claude Code. Will fan out to Claude subagents plus codex and grok CLIs, merge and verify findings into one ranked report. Phase 1 ships the backend adapter and /swarm:agents status.", "version": "0.1.0" } ] diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md index cfe5b97..a1e551a 100644 --- a/.claude/knowledge/features/swarm-backend-adapter.md +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -2,7 +2,7 @@ title: "Swarm Backend Adapter Layer" createdAt: 2026-07-02 createdFrom: "branch: task/add-swarm-plugin" -pluginVersion: 0.1.0 +pluginVersion: 1.8.2 prime: false --- diff --git a/plugins/swarm/.claude-plugin/plugin.json b/plugins/swarm/.claude-plugin/plugin.json index b184b9c..2a79c15 100644 --- a/plugins/swarm/.claude-plugin/plugin.json +++ b/plugins/swarm/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "swarm", - "description": "Local mixture-of-agents code review for Claude Code. Fans out to Claude subagents plus codex and grok CLIs in parallel, merges and verifies their findings, and presents one ranked report.", + "description": "Local mixture-of-agents code review for Claude Code. Will fan out to Claude subagents plus codex and grok CLIs, merge and verify findings into one ranked report. Phase 1 ships the backend adapter and /swarm:agents status.", "version": "0.1.0", "author": { "name": "gering" diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index b5929a5..49637bf 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -43,11 +43,22 @@ TMP_OUT="" cleanup() { if [[ -n "${TMP_OUT:-}" ]]; then rm -f "$TMP_OUT"; fi; } trap cleanup EXIT +print_usage() { + # Usage block = header comment up to (not including) "# Backend notes"; + # bounded by pattern, not line numbers, so header edits can't truncate it. + awk 'NR < 2 {next} /^# Backend notes/ {exit} {sub(/^# ?/, ""); print}' "$0" +} + usage() { - sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||' + print_usage >&2 exit 2 } +require_python3() { + command -v python3 >/dev/null \ + || { echo "python3 not found on PATH — required by the swarm adapter" >&2; exit 1; } +} + validate_backend() { case "$1" in claude|codex|grok) ;; @@ -58,8 +69,15 @@ validate_backend() { # ---------- probes ---------- available_version() { - # Prints the CLI's version line; exit 1 if not installed. + # Prints the backend's version line; exit 1 if not installed. local backend="$1" + if [[ "$backend" == "claude" ]]; then + # claude reviews run in-session via the Agent tool, so inside a Claude + # Code session the backend exists by definition — the PATH lookup only + # provides a nicer version string, never gates availability. + claude --version 2>/dev/null | head -1 || echo "in-session" + return 0 + fi command -v "$backend" >/dev/null || return 1 "$backend" --version 2>/dev/null | head -1 } @@ -74,10 +92,10 @@ ready_check() { } ready_hint() { + # claude needs no hint: it is always available + ready in-session. case "$1" in - claude) echo "install Claude Code" ;; - codex) echo "run: codex login" ;; - grok) echo "run: grok login" ;; + codex) echo "run: codex login" ;; + grok) echo "run: grok login" ;; esac } @@ -90,24 +108,32 @@ subcmd_available() { available_version "$backend" } -subcmd_ready() { - local backend="${1:-}" - [[ -z "$backend" ]] && usage - validate_backend "$backend" +require_usable() { + # Shared installed+ready gate for `ready` and `run`. + local backend="$1" if ! available_version "$backend" >/dev/null; then echo "$backend: not installed" >&2 exit 1 fi - if ready_check "$backend"; then - echo "ready" - else + if ! ready_check "$backend"; then echo "$backend: not ready — $(ready_hint "$backend")" >&2 exit 1 fi } +subcmd_ready() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + validate_backend "$backend" + require_usable "$backend" + echo "ready" +} + print_rows() { - # One TSV row per backend: backend, available, version, ready, hint + # One TSV row per backend: backend, available, version, ready, hint. + # $1 fills empty fields — the human table needs a placeholder because BSD + # column collapses adjacent tabs, shifting later columns left. + local placeholder="${1:-}" local b ver avail rdy hint for b in claude codex grok; do ver="" avail=no rdy=no hint="" @@ -117,13 +143,16 @@ print_rows() { else hint="not installed" fi - printf '%s\t%s\t%s\t%s\t%s\n' "$b" "$avail" "$ver" "$rdy" "$hint" + ver="${ver//$'\t'/ }" # a tab inside a version string would shift the TSV columns + printf '%s\t%s\t%s\t%s\t%s\n' "$b" "$avail" "${ver:-$placeholder}" "$rdy" "${hint:-$placeholder}" done } subcmd_list() { - if [[ "${1:-}" == "--json" ]]; then - print_rows | python3 -c ' + case "${1:-}" in + --json) + require_python3 + print_rows | python3 -c ' import json, sys rows = [] for line in sys.stdin: @@ -133,10 +162,16 @@ for line in sys.stdin: json.dump(rows, sys.stdout, indent=2) print() ' - else - { printf 'BACKEND\tAVAILABLE\tVERSION\tREADY\tHINT\n'; print_rows; } \ - | column -t -s $'\t' - fi + ;; + "") + { printf 'BACKEND\tAVAILABLE\tVERSION\tREADY\tHINT\n'; print_rows "-"; } \ + | column -t -s $'\t' + ;; + *) + echo "Unknown flag: $1" >&2 + exit 2 + ;; + esac } subcmd_run() { @@ -151,6 +186,7 @@ subcmd_run() { local prompt_file="" effort="xhigh" model="" schema="$DEFAULT_SCHEMA" while [[ $# -gt 0 ]]; do + [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; } case "$1" in --prompt-file) prompt_file="$2"; shift 2 ;; --effort) effort="$2"; shift 2 ;; @@ -167,21 +203,23 @@ subcmd_run() { local prompt if [[ -n "$prompt_file" ]]; then + [[ -f "$prompt_file" ]] || { echo "Prompt file not found: $prompt_file" >&2; exit 2; } prompt="$(cat "$prompt_file")" else prompt="$(cat)" fi [[ -z "$prompt" ]] && { echo "Empty prompt (use --prompt-file or stdin)" >&2; exit 2; } - - if ! available_version "$backend" >/dev/null; then - echo "$backend: not installed" >&2 - exit 1 - fi - if ! ready_check "$backend"; then - echo "$backend: not ready — $(ready_hint "$backend")" >&2 - exit 1 + # The prompt travels as one argv word; stay well below ARG_MAX (~1 MiB + # including the environment on macOS) instead of failing as a generic + # backend error at exec time. + if (( ${#prompt} > 262144 )); then + echo "Prompt too large ($(( ${#prompt} / 1024 )) KiB > 256 KiB) — inline less of the diff, or instruct the agent to read it itself" >&2 + exit 2 fi + require_usable "$backend" + require_python3 + case "$backend" in codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; grok) run_grok "$prompt" "$effort" "$model" "$schema" ;; @@ -198,34 +236,59 @@ run_codex() { # final message is discarded (its transcript goes to stderr = debug info). # stdin must be closed: with an inherited open non-TTY stdin, codex waits # for "additional input from stdin" and hangs. + # `--` ends flag parsing: a prompt starting with "-" (e.g. a markdown + # bullet) would otherwise be rejected as an unknown flag. if ! codex exec -s read-only --skip-git-repo-check \ -c model_reasoning_effort="$effort" \ ${model:+-m "$model"} \ --output-schema "$schema" \ --output-last-message "$TMP_OUT" \ - "$prompt" /dev/null; then + -- "$prompt" /dev/null; then echo "codex exec failed" >&2 exit 1 fi [[ -s "$TMP_OUT" ]] || { echo "codex produced no output" >&2; exit 1; } - python3 -c 'import json,sys; json.load(sys.stdin)' <"$TMP_OUT" 2>/dev/null \ - || { echo "codex returned invalid JSON" >&2; exit 1; } + python3 -c ' +import json, sys +try: + json.load(sys.stdin) +except Exception: + sys.exit(1) +' <"$TMP_OUT" || { echo "codex returned invalid JSON" >&2; exit 1; } cat "$TMP_OUT" echo } run_grok() { local prompt="$1" effort="$2" model="$3" schema="$4" + local grok_model="${model:-$GROK_DEFAULT_MODEL}" + + # Only grok-build understands --effort; other models reject the parameter + # outright (and don't enforce --json-schema either — see the knowledge + # entry), so omit the flag instead of failing the run. + local effort_args=() + if [[ "$grok_model" == "$GROK_DEFAULT_MODEL" ]]; then + effort_args=(--effort "$effort") + else + echo "note: --effort omitted — only $GROK_DEFAULT_MODEL supports it" >&2 + fi + + # --single= (not "-p "): as a separate argv word a prompt + # starting with "-" would be parsed as a flag. local raw - if ! raw="$(grok -m "${model:-$GROK_DEFAULT_MODEL}" --effort "$effort" \ + if ! raw="$(grok -m "$grok_model" ${effort_args[@]+"${effort_args[@]}"} \ --json-schema "$(cat "$schema")" \ - -p "$prompt" &2 exit 1 fi printf '%s' "$raw" | python3 -c ' import json, sys -d = json.load(sys.stdin) +try: + d = json.load(sys.stdin) +except Exception: + sys.stderr.write("grok returned invalid JSON\n") + sys.exit(1) if d.get("type") == "error": sys.stderr.write("grok error: %s\n" % d.get("message", "unknown")) sys.exit(1) @@ -246,7 +309,8 @@ main() { available) subcmd_available "$@" ;; ready) subcmd_ready "$@" ;; run) subcmd_run "$@" ;; - ""|-h|--help) usage ;; + -h|--help) print_usage; exit 0 ;; + "") usage ;; *) echo "Unknown subcommand: $cmd" >&2; usage ;; esac } diff --git a/plugins/swarm/skills/agents/SKILL.md b/plugins/swarm/skills/agents/SKILL.md index 2ef4eb9..8bd8e4c 100644 --- a/plugins/swarm/skills/agents/SKILL.md +++ b/plugins/swarm/skills/agents/SKILL.md @@ -9,7 +9,7 @@ user_invocable: true # Swarm Agent Status -> Probe all review backends and report what `/swarm:review` would use right now. +> Probe all review backends and report which are live. ## Instructions @@ -22,11 +22,12 @@ user_invocable: true - `available: false` → Installed ❌, Notes = "not installed" - `available: true, ready: false` → Ready ❌, Notes = the `hint` field (e.g. "run: codex login") - both true → ✅ ✅, Notes empty -3. Close with one line stating which backends a review would fan out to right - now (all with `available && ready`), e.g.: - `Ensemble: claude + codex + grok — full swarm available.` - If only claude is live, note that reviews still work but without - cross-agent consensus. +3. Close with one line stating which backends are live (all with + `available && ready`), e.g.: + `Live backends: claude + codex + grok — full ensemble.` + If only claude is live, note that installing/authenticating the external + CLIs (`codex`, `grok`) would widen the ensemble. Do not reference other + swarm commands until they ship. ## Notes From 072e191d5151b871814d733aed0afbca0a3431a0 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 09:55:46 +0200 Subject: [PATCH 04/14] Harden adapter per swarm ensemble dry-run findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four robustness fixes surfaced by a codex+grok self-review of PR #21: - available_version: `|| true` + explicit `return 0` so a non-zero --version exit or SIGPIPE from head() under pipefail can't flip an installed backend to "unavailable" - run_codex: pass --model via an array, not unquoted ${model:+…}, so a model name with whitespace stays one argv word (matches run_grok) - run_grok parser: reject non-object JSON before .get() (no traceback), and include a snippet in the invalid-JSON message for triage E2E re-verified: codex + grok both return schema-valid JSON (exit 0) on a hyphen-leading prompt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/scripts/agents.sh | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 49637bf..b23459f 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -79,7 +79,12 @@ available_version() { return 0 fi command -v "$backend" >/dev/null || return 1 - "$backend" --version 2>/dev/null | head -1 + # Best-effort version string. `|| true` + explicit `return 0`: once + # `command -v` confirmed the CLI, a non-zero `--version` exit or a SIGPIPE + # from head() (under pipefail) must NOT flip an installed backend to + # "unavailable". + "$backend" --version 2>/dev/null | head -1 || true + return 0 } ready_check() { @@ -232,6 +237,11 @@ run_codex() { TMP_OUT="$(mktemp)" + # Array (not unquoted ${model:+…}) so a model name with whitespace is one + # argv word, matching the effort_args idiom in run_grok. + local model_args=() + [[ -n "$model" ]] && model_args=(-m "$model") + # The schema-validated JSON lands in $TMP_OUT; codex's stdout copy of the # final message is discarded (its transcript goes to stderr = debug info). # stdin must be closed: with an inherited open non-TTY stdin, codex waits @@ -240,7 +250,7 @@ run_codex() { # bullet) would otherwise be rejected as an unknown flag. if ! codex exec -s read-only --skip-git-repo-check \ -c model_reasoning_effort="$effort" \ - ${model:+-m "$model"} \ + ${model_args[@]+"${model_args[@]}"} \ --output-schema "$schema" \ --output-last-message "$TMP_OUT" \ -- "$prompt" /dev/null; then @@ -284,10 +294,16 @@ run_grok() { fi printf '%s' "$raw" | python3 -c ' import json, sys +data = sys.stdin.read() try: - d = json.load(sys.stdin) + d = json.loads(data) except Exception: - sys.stderr.write("grok returned invalid JSON\n") + # Include a snippet so a non-JSON envelope (banner/warning on stdout) is + # triageable instead of an opaque "invalid JSON". + sys.stderr.write("grok returned invalid JSON: %s\n" % (data[:120].replace("\n", " ") or "")) + sys.exit(1) +if not isinstance(d, dict): + sys.stderr.write("grok returned non-object JSON (%s)\n" % type(d).__name__) sys.exit(1) if d.get("type") == "error": sys.stderr.write("grok error: %s\n" % d.get("message", "unknown")) From f78421518077dab0748f720621667080f3b67360 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 11:06:59 +0200 Subject: [PATCH 05/14] Fix findings from the full-pipeline swarm dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second ensemble pass (Claude lenses + codex + grok) on PR #21 surfaced: - available_version claude branch: capture version separately so a SIGPIPE from head() under pipefail can't print both the real version AND the "in-session" fallback (regression from the prior hardening pass) - list without --json: route through column_or_cat so a host lacking util-linux `column` degrades to raw TSV instead of dying (exit 127) - CLAUDE.md: register the swarm plugin in "Current Plugins" - knowledge entry: add updatedAt/updatedFrom to match sibling files (A codex claim that --skip-git-repo-check needs a bypass flag was REFUTED by the verify pass — tested, runs fine outside a repo — so not applied.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- .../features/swarm-backend-adapter.md | 2 ++ CLAUDE.md | 1 + plugins/swarm/scripts/agents.sh | 20 +++++++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md index a1e551a..0f86f5b 100644 --- a/.claude/knowledge/features/swarm-backend-adapter.md +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -2,6 +2,8 @@ title: "Swarm Backend Adapter Layer" createdAt: 2026-07-02 createdFrom: "branch: task/add-swarm-plugin" +updatedAt: 2026-07-02 +updatedFrom: "branch: task/add-swarm-plugin" pluginVersion: 1.8.2 prime: false --- diff --git a/CLAUDE.md b/CLAUDE.md index 2c055b5..c1377d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ This is a **Claude Code plugin marketplace** (monorepo) containing plugins that - **knowledge-system** (v1.5.x) — Knowledge management with three layers: Rules, Knowledge, Memory. Skills: `/init`, `/query`, `/curate`, `/reindex`, `/backfill-knowledge`, `/migrate`, `/statusline` - **work-system** (v1.2.x) — Task and worktree workflow. Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list` - **pr-flow** (v1.1.x) — PR review feedback loop. Skills: `/open`, `/cycle`, `/check`, `/fix`, `/rebase`, `/merge` +- **swarm** (v0.1.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs + Claude subagents). P1: backend adapter + status. Skills: `/agents` ## Plugin Anatomy diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index b23459f..1c53f88 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -59,6 +59,17 @@ require_python3() { || { echo "python3 not found on PATH — required by the swarm adapter" >&2; exit 1; } } +column_or_cat() { + # Align TSV into columns when util-linux `column` is present; otherwise pass + # the raw TSV through so `list` degrades instead of dying (exit 127) under + # set -euo pipefail on a minimal host. + if command -v column >/dev/null; then + column -t -s $'\t' + else + cat + fi +} + validate_backend() { case "$1" in claude|codex|grok) ;; @@ -75,7 +86,12 @@ available_version() { # claude reviews run in-session via the Agent tool, so inside a Claude # Code session the backend exists by definition — the PATH lookup only # provides a nicer version string, never gates availability. - claude --version 2>/dev/null | head -1 || echo "in-session" + # Capture separately (not `… || echo in-session`): a SIGPIPE from head() + # under pipefail would otherwise run BOTH the real version and the + # fallback, printing two lines. + local cver + cver="$(claude --version 2>/dev/null | head -1 || true)" + echo "${cver:-in-session}" return 0 fi command -v "$backend" >/dev/null || return 1 @@ -170,7 +186,7 @@ print() ;; "") { printf 'BACKEND\tAVAILABLE\tVERSION\tREADY\tHINT\n'; print_rows "-"; } \ - | column -t -s $'\t' + | column_or_cat ;; *) echo "Unknown flag: $1" >&2 From 0b1dd1888c36186540d97ed4f7741d4ce13f7ac5 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 11:23:28 +0200 Subject: [PATCH 06/14] Add P2 pipeline blueprint from the ensemble dry-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the validated review-pipeline shape (scope+gate → fan-out → (file,mechanism) merge → verify solos → synthesis) as a reference for P2, with the erprobte workflow script and the dry-run learnings so the build doesn't start from scratch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/docs/pipeline-blueprint.md | 211 +++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 plugins/swarm/docs/pipeline-blueprint.md diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md new file mode 100644 index 0000000..4529f26 --- /dev/null +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -0,0 +1,211 @@ +# `/swarm:review` Pipeline Blueprint (P2–P5) + +> Working proof-of-concept for the review pipeline, validated by two live +> ensemble dry-runs on this very branch (2026-07-02/03, runs `wf_b692c02d-990` +> and `wf_354898e8-770`). This is the **starting point for P2**, not shipped +> code — P2 turns it into a registered workflow script under `plugins/swarm/`. +> Design rationale lives in the task file (`tasks/add-swarm-plugin.md`); this +> file is the concrete shape. + +## Pipeline shape (4 phases) + +``` +Scope+gate → Fan-out (Claude lenses ∥ codex ∥ grok) → Merge (file,mechanism) → Verify solos → Synthesis +``` + +1. **Scope + lens gating** — one cheap agent reads the diff, classifies the + change kind, and decides which lenses are worth running (a lens that can't + pay off is skipped → whole finder agents saved). Gated-out lenses are + reported, never silently dropped. +2. **Fan-out** — in parallel: one Claude finder per gated lens (reads the file + itself → real line numbers) **plus** codex and grok as full multi-lens + reviews through the adapter (thin wrapper agents; workflow scripts have no + Bash). All emit the shared `finding.schema.json`. +3. **Merge** — an LLM merge step clusters the pooled findings by + `(file, mechanism)` — **not** `(file, line)`: external CLIs number against + the inlined diff, so line equality never matches (dry-run learning L1). + Consensus = ≥2 distinct backends in one cluster ⇒ CONFIRMED. +4. **Verify** — every solo cluster (one backend only) goes through an + adversarial 3-state verifier (CONFIRMED/PLAUSIBLE/REFUTED; PLAUSIBLE is the + default, only REFUTED is dropped). Consensus clusters skip verify. +5. **Synthesis** — rank (severity, then consensus>solo) and emit the balance + data (severity split, consensus/solo, per-lens raw vs. surviving). + +## Reference script (erprobt — `wf_354898e8-770`) + +Path constants are the only thing P2 must rewrite: `ADAPTER` → +`${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh`; the diff/prompt files come from the +scope step (write to a temp path, or have finders read the diff directly). + +```js +export const meta = { + name: 'swarm-review', + description: 'Local mixture-of-agents review: scope+gate → fan-out → (file,mechanism) merge → verify solos → ranked synthesis.', + phases: [ + { title: 'Scope', detail: 'classify diff + gate lenses' }, + { title: 'Fan-out', detail: 'Claude lenses + codex + grok in parallel' }, + { title: 'Merge', detail: 'cluster by (file, mechanism)' }, + { title: 'Verify', detail: '3-state verify of solos' }, + ], +} + +const ADAPTER = '${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh' // P2: resolve plugin root +const DIFF_FILE = '' // diff text for finders to read +const EXTERNAL_PROMPT = '' // full-review prompt for codex/grok (diff inlined) + +const CANDIDATE_LENSES = ['correctness', 'security', 'style', 'adversarial', 'conventions'] +const LENS_BRIEF = { + correctness: 'shell quoting/word-splitting, exit codes, set -euo pipefail, JSON handling, argv/ARG_MAX, edge cases', + security: 'command/argument injection via prompt or filename, unsafe temp files, data leakage', + style: 'duplication, dead code, unclear constructs, inconsistent idioms', + adversarial: 'challenge the design/assumptions: what did the author assume that the diff does not guarantee?', + conventions: 'repo conventions: naming, doc/README sync, version-sync, sibling-script idioms', +} +const FINDINGS_SCHEMA = { /* shared shape — mirrors scripts/schema/finding.schema.json */ + type: 'object', additionalProperties: false, required: ['findings'], + properties: { findings: { type: 'array', items: { + type: 'object', additionalProperties: false, + required: ['file', 'line', 'severity', 'summary', 'failure_scenario', 'confidence', 'recommendation'], + properties: { + file: { type: 'string' }, line: { type: 'integer' }, + severity: { enum: ['critical', 'warning', 'minor'] }, + summary: { type: 'string' }, failure_scenario: { type: 'string' }, + confidence: { enum: ['high', 'medium', 'low'] }, recommendation: { type: 'string' }, + }, + } } }, +} + +// Phase 1: Scope + lens gating +phase('Scope') +const GATE_SCHEMA = { + type: 'object', additionalProperties: false, required: ['change_kind', 'run', 'skip'], + properties: { + change_kind: { type: 'string' }, + run: { type: 'array', items: { type: 'string' } }, + skip: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['lens', 'why'], properties: { lens: { type: 'string' }, why: { type: 'string' } } } }, + }, +} +const gate = await agent( + `You are the scope/lens-gating step of a code review. Read the unified diff at ${DIFF_FILE}.\n` + + `Candidate lenses: ${CANDIDATE_LENSES.join(', ')}.\n` + + `Decide which lenses are worth running and which to skip because they cannot pay off. Be decisive but do NOT skip security when any code/argument/filename flows to an external process.\n` + + `Return change_kind, run (lens names), skip (lens + one-clause why).`, + { label: 'scope+gate', phase: 'Scope', schema: GATE_SCHEMA, model: 'haiku', effort: 'low' } +) +const runLenses = (gate?.run || CANDIDATE_LENSES).filter((l) => CANDIDATE_LENSES.includes(l)) + +// Phase 2: Ensemble fan-out +phase('Fan-out') +const claudeThunks = runLenses.map((lens) => () => + agent( + `You are the "${lens}" lens finder. Read the diff at ${DIFF_FILE} and review ONLY through the ${lens} lens: ${LENS_BRIEF[lens]}.\n` + + `One finding per defect, concrete falsifiable failure_scenario. Prefix each summary with "[${lens}] ". Empty is valid. Cite real file lines.`, + { label: `claude:${lens}`, phase: 'Fan-out', schema: FINDINGS_SCHEMA, effort: 'medium' } + ).then((r) => ({ backend: 'claude', lens, findings: r?.findings || [] })).catch(() => ({ backend: 'claude', lens, findings: [] })) +) +const externalThunks = ['codex', 'grok'].map((b) => () => + agent( + `You are a thin transport wrapper — do NOT review yourself. Run this with the Bash tool (timeout 600000), wait for it:\n\n` + + `bash "${ADAPTER}" run ${b} --effort high --prompt-file "${EXTERNAL_PROMPT}"\n\n` + + `It prints one JSON object {"findings":[...]} on stdout. Return it VERBATIM. On non-zero exit / no JSON, return {"findings":[]}.`, + { label: `${b}:full`, phase: 'Fan-out', schema: FINDINGS_SCHEMA, agentType: 'general-purpose', model: 'haiku', effort: 'low' } + ).then((r) => ({ backend: b, lens: null, findings: (r && Array.isArray(r.findings)) ? r.findings : [] })).catch(() => ({ backend: b, lens: null, findings: [] })) +) +const voices = await parallel([...claudeThunks, ...externalThunks]) + +const pool = [] +for (const v of voices.filter(Boolean)) for (const f of v.findings) { + let lens = v.lens + if (!lens) { const m = /^\s*\[(\w+)\]/.exec(f.summary || ''); lens = m ? m[1].toLowerCase() : 'unspecified' } + pool.push({ ...f, backend: v.backend, lens }) +} + +// Phase 3: Merge / cluster by (file, mechanism) — LLM step, NOT (file,line) JS +phase('Merge') +let clusters = [] +if (pool.length > 0) { + const CLUSTER_SCHEMA = { + type: 'object', additionalProperties: false, required: ['clusters'], + properties: { clusters: { type: 'array', items: { + type: 'object', additionalProperties: false, + required: ['file', 'line', 'mechanism', 'severity', 'summary', 'failure_scenario', 'recommendation', 'lens', 'member_indices'], + properties: { + file: { type: 'string' }, line: { type: 'integer' }, mechanism: { type: 'string' }, + severity: { enum: ['critical', 'warning', 'minor'] }, + summary: { type: 'string' }, failure_scenario: { type: 'string' }, recommendation: { type: 'string' }, + lens: { type: 'string' }, member_indices: { type: 'array', items: { type: 'integer' } }, + }, + } } }, + } + const numbered = pool.map((f, i) => `#${i} [${f.backend}/${f.lens}] ${f.file}:${f.line} — ${f.summary} :: ${f.failure_scenario}`).join('\n') + const res = await agent( + `Merge/dedup step. ${pool.length} raw findings from claude/codex/grok below. Cluster by UNDERLYING DEFECT — same file + same mechanism = one cluster — EVEN IF line numbers differ (external tools number against the diff; match on meaning, not line). ` + + `Return per cluster: file, representative line, short mechanism key, severity (max of members), summary, strongest failure_scenario, recommendation, dominant lens, member_indices. Every index in exactly one cluster.\n\n` + numbered, + { label: 'merge:cluster', phase: 'Merge', schema: CLUSTER_SCHEMA, effort: 'medium' } + ) + clusters = (res?.clusters || []).map((c) => { + const members = (c.member_indices || []).filter((i) => i >= 0 && i < pool.length) + const backends = Array.from(new Set(members.map((i) => pool[i].backend))).sort() + return { ...c, backends, consensus: backends.length >= 2 ? 'CONFIRMED' : 'solo' } + }) +} +const consensusClusters = clusters.filter((c) => c.consensus === 'CONFIRMED') +const soloClusters = clusters.filter((c) => c.consensus === 'solo') + +// Phase 4: 3-state verify of solos +phase('Verify') +const VERDICT_SCHEMA = { + type: 'object', additionalProperties: false, required: ['verdict', 'evidence'], + properties: { verdict: { enum: ['CONFIRMED', 'PLAUSIBLE', 'REFUTED'] }, evidence: { type: 'string' } }, +} +const verifiedSolos = await parallel(soloClusters.map((c) => () => + agent( + `Adversarial verifier for one solo finding — try to REFUTE it against the real repo.\n` + + `File: ${c.file} (line ${c.line})\nMechanism: ${c.mechanism}\nClaim: ${c.summary}\nFailure: ${c.failure_scenario}\n\n` + + `Read the file / run read-only checks. Verdict: CONFIRMED / REFUTED / PLAUSIBLE (default when unsure) + one-sentence evidence.`, + { label: `verify:${c.file.split('/').pop()}`, phase: 'Verify', schema: VERDICT_SCHEMA, effort: 'medium' } + ).then((v) => ({ ...c, verifier: v?.verdict || 'PLAUSIBLE', evidence: v?.evidence || '' })).catch(() => ({ ...c, verifier: 'PLAUSIBLE', evidence: 'verifier error → PLAUSIBLE' })) +)) + +// Synthesis + balance data +const finalConsensus = consensusClusters.map((c) => ({ ...c, verifier: 'CONFIRMED', evidence: `agreed by ${c.backends.join('+')}` })) +const finalSolos = verifiedSolos.filter(Boolean).filter((c) => c.verifier !== 'REFUTED') +const refuted = verifiedSolos.filter(Boolean).filter((c) => c.verifier === 'REFUTED') +const sevRank = { critical: 0, warning: 1, minor: 2 } +const all = [...finalConsensus, ...finalSolos].sort((a, b) => (sevRank[a.severity] - sevRank[b.severity]) || (a.consensus === 'CONFIRMED' ? -1 : 1)) +const perLens = {}, survivingPerLens = {} +for (const f of pool) perLens[f.lens] = (perLens[f.lens] || 0) + 1 +for (const c of all) survivingPerLens[c.lens] = (survivingPerLens[c.lens] || 0) + 1 +return { gate, voices, findings: all, refuted, balance: { total: all.length, consensus: finalConsensus.length, solo: finalSolos.length, refuted: refuted.length, rawPerLens: perLens, survivingPerLens } } +``` + +## What the dry-runs proved (and the P2 calibration points) + +- **Structure holds:** 12 agents, 4 phases, 0 errors, ~6 min. The adapter + carries the external voices cleanly. +- **Lens gating works but was too aggressive** — it skipped `security` on the + adapter diff. P2 gate prompt already hardened here: *never skip security + when code/arguments/filenames flow to an external process.* +- **Merge must be the LLM cluster step, not `(file,line)` JS** (L1). With the + cluster step, consensus=0 was *honest* (the backends genuinely found + different defects) — solo+verify is the normal path, consensus the exception. +- **3-state verify earns its place:** it REFUTED a wrong codex claim + (`--skip-git-repo-check` needing a bypass flag) that a naive pipeline would + have shipped. +- **MoA is complementary:** grok found defects the Claude lenses didn't and + vice-versa; codex's one finding was the false one. Different models, different + catches. + +## Open P2 wiring (not yet in the blueprint) + +- **Registered workflow** (not inline) so per-agent `durationMs` is available + for the timing balance line. +- **Model labels** in the balance line (`Opus-4.8`/`GPT-5.5`/`grok-build`), + read from each backend's review output. +- **Optional composer lens-gate**: `grok-composer-2.5-fast` can't enforce + `--json-schema`/`--effort`, but a strict-JSON prompt makes it emit valid JSON + and reason on demand (tested 2/2). It's ~2× faster than grok-build but its + ~20s CLI cost undercuts a Haiku gate; keep it optional, with a defensive + parser + fallback-to-all-lenses. +- **Balance / footer / loop-round box:** render deterministically from the + synthesis data (see task file P4/P5). From 686ae8862a04af6892738a6a8eac7ca82a1da109 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 11:58:42 +0200 Subject: [PATCH 07/14] Harden adapter + docs from the composer ensemble dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter (agents.sh): - ARG_MAX guard now counts BYTES not chars (a multibyte prompt slipped the ${#prompt} char check yet overflowed exec), and checks file size BEFORE slurping it into a variable - external codex/grok calls wrapped in a wall-clock timeout (coreutils timeout/gtimeout when present, passthrough otherwise; SWARM_TIMEOUT) - run_grok preflight-rejects non-default models (only grok-build enforces --json-schema; others would fail late with structuredOutput:null) Docs: - README: cross-agent agreement is a strong signal WHEN it occurs, not "primary" — solo + 3-state verify is the common path (dry-run showed consensus is the exception) - blueprint: drop the dead link to the uncommitted task file Found by the ensemble reviewing its own PR; 5 REFUTED findings dropped by the verify pass, the CRITICAL prompt-injection tracked as P2 design. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/README.md | 7 +-- plugins/swarm/docs/pipeline-blueprint.md | 4 +- plugins/swarm/scripts/agents.sh | 63 +++++++++++++++--------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/plugins/swarm/README.md b/plugins/swarm/README.md index 672d46c..68cc76d 100644 --- a/plugins/swarm/README.md +++ b/plugins/swarm/README.md @@ -2,9 +2,10 @@ Local mixture-of-agents code review for Claude Code. Fans out one review across multiple independent agents — Claude subagents plus the `codex` and -`grok` CLIs — merges and deduplicates their findings, uses cross-agent -agreement as the primary confidence signal, and presents a single ranked -report. +`grok` CLIs — merges and deduplicates their findings, and presents a single +ranked report. Cross-agent agreement is a strong confidence signal when it +occurs; single-agent findings (the common case) pass an adversarial 3-state +verifier so real catches survive and noise is dropped. Complementary to [pr-flow](../pr-flow/): pr-flow drives the GitHub-PR `@claude`-bot loop; swarm reviews **locally**, before anything is pushed. diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md index 4529f26..1ad46a8 100644 --- a/plugins/swarm/docs/pipeline-blueprint.md +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -4,8 +4,8 @@ > ensemble dry-runs on this very branch (2026-07-02/03, runs `wf_b692c02d-990` > and `wf_354898e8-770`). This is the **starting point for P2**, not shipped > code — P2 turns it into a registered workflow script under `plugins/swarm/`. -> Design rationale lives in the task file (`tasks/add-swarm-plugin.md`); this -> file is the concrete shape. +> Design rationale lives in the working task file (kept locally, not committed +> to the repo); this file is the concrete shape. ## Pipeline shape (4 phases) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 1c53f88..0a1dac2 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -70,6 +70,18 @@ column_or_cat() { fi } +# Wall-clock cap for external CLI calls so a hung backend fails fast instead of +# blocking a fan-out forever. Uses coreutils timeout/gtimeout when available; +# passes through unchanged if neither exists (best-effort, never a hard dep). +# Override seconds via SWARM_TIMEOUT; 0 disables. +ADAPTER_TIMEOUT="${SWARM_TIMEOUT:-600}" +with_timeout() { + if [[ "$ADAPTER_TIMEOUT" == "0" ]]; then "$@"; return; fi + if command -v timeout >/dev/null; then timeout "$ADAPTER_TIMEOUT" "$@" + elif command -v gtimeout >/dev/null; then gtimeout "$ADAPTER_TIMEOUT" "$@" + else "$@"; fi +} + validate_backend() { case "$1" in claude|codex|grok) ;; @@ -222,21 +234,24 @@ subcmd_run() { esac [[ -f "$schema" ]] || { echo "Schema not found: $schema" >&2; exit 2; } + # The prompt travels as one argv word; stay well below ARG_MAX (~1 MiB incl. + # environment on macOS). Measure BYTES (ARG_MAX is a byte limit — a multibyte + # prompt would slip a `${#prompt}` char-count yet still overflow exec), and + # for a file check its size BEFORE reading it (a 500 MiB file would otherwise + # be slurped into a shell variable first). + local max_bytes=262144 nbytes local prompt if [[ -n "$prompt_file" ]]; then [[ -f "$prompt_file" ]] || { echo "Prompt file not found: $prompt_file" >&2; exit 2; } + nbytes=$(wc -c < "$prompt_file") + (( nbytes > max_bytes )) && { echo "Prompt file too large ($(( nbytes / 1024 )) KiB > 256 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } prompt="$(cat "$prompt_file")" else prompt="$(cat)" + nbytes=$(printf '%s' "$prompt" | wc -c) + (( nbytes > max_bytes )) && { echo "Prompt too large ($(( nbytes / 1024 )) KiB > 256 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } fi [[ -z "$prompt" ]] && { echo "Empty prompt (use --prompt-file or stdin)" >&2; exit 2; } - # The prompt travels as one argv word; stay well below ARG_MAX (~1 MiB - # including the environment on macOS) instead of failing as a generic - # backend error at exec time. - if (( ${#prompt} > 262144 )); then - echo "Prompt too large ($(( ${#prompt} / 1024 )) KiB > 256 KiB) — inline less of the diff, or instruct the agent to read it itself" >&2 - exit 2 - fi require_usable "$backend" require_python3 @@ -264,13 +279,15 @@ run_codex() { # for "additional input from stdin" and hangs. # `--` ends flag parsing: a prompt starting with "-" (e.g. a markdown # bullet) would otherwise be rejected as an unknown flag. - if ! codex exec -s read-only --skip-git-repo-check \ + local rc=0 + with_timeout codex exec -s read-only --skip-git-repo-check \ -c model_reasoning_effort="$effort" \ ${model_args[@]+"${model_args[@]}"} \ --output-schema "$schema" \ --output-last-message "$TMP_OUT" \ - -- "$prompt" /dev/null; then - echo "codex exec failed" >&2 + -- "$prompt" /dev/null || rc=$? + if (( rc != 0 )); then + (( rc == 124 )) && echo "codex exec timed out after ${ADAPTER_TIMEOUT}s" >&2 || echo "codex exec failed" >&2 exit 1 fi [[ -s "$TMP_OUT" ]] || { echo "codex produced no output" >&2; exit 1; } @@ -289,23 +306,25 @@ run_grok() { local prompt="$1" effort="$2" model="$3" schema="$4" local grok_model="${model:-$GROK_DEFAULT_MODEL}" - # Only grok-build understands --effort; other models reject the parameter - # outright (and don't enforce --json-schema either — see the knowledge - # entry), so omit the flag instead of failing the run. - local effort_args=() - if [[ "$grok_model" == "$GROK_DEFAULT_MODEL" ]]; then - effort_args=(--effort "$effort") - else - echo "note: --effort omitted — only $GROK_DEFAULT_MODEL supports it" >&2 + # Preflight-reject non-default models: only grok-build enforces --json-schema + # (and accepts --effort). Any other model would silently return + # structuredOutput:null and the run would fail late with no schema output — + # so reject up front with a usage error rather than burn a review on it. + # (Schema-less models like grok-composer-2.5-fast belong on the caller's own + # defensive-parse path, not this schema-enforcing adapter.) + if [[ "$grok_model" != "$GROK_DEFAULT_MODEL" ]]; then + echo "grok model '$grok_model' does not enforce --json-schema — the adapter requires schema output; use $GROK_DEFAULT_MODEL (default)" >&2 + exit 2 fi # --single= (not "-p "): as a separate argv word a prompt # starting with "-" would be parsed as a flag. - local raw - if ! raw="$(grok -m "$grok_model" ${effort_args[@]+"${effort_args[@]}"} \ + local raw rc=0 + raw="$(with_timeout grok -m "$grok_model" --effort "$effort" \ --json-schema "$(cat "$schema")" \ - --single="$prompt" &2 + --single="$prompt" &2 || echo "grok failed" >&2 exit 1 fi printf '%s' "$raw" | python3 -c ' From 4b8a1188e2663a1b9860aed13d9a97b722c4bb4f Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 12:58:08 +0200 Subject: [PATCH 08/14] Fix loop-round-1 findings (incl. bugs in the prior timeout fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensemble re-reviewed the fixed diff at xhigh and found regressions in the timeout hardening plus deeper issues: - validate SWARM_TIMEOUT up front (a malformed value made `timeout` exit 125, which the rc==124 checks missed → every external run misreported as a backend failure) - warn once when no timeout/gtimeout is on PATH (the cap silently never applied on stock macOS) - run_codex/run_grok now validate the findings SHAPE, not just JSON syntax, before emitting (a valid-but-wrong object would crash the merge) - run_grok: --disable-web-search as a defensive partial sandbox (the diff is untrusted; close the obvious network exfil channel). Full read-deny sandbox parity with codex is P2. - SKILL.md: grok Ready is a heuristic (auth file present ≠ valid token) — say so instead of overclaiming "authenticated" - finding.schema.json: line has minimum 0 (no negative lines) Security note: during this round an agentic backend actually read a real credentials file via the injection path — tracked as the P2 critical; round-2 prompts use fictional secret paths + an explicit no-exploit rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/scripts/agents.sh | 38 +++++++++++++++++-- .../swarm/scripts/schema/finding.schema.json | 1 + plugins/swarm/skills/agents/SKILL.md | 5 +++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 0a1dac2..1446c57 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -75,11 +75,28 @@ column_or_cat() { # passes through unchanged if neither exists (best-effort, never a hard dep). # Override seconds via SWARM_TIMEOUT; 0 disables. ADAPTER_TIMEOUT="${SWARM_TIMEOUT:-600}" +_timeout_warned="" with_timeout() { if [[ "$ADAPTER_TIMEOUT" == "0" ]]; then "$@"; return; fi if command -v timeout >/dev/null; then timeout "$ADAPTER_TIMEOUT" "$@" elif command -v gtimeout >/dev/null; then gtimeout "$ADAPTER_TIMEOUT" "$@" - else "$@"; fi + else + # No coreutils timeout: run bare, but say so once — otherwise the documented + # cap silently never applies (e.g. stock macOS) and a hung backend blocks. + if [[ -z "$_timeout_warned" ]]; then + echo "warning: no timeout/gtimeout on PATH — external calls run WITHOUT the ${ADAPTER_TIMEOUT}s cap (install coreutils, or set SWARM_TIMEOUT=0 to silence)" >&2 + _timeout_warned=1 + fi + "$@" + fi +} + +require_valid_timeout() { + # A malformed SWARM_TIMEOUT would reach `timeout` and exit 125 — which the + # rc==124 checks don't recognize, so every external run would misreport as a + # backend failure. Reject up front. Only the literal integer disables (0). + [[ "$ADAPTER_TIMEOUT" =~ ^[0-9]+$ ]] \ + || { echo "Invalid SWARM_TIMEOUT='$ADAPTER_TIMEOUT' — must be a non-negative integer (seconds; 0 disables)" >&2; exit 2; } } validate_backend() { @@ -255,6 +272,7 @@ subcmd_run() { require_usable "$backend" require_python3 + require_valid_timeout case "$backend" in codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; @@ -291,13 +309,17 @@ run_codex() { exit 1 fi [[ -s "$TMP_OUT" ]] || { echo "codex produced no output" >&2; exit 1; } + # Validate SHAPE, not just JSON syntax: a valid-but-wrong object (no findings + # array) would otherwise pass through and crash the merge step downstream. python3 -c ' import json, sys try: - json.load(sys.stdin) + d = json.load(sys.stdin) except Exception: - sys.exit(1) -' <"$TMP_OUT" || { echo "codex returned invalid JSON" >&2; exit 1; } + sys.stderr.write("codex returned invalid JSON\n"); sys.exit(1) +if not (isinstance(d, dict) and isinstance(d.get("findings"), list)): + sys.stderr.write("codex output is not a {findings:[...]} object\n"); sys.exit(1) +' <"$TMP_OUT" || exit 1 cat "$TMP_OUT" echo } @@ -319,8 +341,13 @@ run_grok() { # --single= (not "-p "): as a separate argv word a prompt # starting with "-" would be parsed as a flag. + # --disable-web-search: a defensive partial sandbox — the diff is untrusted + # and could try to steer grok into fetching a URL to exfiltrate data. This + # closes the web channel; full read-deny sandboxing (parity with codex + # `-s read-only`, plus denying shell/write) is P2 work. local raw rc=0 raw="$(with_timeout grok -m "$grok_model" --effort "$effort" \ + --disable-web-search \ --json-schema "$(cat "$schema")" \ --single="$prompt" Date: Fri, 3 Jul 2026 13:09:08 +0200 Subject: [PATCH 09/14] Lower prompt-size guard below Linux per-arg limit (R2 finding) The prompt is passed as ONE argv word, so the binding cap is the per-argument limit (Linux MAX_ARG_STRLEN = 128 KiB), not total ARG_MAX. The 256 KiB guard let a ~200 KiB prompt pass yet fail to exec on Linux. Cap at 120 KiB with headroom for the schema arg + environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/scripts/agents.sh | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 1446c57..6be1a18 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -251,22 +251,24 @@ subcmd_run() { esac [[ -f "$schema" ]] || { echo "Schema not found: $schema" >&2; exit 2; } - # The prompt travels as one argv word; stay well below ARG_MAX (~1 MiB incl. - # environment on macOS). Measure BYTES (ARG_MAX is a byte limit — a multibyte - # prompt would slip a `${#prompt}` char-count yet still overflow exec), and - # for a file check its size BEFORE reading it (a 500 MiB file would otherwise - # be slurped into a shell variable first). - local max_bytes=262144 nbytes + # The prompt travels as ONE argv word, so the binding limit is the per-argument + # cap, not total ARG_MAX: Linux MAX_ARG_STRLEN is 128 KiB (macOS has no + # per-arg cap but a ~1 MiB total). Cap at 120 KiB to stay under the Linux + # per-arg limit with headroom for the schema arg + environment. Measure BYTES + # (a multibyte prompt would slip a `${#prompt}` char-count yet overflow exec), + # and for a file check its size BEFORE reading it (a 500 MiB file would + # otherwise be slurped into a shell variable first). + local max_bytes=122880 nbytes local prompt if [[ -n "$prompt_file" ]]; then [[ -f "$prompt_file" ]] || { echo "Prompt file not found: $prompt_file" >&2; exit 2; } nbytes=$(wc -c < "$prompt_file") - (( nbytes > max_bytes )) && { echo "Prompt file too large ($(( nbytes / 1024 )) KiB > 256 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } + (( nbytes > max_bytes )) && { echo "Prompt file too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } prompt="$(cat "$prompt_file")" else prompt="$(cat)" nbytes=$(printf '%s' "$prompt" | wc -c) - (( nbytes > max_bytes )) && { echo "Prompt too large ($(( nbytes / 1024 )) KiB > 256 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } + (( nbytes > max_bytes )) && { echo "Prompt too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } fi [[ -z "$prompt" ]] && { echo "Empty prompt (use --prompt-file or stdin)" >&2; exit 2; } From a1327a5b526ac996fb82f0a59171a8eeb6f4fca3 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 17:54:25 +0200 Subject: [PATCH 10/14] Harden the trust boundary: sandbox grok, scrub secrets, cap fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop's real credential exfiltration + converged security findings drove adapter-level defenses (the parts fixable in P1): - grok now runs --tools "" (verified to block file reads; the diff is inlined so grok needs no tools) on top of --disable-web-search - scrub_secrets redacts secret-shaped content (AWS/GH/sk tokens, private keys, secret=… ) from findings JSON before it leaves run_codex/run_grok — a backstop even if a backend sandbox is bypassed - finding.schema.json caps summary/failure_scenario/recommendation length so a payload can't route a large blob through a field - blueprint gains a mandatory Security section (fencing, merge/verify scrub, consensus-verify, deterministic transport, OS jail = P2) Remaining injection hardening is pipeline-level (merge/verify stages, prompt fencing) and lands in P2; tracked in the task file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/docs/pipeline-blueprint.md | 29 +++++++++++++ plugins/swarm/scripts/agents.sh | 43 ++++++++++++++++--- .../swarm/scripts/schema/finding.schema.json | 6 ++- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md index 1ad46a8..fe87489 100644 --- a/plugins/swarm/docs/pipeline-blueprint.md +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -196,6 +196,35 @@ return { gate, voices, findings: all, refuted, balance: { total: all.length, con vice-versa; codex's one finding was the false one. Different models, different catches. +## Security — the trust boundary (MANDATORY for P2, hardened by the loop) + +The diff under review is **untrusted input** that flows into agentic backends and +back into the report. Three loop rounds (one of which exfiltrated a real +credential mid-review) converged on these non-negotiable mitigations: + +1. **Sandbox every backend to no-tool / read-deny.** The diff is inlined, so + backends need NO filesystem or shell tools. Adapter now enforces: grok + `--tools ""` (verified to block file reads) + `--disable-web-search`; codex + `-s read-only`. Full OS-level jailing (sandbox-exec / bwrap) is still open. +2. **Scrub secrets at the adapter boundary.** `scrub_secrets` redacts + secret-shaped content (AWS keys, private keys, gh/sk tokens, `secret=…`) from + findings JSON before it leaves `run_codex`/`run_grok` — a backstop even if a + sandbox is bypassed. **The merge and verify stages must scrub too** (they + re-interpolate findings text into new prompts → second-order injection). +3. **Fence the diff as data.** Wrap the inlined diff in explicit + untrusted-data delimiters with a system instruction that its content is data, + never instructions. (codex/grok get the diff as a positional prompt today + with no fencing — the injection vector.) +4. **Bound findings size.** `finding.schema.json` caps summary/failure_scenario/ + recommendation length, so a payload can't route a large blob through a field. +5. **Don't fully trust consensus.** Consensus (≥2 backends) currently skips the + verifier, but agreement comes from LLM merge-clustering + correlated model + bias, not independent proof. P2: still run a light verify on consensus + findings, or require cross-family agreement (not composer+grok). +6. **Prefer deterministic transport.** The Haiku "thin transport" wrapper can + silently drop/reshape findings while staying schema-valid; where a registered + workflow can shell out, pass adapter JSON through deterministically instead. + ## Open P2 wiring (not yet in the blueprint) - **Registered workflow** (not inline) so per-agent `durationMs` is available diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 6be1a18..5b269bf 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -99,6 +99,33 @@ require_valid_timeout() { || { echo "Invalid SWARM_TIMEOUT='$ADAPTER_TIMEOUT' — must be a non-negative integer (seconds; 0 disables)" >&2; exit 2; } } +scrub_secrets() { + # Last-line-of-defense secret filter on the findings JSON before it leaves the + # adapter. The diff under review is untrusted and a prompt-injected backend + # could try to route a credential into a findings string field; redact + # secret-shaped content here so it can never reach the merged report, even if + # a backend sandbox is bypassed. Redacts (not blocks) so real findings survive. + python3 -c ' +import re, sys +PATTERNS = [ + (re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED-AWS-KEY]"), + (re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"), "[REDACTED-PRIVATE-KEY]"), + (re.compile(r"(?i)aws_secret_access_key\s*[=:]\s*[A-Za-z0-9/+]{20,}"), "aws_secret_access_key=[REDACTED]"), + (re.compile(r"(?i)\b(secret|token|password|passwd|api[_-]?key)\b\s*[=:]\s*[A-Za-z0-9/+._-]{16,}"), r"\1=[REDACTED]"), + (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), "[REDACTED-GH-TOKEN]"), + (re.compile(r"\bsk-[A-Za-z0-9]{20,}"), "[REDACTED-API-KEY]"), +] +data = sys.stdin.read() +hit = False +for pat, repl in PATTERNS: + data, n = pat.subn(repl, data) + if n: hit = True +if hit: + sys.stderr.write("swarm: redacted secret-shaped content from findings before output\n") +sys.stdout.write(data) +' +} + validate_backend() { case "$1" in claude|codex|grok) ;; @@ -322,7 +349,7 @@ except Exception: if not (isinstance(d, dict) and isinstance(d.get("findings"), list)): sys.stderr.write("codex output is not a {findings:[...]} object\n"); sys.exit(1) ' <"$TMP_OUT" || exit 1 - cat "$TMP_OUT" + scrub_secrets <"$TMP_OUT" echo } @@ -343,13 +370,15 @@ run_grok() { # --single= (not "-p "): as a separate argv word a prompt # starting with "-" would be parsed as a flag. - # --disable-web-search: a defensive partial sandbox — the diff is untrusted - # and could try to steer grok into fetching a URL to exfiltrate data. This - # closes the web channel; full read-deny sandboxing (parity with codex - # `-s read-only`, plus denying shell/write) is P2 work. + # Sandbox: the diff is untrusted and could try to steer grok into reading + # local secrets or fetching a URL to exfiltrate. grok reviews the diff INLINE + # in the prompt, so it needs NO tools — `--tools ""` (empty allowlist) + # removes file/shell access (verified: grok then can't read files), and + # `--disable-web-search` closes the network channel. scrub_secrets on the + # output is the belt-and-braces backstop. local raw rc=0 raw="$(with_timeout grok -m "$grok_model" --effort "$effort" \ - --disable-web-search \ + --tools "" --disable-web-search \ --json-schema "$(cat "$schema")" \ --single="$prompt" Date: Fri, 3 Jul 2026 20:17:46 +0200 Subject: [PATCH 11/14] OS-level read-deny jail: the root-cause fix for backend file reads (R3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 showed the whack-a-mole: closing grok's file access moved the critical to codex (`-s read-only` still permits reads). The fix is one OS jail around EVERY external call, not per-backend flags: - sandboxed(): sandbox-exec (macOS) / bwrap (Linux) deny-read of common secret stores, realpath-resolved (defeats ~/.ssh→dotfiles, /tmp, /etc symlinks) as both subpath (dirs) and literal (files) - VERIFIED: codex explicitly told to read a jailed file returns BLOCKED; normal reviews still work; secret never reaches the output - codex reasoning transcript (stderr) now discarded (could echo a read secret, never scrubbed) - schema gains maxItems:100 (per-field maxLength alone allowed volume exfil across many entries) - stdin guard (TTY → clear error, not a hang); knowledge frontmatter field order; CLAUDE.md /swarm:agents notation - blueprint: consensus gets a light verify not a blind skip; wrapper must distinguish error from empty Fixed a set -e footgun found while wiring this up: _sandbox_deny_paths ended in `[[ … ]] && …` returning 1 when SWARM_DENY_PATHS was unset, aborting the profile build (every default run silently produced nothing). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- .../features/swarm-backend-adapter.md | 2 +- CLAUDE.md | 2 +- plugins/swarm/docs/pipeline-blueprint.md | 17 +++- plugins/swarm/scripts/agents.sh | 78 ++++++++++++++++++- .../swarm/scripts/schema/finding.schema.json | 1 + 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md index 0f86f5b..3fad619 100644 --- a/.claude/knowledge/features/swarm-backend-adapter.md +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -1,8 +1,8 @@ --- title: "Swarm Backend Adapter Layer" createdAt: 2026-07-02 -createdFrom: "branch: task/add-swarm-plugin" updatedAt: 2026-07-02 +createdFrom: "branch: task/add-swarm-plugin" updatedFrom: "branch: task/add-swarm-plugin" pluginVersion: 1.8.2 prime: false diff --git a/CLAUDE.md b/CLAUDE.md index c1377d9..85970ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ This is a **Claude Code plugin marketplace** (monorepo) containing plugins that - **knowledge-system** (v1.5.x) — Knowledge management with three layers: Rules, Knowledge, Memory. Skills: `/init`, `/query`, `/curate`, `/reindex`, `/backfill-knowledge`, `/migrate`, `/statusline` - **work-system** (v1.2.x) — Task and worktree workflow. Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list` - **pr-flow** (v1.1.x) — PR review feedback loop. Skills: `/open`, `/cycle`, `/check`, `/fix`, `/rebase`, `/merge` -- **swarm** (v0.1.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs + Claude subagents). P1: backend adapter + status. Skills: `/agents` +- **swarm** (v0.1.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs + Claude subagents). P1: backend adapter + status. Skills: `/swarm:agents` ## Plugin Anatomy diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md index fe87489..e047182 100644 --- a/plugins/swarm/docs/pipeline-blueprint.md +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -27,7 +27,10 @@ Scope+gate → Fan-out (Claude lenses ∥ codex ∥ grok) → Merge (file,mechan Consensus = ≥2 distinct backends in one cluster ⇒ CONFIRMED. 4. **Verify** — every solo cluster (one backend only) goes through an adversarial 3-state verifier (CONFIRMED/PLAUSIBLE/REFUTED; PLAUSIBLE is the - default, only REFUTED is dropped). Consensus clusters skip verify. + default, only REFUTED is dropped). Consensus clusters get a *lighter* verify + rather than a blind skip — agreement across backends that share the same + inlined diff + schema + near-identical prompts can be correlated bias, not + independent proof (see § Security). Prefer cross-family agreement. 5. **Synthesis** — rank (severity, then consensus>solo) and emit the balance data (severity split, consensus/solo, per-lens raw vs. surviving). @@ -168,6 +171,8 @@ const verifiedSolos = await parallel(soloClusters.map((c) => () => )) // Synthesis + balance data +// P2 (see § Security): consensus is a strong prior, not proof — run a light verify +// here too (or require cross-family agreement) instead of stamping CONFIRMED blind. const finalConsensus = consensusClusters.map((c) => ({ ...c, verifier: 'CONFIRMED', evidence: `agreed by ${c.backends.join('+')}` })) const finalSolos = verifiedSolos.filter(Boolean).filter((c) => c.verifier !== 'REFUTED') const refuted = verifiedSolos.filter(Boolean).filter((c) => c.verifier === 'REFUTED') @@ -221,9 +226,13 @@ credential mid-review) converged on these non-negotiable mitigations: verifier, but agreement comes from LLM merge-clustering + correlated model bias, not independent proof. P2: still run a light verify on consensus findings, or require cross-family agreement (not composer+grok). -6. **Prefer deterministic transport.** The Haiku "thin transport" wrapper can - silently drop/reshape findings while staying schema-valid; where a registered - workflow can shell out, pass adapter JSON through deterministically instead. +6. **Prefer deterministic transport, and distinguish error from empty.** The + Haiku "thin transport" wrapper can silently drop/reshape findings while + staying schema-valid; where a registered workflow can shell out, pass adapter + JSON through deterministically instead. A backend that errored/was dropped + must NOT collapse to the same `{findings:[]}` as a clean empty review — + carry an explicit `error` marker so a silently-failing backend is visible, + not mistaken for "found nothing". ## Open P2 wiring (not yet in the blueprint) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 5b269bf..6475208 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -99,6 +99,69 @@ require_valid_timeout() { || { echo "Invalid SWARM_TIMEOUT='$ADAPTER_TIMEOUT' — must be a non-negative integer (seconds; 0 disables)" >&2; exit 2; } } +# OS-level read-deny jail for external CLI calls (the root-cause fix for +# "-s read-only still permits file reads"). The diff is untrusted, so an +# injected payload could steer a backend to read local secrets. This denies +# reads of common secret stores while leaving the CLI's own config + the repo +# readable (verified: ~/.aws blocked, ~/.codex readable). macOS: sandbox-exec; +# Linux: bwrap; else passthrough (scrub_secrets + backend flags remain). +# Extra deny paths via SWARM_DENY_PATHS (colon-separated). +_sandbox_deny_paths() { + printf '%s\n' \ + "$HOME/.aws" "$HOME/.ssh" "$HOME/.gnupg" "$HOME/.netrc" \ + "$HOME/.config/gcloud" "$HOME/.kube" "$HOME/.docker" \ + "$HOME/.git-credentials" "$HOME/.npmrc" "$HOME/.pypirc" \ + "$HOME/.config/gh" "$HOME/.cargo/credentials" "/etc/master.passwd" + local extra="${SWARM_DENY_PATHS:-}" + # if-form, not `[[ … ]] && …`: the latter returns 1 when extra is empty, and + # under set -e that aborts the `profile="$(…)"` assignment that calls this. + if [[ -n "$extra" ]]; then printf '%s\n' "${extra//:/$'\n'}"; fi + return 0 +} + +SANDBOX_CMD=() +_sandbox_warned="" +_sandbox_ready="" +_init_sandbox() { + # Lazy (called from `run`, after require_python3): needs python3 for realpath. + [[ -n "$_sandbox_ready" ]] && return + _sandbox_ready=1 + if command -v sandbox-exec >/dev/null; then + # Build the deny profile via python: realpath each path (defeats symlinks + # like /tmp→/private/tmp, /etc→/private/etc — sandbox-exec matches the + # resolved path) and deny it as BOTH a subpath (dirs + contents) and a + # literal (single files like ~/.netrc). + local profile + profile="$(_sandbox_deny_paths | python3 -c ' +import os, sys +rules = [] +for line in sys.stdin: + p = line.strip() + if not p: + continue + rp = os.path.realpath(p) + esc = rp.replace("\\", "\\\\").replace("\"", "\\\"") + rules.append("(subpath \"%s\")" % esc) + rules.append("(literal \"%s\")" % esc) +sys.stdout.write("(version 1)(allow default)(deny file-read* %s)" % " ".join(rules)) +')" + SANDBOX_CMD=(sandbox-exec -p "$profile") + elif command -v bwrap >/dev/null; then + local args=(--dev-bind / /) p + while IFS= read -r p; do [[ -e "$p" ]] && args+=(--tmpfs "$p"); done < <(_sandbox_deny_paths) + SANDBOX_CMD=(bwrap "${args[@]}") + fi +} + +sandboxed() { + # Prefix an external call with the OS jail; warn once if none is available. + if ((${#SANDBOX_CMD[@]} == 0)) && [[ -z "$_sandbox_warned" ]]; then + echo "warning: no sandbox-exec/bwrap — external calls run without an OS read-deny jail (secret scrub still applies)" >&2 + _sandbox_warned=1 + fi + with_timeout ${SANDBOX_CMD[@]+"${SANDBOX_CMD[@]}"} "$@" +} + scrub_secrets() { # Last-line-of-defense secret filter on the findings JSON before it leaves the # adapter. The diff under review is untrusted and a prompt-injected backend @@ -293,6 +356,9 @@ subcmd_run() { (( nbytes > max_bytes )) && { echo "Prompt file too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } prompt="$(cat "$prompt_file")" else + # Guard against blocking forever on an interactive/absent stdin: with no + # --prompt-file and a TTY on fd 0, `cat` would hang waiting for input. + [[ -t 0 ]] && { echo "No prompt: pass --prompt-file or pipe the prompt on stdin" >&2; exit 2; } prompt="$(cat)" nbytes=$(printf '%s' "$prompt" | wc -c) (( nbytes > max_bytes )) && { echo "Prompt too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } @@ -302,6 +368,7 @@ subcmd_run() { require_usable "$backend" require_python3 require_valid_timeout + _init_sandbox case "$backend" in codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; @@ -326,13 +393,16 @@ run_codex() { # for "additional input from stdin" and hangs. # `--` ends flag parsing: a prompt starting with "-" (e.g. a markdown # bullet) would otherwise be rejected as an unknown flag. + # 2>/dev/null discards codex's reasoning transcript (goes to stderr): under + # injection it could echo a secret it read, and it never passes scrub_secrets. + # The exit code (incl. 124 timeout) still drives error handling. local rc=0 - with_timeout codex exec -s read-only --skip-git-repo-check \ + sandboxed codex exec -s read-only --skip-git-repo-check \ -c model_reasoning_effort="$effort" \ ${model_args[@]+"${model_args[@]}"} \ --output-schema "$schema" \ --output-last-message "$TMP_OUT" \ - -- "$prompt" /dev/null || rc=$? + -- "$prompt" /dev/null 2>/dev/null || rc=$? if (( rc != 0 )); then (( rc == 124 )) && echo "codex exec timed out after ${ADAPTER_TIMEOUT}s" >&2 || echo "codex exec failed" >&2 exit 1 @@ -377,10 +447,10 @@ run_grok() { # `--disable-web-search` closes the network channel. scrub_secrets on the # output is the belt-and-braces backstop. local raw rc=0 - raw="$(with_timeout grok -m "$grok_model" --effort "$effort" \ + raw="$(sandboxed grok -m "$grok_model" --effort "$effort" \ --tools "" --disable-web-search \ --json-schema "$(cat "$schema")" \ - --single="$prompt" /dev/null)" || rc=$? if (( rc != 0 )); then (( rc == 124 )) && echo "grok timed out after ${ADAPTER_TIMEOUT}s" >&2 || echo "grok failed" >&2 exit 1 diff --git a/plugins/swarm/scripts/schema/finding.schema.json b/plugins/swarm/scripts/schema/finding.schema.json index 9d39b32..e4c1471 100644 --- a/plugins/swarm/scripts/schema/finding.schema.json +++ b/plugins/swarm/scripts/schema/finding.schema.json @@ -8,6 +8,7 @@ "properties": { "findings": { "type": "array", + "maxItems": 100, "items": { "type": "object", "additionalProperties": false, From c368f5d9dd4838e63f2bc6a44e067b79a933ce22 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 20:38:27 +0200 Subject: [PATCH 12/14] Per-backend jail + env filter + error-path scrub (R4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4 flagged the OS jail's own gaps. Fixes: - per-backend deny: a backend keeps its OWN cred dir readable but the SIBLINGS' are denied (verified: codex can't read ~/.grok/auth.json) — closes cross-backend credential theft; also deny ~/.config/anthropic, ~/.config/openai, ~/.claude.json - env filter: strip secret-shaped vars (AWS_*, *_TOKEN, *_API_KEY, …) before the call — the jail blocks file reads but backends inherit the environment, so a secret in AWS_SECRET_ACCESS_KEY bypassed it - grok invalid-JSON error path no longer echoes raw bytes to stderr (never passed scrub_secrets; could carry injected/secret content) Honest limit documented in the blueprint: a full deny-$HOME allowlist jail is impractical for the node/bun CLIs (their loaders read all over $HOME). The denylist is a backstop; the real defense is that backends need no file reads (diff is inlined) and grok is tool-less. A minimal- runtime container is the P2 upgrade. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/docs/pipeline-blueprint.md | 16 +++++-- plugins/swarm/scripts/agents.sh | 58 ++++++++++++++++++------ 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md index e047182..8be760a 100644 --- a/plugins/swarm/docs/pipeline-blueprint.md +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -207,10 +207,18 @@ The diff under review is **untrusted input** that flows into agentic backends an back into the report. Three loop rounds (one of which exfiltrated a real credential mid-review) converged on these non-negotiable mitigations: -1. **Sandbox every backend to no-tool / read-deny.** The diff is inlined, so - backends need NO filesystem or shell tools. Adapter now enforces: grok - `--tools ""` (verified to block file reads) + `--disable-web-search`; codex - `-s read-only`. Full OS-level jailing (sandbox-exec / bwrap) is still open. +1. **Sandbox every backend + filter the env.** The diff is inlined, so backends + need NO filesystem/shell tools. Adapter enforces: grok `--tools ""` (verified + to block file reads) + `--disable-web-search`; codex `-s read-only`; **an + OS-level read-deny jail** (`sandbox-exec`/`bwrap`) around every call, denying + secret stores **per-backend** (a backend keeps its own cred dir but not its + siblings' — verified: codex can't read `~/.grok`); **an env filter** stripping + secret-shaped vars (the jail blocks files, not the inherited env). + *Denylist, not allowlist, by necessity:* the node/bun-based CLIs load runtime + from all over `$HOME`, so a deny-`$HOME`-allowlist jail breaks them (tested: + codex's node loader dies). The denylist is a backstop; the primary defense is + that backends need no reads at all (diff inlined) + grok is tool-less. A full + allowlist (or a purpose-built minimal-runtime container) is the P2 upgrade. 2. **Scrub secrets at the adapter boundary.** `scrub_secrets` redacts secret-shaped content (AWS keys, private keys, gh/sk tokens, `secret=…`) from findings JSON before it leaves `run_codex`/`run_grok` — a backstop even if a diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 6475208..0ad6669 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -107,11 +107,22 @@ require_valid_timeout() { # Linux: bwrap; else passthrough (scrub_secrets + backend flags remain). # Extra deny paths via SWARM_DENY_PATHS (colon-separated). _sandbox_deny_paths() { + # $1 = the calling backend (its OWN credential dir stays readable — it needs + # it to authenticate; the OTHER backends' cred dirs are denied so an injected + # read can't steal a sibling's token). A denylist is a backstop, not the + # primary defense: grok runs tool-less, the diff is inlined so backends need + # no file reads at all, and scrub_secrets + env filtering back it up. A full + # allowlist jail is impractical here — the node/bun-based CLIs load runtime + # from all over $HOME, so deny-$HOME breaks them (documented in the blueprint). + local own="${1:-}" printf '%s\n' \ "$HOME/.aws" "$HOME/.ssh" "$HOME/.gnupg" "$HOME/.netrc" \ "$HOME/.config/gcloud" "$HOME/.kube" "$HOME/.docker" \ "$HOME/.git-credentials" "$HOME/.npmrc" "$HOME/.pypirc" \ - "$HOME/.config/gh" "$HOME/.cargo/credentials" "/etc/master.passwd" + "$HOME/.config/gh" "$HOME/.cargo/credentials" "/etc/master.passwd" \ + "$HOME/.config/anthropic" "$HOME/.config/openai" "$HOME/.claude.json" + if [[ "$own" != "codex" ]]; then printf '%s\n' "$HOME/.codex"; fi + if [[ "$own" != "grok" ]]; then printf '%s\n' "$HOME/.grok"; fi local extra="${SWARM_DENY_PATHS:-}" # if-form, not `[[ … ]] && …`: the latter returns 1 when extra is empty, and # under set -e that aborts the `profile="$(…)"` assignment that calls this. @@ -123,16 +134,18 @@ SANDBOX_CMD=() _sandbox_warned="" _sandbox_ready="" _init_sandbox() { - # Lazy (called from `run`, after require_python3): needs python3 for realpath. + # Lazy, per-backend (needs python3 for realpath). One backend per process, so + # building once is fine. [[ -n "$_sandbox_ready" ]] && return _sandbox_ready=1 + local backend="${1:-}" if command -v sandbox-exec >/dev/null; then # Build the deny profile via python: realpath each path (defeats symlinks # like /tmp→/private/tmp, /etc→/private/etc — sandbox-exec matches the # resolved path) and deny it as BOTH a subpath (dirs + contents) and a # literal (single files like ~/.netrc). local profile - profile="$(_sandbox_deny_paths | python3 -c ' + profile="$(_sandbox_deny_paths "$backend" | python3 -c ' import os, sys rules = [] for line in sys.stdin: @@ -148,18 +161,38 @@ sys.stdout.write("(version 1)(allow default)(deny file-read* %s)" % " ".join(rul SANDBOX_CMD=(sandbox-exec -p "$profile") elif command -v bwrap >/dev/null; then local args=(--dev-bind / /) p - while IFS= read -r p; do [[ -e "$p" ]] && args+=(--tmpfs "$p"); done < <(_sandbox_deny_paths) + while IFS= read -r p; do [[ -e "$p" ]] && args+=(--tmpfs "$p"); done < <(_sandbox_deny_paths "$backend") SANDBOX_CMD=(bwrap "${args[@]}") fi } +_env_filter_args() { + # Emit `-u NAME` pairs for secret-shaped env vars: the jail blocks file reads + # but backends inherit the environment, so a secret in AWS_SECRET_ACCESS_KEY / + # *_TOKEN / *_API_KEY would otherwise pass straight through. Backend auth comes + # from its config dir (not env), so stripping these is safe. + local name + while IFS='=' read -r name _; do + case "$name" in + AWS_*|*_TOKEN|*_SECRET|*_PASSWORD|*PASSWD*|*_API_KEY|*APIKEY*|*_CREDENTIALS|GH_TOKEN|GITHUB_TOKEN|NPM_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|XAI_API_KEY|GROK_API_KEY) + printf '%s\n' "-u" "$name" ;; + esac + done < <(env) +} + sandboxed() { - # Prefix an external call with the OS jail; warn once if none is available. + # OS jail + env filter around an external call. $1 = backend (its own cred dir + # stays readable; siblings' are denied). Warn once if no jail is available. + local backend="$1"; shift + _init_sandbox "$backend" if ((${#SANDBOX_CMD[@]} == 0)) && [[ -z "$_sandbox_warned" ]]; then - echo "warning: no sandbox-exec/bwrap — external calls run without an OS read-deny jail (secret scrub still applies)" >&2 + echo "warning: no sandbox-exec/bwrap — external calls run without an OS read-deny jail (secret scrub + env filter still apply)" >&2 _sandbox_warned=1 fi - with_timeout ${SANDBOX_CMD[@]+"${SANDBOX_CMD[@]}"} "$@" + local env_args=() _e + while IFS= read -r _e; do env_args+=("$_e"); done < <(_env_filter_args) + # order: timeout → env (strip secrets) → sandbox-exec (jail) → backend + with_timeout env ${env_args[@]+"${env_args[@]}"} ${SANDBOX_CMD[@]+"${SANDBOX_CMD[@]}"} "$@" } scrub_secrets() { @@ -368,7 +401,6 @@ subcmd_run() { require_usable "$backend" require_python3 require_valid_timeout - _init_sandbox case "$backend" in codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; @@ -397,7 +429,7 @@ run_codex() { # injection it could echo a secret it read, and it never passes scrub_secrets. # The exit code (incl. 124 timeout) still drives error handling. local rc=0 - sandboxed codex exec -s read-only --skip-git-repo-check \ + sandboxed codex codex exec -s read-only --skip-git-repo-check \ -c model_reasoning_effort="$effort" \ ${model_args[@]+"${model_args[@]}"} \ --output-schema "$schema" \ @@ -447,7 +479,7 @@ run_grok() { # `--disable-web-search` closes the network channel. scrub_secrets on the # output is the belt-and-braces backstop. local raw rc=0 - raw="$(sandboxed grok -m "$grok_model" --effort "$effort" \ + raw="$(sandboxed grok grok -m "$grok_model" --effort "$effort" \ --tools "" --disable-web-search \ --json-schema "$(cat "$schema")" \ --single="$prompt" /dev/null)" || rc=$? @@ -461,9 +493,9 @@ data = sys.stdin.read() try: d = json.loads(data) except Exception: - # Include a snippet so a non-JSON envelope (banner/warning on stdout) is - # triageable instead of an opaque "invalid JSON". - sys.stderr.write("grok returned invalid JSON: %s\n" % (data[:120].replace("\n", " ") or "")) + # Do NOT echo the raw bytes: on the error path they never pass scrub_secrets + # and could carry injected/secret content. Report size only. + sys.stderr.write("grok returned invalid JSON (%d bytes; content withheld)\n" % len(data)) sys.exit(1) if not isinstance(d, dict): sys.stderr.write("grok returned non-object JSON (%s)\n" % type(d).__name__) From e72a32a6014893cccc13a00480a77487f340a2f0 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 20:51:56 +0200 Subject: [PATCH 13/14] Default HOME so unset HOME can't abort the script under set -u (R5) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/scripts/agents.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 0ad6669..19536ef 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -35,6 +35,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_SCHEMA="$SCRIPT_DIR/schema/finding.schema.json" GROK_DEFAULT_MODEL="grok-build" +# Default HOME so `$HOME` expansions below (auth file, sandbox deny paths) don't +# abort the whole script under `set -u` when HOME is unset. +HOME="${HOME:-$(cd ~ 2>/dev/null && pwd || echo /nonexistent)}" GROK_AUTH_FILE="${GROK_AUTH_FILE:-$HOME/.grok/auth.json}" # Temp file for codex's --output-last-message; must be a global (not a From 923fa333761ad1b39b3ef3b66dce4e372b4acf31 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 3 Jul 2026 21:47:21 +0200 Subject: [PATCH 14/14] Fix R6 (no-security-lens) findings: README schema example + bwrap files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README output-shape snippet was invalid JSON without the top-level findings array — replaced with a valid, complete example - bwrap jail: --tmpfs dies with ENOTDIR on regular-file deny paths (~/.netrc etc.); bind /dev/null read-only over files, --tmpfs only over directories Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nWqHzv35iMe4mXrLGWKyA --- plugins/swarm/README.md | 18 +++++++++++++++--- plugins/swarm/scripts/agents.sh | 8 +++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/plugins/swarm/README.md b/plugins/swarm/README.md index 68cc76d..ab778d6 100644 --- a/plugins/swarm/README.md +++ b/plugins/swarm/README.md @@ -55,10 +55,22 @@ Both external CLIs enforce the same JSON schema on their output, so the ensemble merge receives uniform findings: ```json -{ "file", "line", "severity": "critical|warning|minor", - "summary", "failure_scenario", "confidence": "high|medium|low", - "recommendation" } +{ + "findings": [ + { + "file": "scripts/foo.sh", + "line": 42, + "severity": "warning", + "summary": "One-sentence statement of the defect", + "failure_scenario": "Concrete, falsifiable inputs → wrong behavior", + "confidence": "high", + "recommendation": "Suggested fix" + } + ] +} ``` +Severity is one of `critical | warning | minor`; confidence one of +`high | medium | low`. `failure_scenario` is required and must be falsifiable — it is what the verifier tests in the confidence phase. diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh index 19536ef..00178e1 100755 --- a/plugins/swarm/scripts/agents.sh +++ b/plugins/swarm/scripts/agents.sh @@ -163,8 +163,14 @@ sys.stdout.write("(version 1)(allow default)(deny file-read* %s)" % " ".join(rul ')" SANDBOX_CMD=(sandbox-exec -p "$profile") elif command -v bwrap >/dev/null; then + # --tmpfs masks a directory; a regular file (e.g. ~/.netrc) needs a bind of + # an empty source instead — --tmpfs over a file dies with ENOTDIR. local args=(--dev-bind / /) p - while IFS= read -r p; do [[ -e "$p" ]] && args+=(--tmpfs "$p"); done < <(_sandbox_deny_paths "$backend") + while IFS= read -r p; do + if [[ -d "$p" ]]; then args+=(--tmpfs "$p") + elif [[ -f "$p" ]]; then args+=(--ro-bind /dev/null "$p") + fi + done < <(_sandbox_deny_paths "$backend") SANDBOX_CMD=(bwrap "${args[@]}") fi }