diff --git a/VERSION b/VERSION index 236c7ad..818944f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.21 +0.0.22 diff --git a/docs/README.md b/docs/README.md index 87934b9..d0ab2e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,8 @@ with ddev — see the [README](../README.md) whether the kit fits your setup. - [CLI — scripts and flags](reference/cli.md) - [Files and paths](reference/files.md) +- [Ecosystem compatibility](reference/compatibility.md) — which tools that + spawn opencode work with the kit, and how - [Audit log](reference/audit-log.md) - [Glossary](reference/glossary.md) diff --git a/docs/concepts/security-model.md b/docs/concepts/security-model.md index 66d47f0..c02b001 100644 --- a/docs/concepts/security-model.md +++ b/docs/concepts/security-model.md @@ -18,6 +18,9 @@ Concretely: - the agent's processes run as `opencode`, never as the developer — no credentials, SSH keys, or dotfiles in `/home/` are reachable, +- the agent-resources migration (`--migrate-agents`) moves `~/.agents` + whole but only `~/.claude/skills` — credentials such as + `~/.claude/.credentials.json` never cross into the agent's home, - containers run under the `opencode` host UID via a rootless backend — the agent can never reach a root-equivalent docker socket, - no code path executes as the developer (no RunAs-developer sudoers rule). @@ -92,3 +95,31 @@ the same: the agent user must end up as "other", with no bits. Not sure what your values are? Check them with `id -u` / `id -g` in a fresh WSL terminal (logged in as the default user). + +## Other root-equivalent surfaces (audit) + +Docker is not the only tool whose daemon socket means root. `status.sh` +ships a **report-only audit** ("Root-equivalent access" section) that +checks the surfaces below for agent reachability — the kit never changes +them, because removing access is an admin decision: + +| Surface | Why it matters | Default on WSL2 | +|---|---|---| +| Rootful docker socket (`/var/run/docker.sock`) / `docker` group | full root, classic escape | installed by many devs — fine while the agent user stays out of the group | +| Docker Desktop / Rancher Desktop integration sockets under `/mnt/wsl` | root-equivalent daemon in the Windows VM, exposed **world-usable** to every WSL distro user | present whenever Docker Desktop's WSL integration is enabled — remove the agent's distro from it or restrict the socket | +| `containerd` socket | full root | root-only by default | +| LXD/LXC, libvirt (`lxd`, `libvirt` groups + sockets) | full root (host VMs/mounts) | not installed by default | +| `sudo`/`admin`/`wheel`/`disk`/`snap` groups for the agent user | full root | never granted by the kit — the audit catches later manual grants | +| `wireshark`/`adm`/`systemd-journal` groups | not root, but packet/log access can leak credentials | not granted by the kit | +| Windows interop (executing `.exe` via `/mnt/c`) | runs code as the Windows session user | blocked by the `/mnt/c` restriction above; the audit probes the exec bit explicitly | + +Deliberately **not** flagged: + +- `snapd`'s socket is mode 666 by design, but snapd gates write requests + by peer credentials (root only) — not root-equivalent for the agent. +- `newuidmap`/`newgidmap` are setuid, but required by the rootless + backends and bounded to the ranges in `/etc/subuid`. + +The audit is stat math only — no privileged probes, no prompts. Override +the socket list with `ROOT_EQUIV_SOCKS="…"` (same pattern as +`LEAK_SCAN_DIRS`). diff --git a/docs/concepts/wrapper.md b/docs/concepts/wrapper.md index 67a36b0..e356855 100644 --- a/docs/concepts/wrapper.md +++ b/docs/concepts/wrapper.md @@ -9,7 +9,9 @@ Every `opencode` invocation goes through the wrapper at `/usr/local/bin/opencode`: 1. **Validate working directory** — the current directory must be inside a - path listed in `projects.conf`. Otherwise opencode does not start. + path listed in `projects.conf`. Otherwise the interactive start is + refused (headless invocations skip this — see + [below](#headless-invocations-serve-run-queries)). 2. **Detect container tools** — if the project's `opencode.jsonc` broadly allows docker/ddev, the wrapper attaches the configured rootless backend (no confirmation dialog — the state is visible in the TUI, @@ -29,23 +31,46 @@ The wrapper prints its banner and starts opencode **immediately** — no 0.0.21: the question was effectively always answered with yes, and the kit's state is now visible inside the TUI at all times). -## The serve exception (headless start) - -`opencode serve` does not go through these steps. Third-party UIs like -OpenChamber spawn the server non-interactively — stdin is `/dev/null` and -stdout is parsed for the `opencode server listening on ` line — so -any interactive prompt would break the startup (and `read` on a closed -stdin kills the wrapper before it execs). Since 0.0.21 the wrapper is -prompt-free everywhere; serve mode additionally: - -- skips the project-directory check — the server accepts sessions per - client request, and the soft permission layer (global + per-project - `opencode.jsonc`) still applies to every session, -- prints nothing on stdout; diagnostics (shadow binary, backend - warnings) go to stderr, -- resolves container tools silently — a server serves many projects, so - there is no single opt-in to confirm; whether a session may actually - use docker/ddev stays decided by the `opencode.jsonc` rules. +## Headless invocations (serve, run, queries) + +`opencode serve` and the other non-interactive subcommands do not go +through the interactive path. Third-party tools spawn opencode +non-interactively and parse its stdout: + +| Invocation | Who uses it | What is parsed | +|---|---|---| +| `opencode serve` | OpenChamber, cezar, CodeWalk, the VS Code extension | `opencode server listening on ` | +| `opencode run` | CI runners, kanban orchestrators, eval harnesses | `--format json` / stream-json events | +| `opencode acp` | IDE agents (Agent Client Protocol) | JSON-RPC over stdio | +| `opencode models`, `agent`, `providers`, `export`, … | cezar (model discovery), scripts | JSON/plain listings | + +A banner or prompt on stdout would break those parsers (and `read` on a +closed stdin would kill the wrapper under `set -e`), and the +project-directory check would refuse tools running from git worktrees or +temporary checkouts. Headless invocations therefore: + +- skip the project-directory check — the soft permission layer (global + + per-project `opencode.jsonc`) still applies to every session, +- print nothing on stdout; diagnostics (shadow binary, backend warnings) + go to stderr, +- resolve container tools silently — `serve` always attaches them (a + server serves many projects); `run`/queries attach them when the CWD's + project config opts in. Whether a session may actually use docker/ddev + stays decided by the `opencode.jsonc` rules. + +What counts as headless: `serve`, `acp`, the query subcommands (`models`, +`agent`, `providers`, `session`, `export`, `import`, `stats`, `account`, +`github`, `pr`, `mcp`, `plug`, `db`, `generate`, `web`, `debug`, +`uninstall`, `upgrade`), and `run` when a message argument is given or +stdin is piped. Interactive TUI starts (no subcommand, flags-only +starts, `tui`, `attach`, or `opencode run` on a terminal without a +message) keep the banner and the project-directory check. + +Which ecosystem tools use which invocation — and the verified status of +each — is tracked in the [compatibility +matrix](../reference/compatibility.md) (issue #42 research). Tools that +are opencode *plugins* (awesome-opencode) load inside the agent process +and never touch the wrapper. `OPENCODE_SERVER_PASSWORD` and `OPENCODE_SERVER_USERNAME` are preserved across the `sudo -u opencode` exec, so the Basic-auth credentials a UI diff --git a/docs/getting-started.md b/docs/getting-started.md index 2f02b9c..b24123b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,12 +40,14 @@ It then asks only the essential questions: read your repositories: the group baseline covers `.git/`, and the kit sets `safe.directory` for the `opencode` user (no "dubious ownership" errors). -3. **Agent resources** — when you already have `~/.agents` or `~/.claude` - (skills; opencode auto-loads both from `/skills/**/SKILL.md`), - bring them into `/home/opencode` so the agent can use them: - **move** (recommended — one canonical copy; you keep read/write via the - sharing group), **copy** (both sides keep their own, may drift) or - **skip**. Non-interactive installs move; +3. **Agent resources** — bring the resources opencode auto-loads into + `/home/opencode` so the agent can use them: `~/.agents` + (**whole directory** — it is opencode's own namespace) and + `~/.claude/skills` (**skills/ only** — the rest of `~/.claude` is + Claude Code's home, so credentials like `.credentials.json` stay in + your home). Choose **move** (recommended — one canonical copy; you + keep read/write via the sharing group), **copy** (both sides keep + their own, may drift) or **skip**. Non-interactive installs move; `--migrate-agents move|copy|skip` forces a choice. 4. **ddev settings** — dev-owned projects (default): the kit writes `disable_settings_management: true` into each project's diff --git a/docs/how-to/openchamber.md b/docs/how-to/openchamber.md index c568ac8..6b79cea 100644 --- a/docs/how-to/openchamber.md +++ b/docs/how-to/openchamber.md @@ -11,14 +11,17 @@ OpenChamber can manage the opencode server itself: it spawns connects to it. That spawn is non-interactive — stdin is closed and OpenChamber waits for the `opencode server listening on …` line on stdout. -The kit's wrapper has a **headless serve mode** for exactly this spawn +The kit's wrapper has a **headless mode** for exactly this spawn style: when the first argument is `serve`, it prints nothing on stdout, asks nothing, and starts the server as the `opencode` user directly. (Since 0.0.21 the wrapper is prompt-free in general — no `Press Enter`, -no `[Y/n]` — but serve mode additionally keeps stdout clean for +no `[Y/n]` — but headless mode additionally keeps stdout clean for parsers.) Project-directory checks do not apply to `serve` — sessions still get the global and per-project `opencode.jsonc` -permission rules (see [the wrapper](../concepts/wrapper.md)). +permission rules (see [the wrapper](../concepts/wrapper.md)). The same +headless contract covers other ecosystem tools — `opencode run` +orchestrators (cezar, CI runners) and `opencode acp` IDE agents — see +[headless invocations](../concepts/wrapper.md#headless-invocations-serve-run-queries). ## Just run it diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7a9cfe2..de64710 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -75,7 +75,7 @@ curl -fsSL https://raw.githubusercontent.com/steffenmaechtel/opencode-permission | `--projects ` | Pre-define project roots, skip interactive selection (consumes every following non-flag argument) | | `--container-backend ` | Non-interactive backend choice | | `--secure-git-config` | Enable `.git/config` hardening up front | -| `--migrate-agents ` | Bring the developer's `~/.agents` + `~/.claude` (skills) into `/home/opencode` — move (recommended), copy, or skip; default: ask (`--yes` = move) | +| `--migrate-agents ` | Bring the developer's agent resources into `/home/opencode`: `~/.agents` **whole** (opencode's own namespace) + `~/.claude/skills` **skills/ only** (credentials like `~/.claude/.credentials.json` stay in your home) — move (recommended), copy, or skip; default: ask (`--yes` = move) | Flags may appear in any order; unknown options abort the install. diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md new file mode 100644 index 0000000..b940008 --- /dev/null +++ b/docs/reference/compatibility.md @@ -0,0 +1,57 @@ +# Ecosystem compatibility + +This page lists third-party tools that spawn or front opencode, how they +invoke it, and whether they work on a machine where the kit owns the +`opencode` command. Last verified: Aug 2026 (opencode v1.18.15). + +## How the kit intercepts tools + +Everything that spawns `opencode` through `PATH` gets the kit's wrapper +at `/usr/local/bin/opencode` automatically — no tool configuration +needed. Tools that take an explicit binary path (`OPENCODE_BINARY`, +`CEZ_OPENCODE_BIN`, …) must point at the wrapper, not at +`~/.opencode/bin/opencode`. + +Non-interactive invocations (`serve`, `run`, `acp`, query subcommands) +run through the wrapper's [headless +contract](../concepts/wrapper.md#headless-invocations-serve-run-queries): +stdout stays machine-clean, no prompts, no project-directory refusal. +The soft permission layer (global + per-project `opencode.jsonc`) applies +to every session regardless of which tool started it. + +## Compatibility matrix + +| Tool | Kind | Invocation | Status | +|---|---|---|---| +| [OpenChamber](https://openchamber.dev) (web/desktop/VS Code) | UI | `opencode serve` | works (headless serve since 0.0.16) | +| CodeWalk | remote UI | user-run `opencode serve` | works | +| OpenCode Mobile, P4OC | mobile clients | user-run `opencode serve` | works | +| [cezar](https://github.com/lukaszuznanski/cezar) | orchestrator | `opencode serve` + `opencode models` | works (headless queries since 0.0.22) | +| Vibe Kanban | kanban orchestrator | `opencode run` | works (headless run since 0.0.22) | +| eval-harness | skill testing | `opencode run` | works (headless run since 0.0.22) | +| opencode-actions | CI (GitHub Actions) | `opencode run` | works (headless run since 0.0.22) | +| Telegram/harness bots (kimaki, GolemBot, …) | chat bots | `opencode run` / `serve` | works | +| opencode.nvim, opencode-vim | editor frontends | `opencode run` / SDK | works | +| ACP-based IDE agents | IDE | `opencode acp` (JSON-RPC stdio) | works (headless acp since 0.0.22) | +| awesome-opencode plugins | plugins | load inside the agent process | unaffected — the soft layer applies to their tool calls | +| [OpenHarness](https://github.com/HKUDS/OpenHarness) | own harness | does not invoke opencode (own auth: `~/.claude/.credentials.json`, `~/.codex/auth.json`) | no interaction with the kit | + +"Works" means: the tool's spawn pattern passes the wrapper and opencode +runs under the kit's UID separation with the soft permission layer +enforced. It does not mean the kit audits or endorses the tool itself. + +## Caveats + +- **Absolute-path spawns.** A tool hardcoding `~/.opencode/bin/opencode` + bypasses the wrapper — the kit's [bypass + guards](../concepts/wrapper.md) detect and warn about that binary. + Point the tool's binary setting at `/usr/local/bin/opencode`. +- **sudo-spawning tools.** Tools that spawn opencode under `sudo` run it + as root — outside the kit's model. Report such a tool and we will take + a look; the kit deliberately grants no root path. + +## Keeping this page current + +The ecosystem moves fast — if a tool breaks or a notable one is missing, +please open an issue at +[steffenmaechtel/opencode-permissions-kit](https://github.com/steffenmaechtel/opencode-permissions-kit/issues). diff --git a/files/install.sh b/files/install.sh index a2404a5..c2abc2f 100755 --- a/files/install.sh +++ b/files/install.sh @@ -1314,9 +1314,16 @@ sudo chmod 2775 /home/opencode/.config /home/opencode/.config/opencode /home/ope # DEVELOPER's home they are invisible to the agent, which reads # /home/opencode. Offer move (recommended — one canonical copy, the # developer keeps rw via the sharing group), copy (both sides keep their -# own, may drift) or skip. Applies to both folders; the choice is made -# once and applies to whichever exist. -MIGRATE_AGENT_DIRS=".agents .claude" +# own, may drift) or skip. The choice is made once and applies to +# whichever exist. +# +# Scope (review 0.0.22): ~/.agents is opencode's OWN directory and +# migrates WHOLE — everything in it belongs to the agent namespace +# anyway. ~/.claude is Claude Code's home and carries OAuth tokens +# (.credentials.json) and account state at its top level — only +# skills/ crosses (the only part opencode loads), so credentials +# never reach the agent's group-readable home. +MIGRATE_AGENT_DIRS=".agents .claude/skills" _opk_migrate_one() { _opk_src="/home/$DEFAULT_USER/$1" _opk_dst="/home/$OPENCODE_USER/$1" @@ -1330,9 +1337,19 @@ _opk_migrate_one() { # Sharing baseline: opencode owns, the developer keeps rw # through the group (dirs setgid so new files inherit it). The # top dir gets the explicit 2775 — mkdir's mode depends on the - # process umask. + # process umask. Sub-path migrations (.claude/skills) also + # normalize the parent the kit just created (mkdir -p leaves it + # root-owned otherwise) — never /home/opencode itself, whose + # mode 2750 (no world bit) is by design. sudo chown -R "$OPENCODE_USER:$OPENCODE_GROUP" "$_opk_dst" sudo chmod 2775 "$_opk_dst" + case "$1" in + */*) + _opk_parent="$(dirname "$_opk_dst")" + sudo chown "$OPENCODE_USER:$OPENCODE_GROUP" "$_opk_parent" 2>/dev/null || true + sudo chmod 2775 "$_opk_parent" 2>/dev/null || true + ;; + esac sudo find "$_opk_dst" -type d -exec chmod g+rwxs {} + 2>/dev/null || true sudo find "$_opk_dst" -type f -exec chmod g+rw {} + 2>/dev/null || true if [ "$_opk_ag" = m ]; then @@ -1357,7 +1374,8 @@ if [ "$DEFAULT_USER" != "$OPENCODE_USER" ] && [ "$_opk_have_agent_dirs" = true ] if [ "$INTERACTIVE" = true ]; then while true; do echo "" >&2 - printf "[?] Existing agent resources (~/.agents, ~/.claude — skills etc.) — bring them into /home/%s?\n" "$OPENCODE_USER" >&2 + printf "[?] Existing agent resources (~/.agents, ~/.claude/skills) — bring them into /home/%s?\n" "$OPENCODE_USER" >&2 + echo " (~/.agents moves whole; from ~/.claude only skills/ — credentials like .credentials.json stay in your home)" >&2 echo " (m) Move — recommended: one canonical copy; you keep read/write via the $OPENCODE_GROUP group" >&2 echo " (c) Copy — duplicate; both sides keep their own copy (may drift)" >&2 echo " (s) Skip — leave them in your home (the agent cannot use them)" >&2 @@ -1376,7 +1394,7 @@ if [ "$DEFAULT_USER" != "$OPENCODE_USER" ] && [ "$_opk_have_agent_dirs" = true ] case "$_opk_ag" in m|c) for _opk_d in $MIGRATE_AGENT_DIRS; do _opk_migrate_one "$_opk_d"; done ;; s) - ui_detail "skipped: ~/.agents and ~/.claude stay in your home — the agent cannot use these skills" + ui_detail "skipped: ~/.agents and ~/.claude/skills stay in your home — the agent cannot use them" log "agents migration: skipped by choice" ;; esac diff --git a/files/opencode-permissions-kit-lib/wrapper b/files/opencode-permissions-kit-lib/wrapper index 8201e0b..18338bb 100755 --- a/files/opencode-permissions-kit-lib/wrapper +++ b/files/opencode-permissions-kit-lib/wrapper @@ -8,8 +8,9 @@ # enforced by opencode's own permission layer only. The wrapper's job is # UID separation: validate the project dir, detect container-tool opt-in, # and exec the binary as 'opencode' against its rootless backend. -# `opencode serve` takes a headless path (see SERVE_MODE below): third-party -# UIs like OpenChamber spawn the server non-interactively. +# `opencode serve` and friends take a headless path (see HEADLESS below): +# third-party UIs like OpenChamber spawn the server non-interactively, and +# orchestrators parse `opencode run --format json` output. set -e RED='\033[0;31m' @@ -35,26 +36,58 @@ case "$CONTAINER_BACKEND" in *) CONTAINER_BACKEND="" ;; esac -# Headless serve mode. Third-party UIs (OpenChamber, ...) spawn -# `opencode serve --hostname --port

` non-interactively: stdin is -# /dev/null and stdout is parsed for the "opencode server listening on -# " line. Banner and prompts break that twice over — extra stdout -# noise for the parser, and `read` hitting EOF would make set -e kill -# the wrapper before the exec. Serve mode therefore prints nothing on -# stdout, reports diagnostics on stderr, skips the project-dir check -# (the server accepts sessions per client request; the soft layer — -# global + per-project opencode.jsonc — still applies to every session), -# and resolves container tools silently. -SERVE_MODE=false -if [ "${1:-}" = "serve" ]; then SERVE_MODE=true; fi +# Headless mode (issue #42; generalizes the 0.0.16 serve fix). Ecosystem +# tools spawn opencode non-interactively and parse its stdout: +# `opencode serve` OpenChamber, cezar, CodeWalk, the VS Code extension +# (parse the "listening on " line) +# `opencode run` CI runners, kanban orchestrators, eval harnesses +# (`--format json` / stream-json on stdout) +# `opencode acp` JSON-RPC over stdio (IDE agents) — stdout must stay +# protocol-clean +# query subcommands `models`, `agent`, `providers`, ... (e.g. cezar +# caches `opencode models` output) +# A banner on stdout breaks those parsers twice over — extra noise and a +# `read` hitting EOF under set -e killing the wrapper before the exec — +# and the project-dir refusal would block tools running from git +# worktrees or temp checkouts. Headless therefore prints NOTHING on +# stdout (diagnostics go to stderr), skips the project-dir check (the +# soft layer — global + per-project opencode.jsonc — still applies to +# every session), and resolves container tools silently. Interactive TUI +# starts (no subcommand, flags-only start, `tui`, `attach`, or `run` with +# a tty and no message argument) keep banner and check. +HEADLESS=false +_hr_first=true +_hr_msg=false +for _hr_a in "$@"; do + if [ "$_hr_first" = true ]; then + _hr_first=false + continue + fi + case "$_hr_a" in + -*) ;; + *) _hr_msg=true; break ;; + esac +done +case "${1:-}" in + serve|acp|models|agent|providers|session|export|import|stats|account|github|pr|mcp|plug|db|generate|web|debug|uninstall|upgrade) + HEADLESS=true + ;; + run) + # Non-interactive when a message argument exists (any arg after the + # subcommand that is not a flag) or stdin is piped (prompt on stdin). + if [ "$_hr_msg" = true ] || ! [ -t 0 ]; then + HEADLESS=true + fi + ;; +esac -# Wrapper diagnostics: interactive starts print to the terminal, serve +# Wrapper diagnostics: interactive starts print to the terminal, headless # mode redirects to stderr so stdout carries only opencode's own output. # printf '%b' (not echo) so the \033 color escapes render identically # under dash and bash. An empty argument prints a blank separator line. note() { _fd=1 - [ "$SERVE_MODE" = true ] && _fd=2 + [ "$HEADLESS" = true ] && _fd=2 if [ -n "$1" ]; then printf ' %b\n' "$1" >&"$_fd" else @@ -76,7 +109,7 @@ if [ -f "$PROJECTS_CONF" ]; then done < "$PROJECTS_CONF" fi -if [ "$VALID" != true ] && [ "$SERVE_MODE" != true ]; then +if [ "$VALID" != true ] && [ "$HEADLESS" != true ]; then # Version/help queries carry no project context — answer them from the # real binary instead of refusing. The official opencode installer # probes `opencode --version` from $HOME while the kit installs; the @@ -111,7 +144,7 @@ if [ "$VALID" != true ] && [ "$SERVE_MODE" != true ]; then exit 1 fi -if [ "$SERVE_MODE" != true ]; then +if [ "$HEADLESS" != true ]; then echo "" echo " ${GREEN}SECURED BY opencode permissions kit ($VERSION)${NC}" echo " ${CYAN}@steffenmaechtel/opencode-permissions-kit${NC}" @@ -168,23 +201,21 @@ if [ -n "$PROJECT_CONFIG" ]; then PROJECT_TOOLS=$(python3 /usr/local/lib/opencode-permissions-kit/jsonc-parser.py --tools "$PROJECT_CONFIG" 2>/dev/null || true) if [ -n "$PROJECT_TOOLS" ]; then note "${CYAN}Container tools enabled by this project:${NC}" - if [ "$SERVE_MODE" = true ]; then - printf '%s\n' "$PROJECT_TOOLS" | sed 's/^/ - /' >&2 - else - printf '%s\n' "$PROJECT_TOOLS" | sed 's/^/ - /' - fi + printf '%s\n' "$PROJECT_TOOLS" | sed 's/^/ - /' >&"$_fd" note "" CONTAINER_REQUESTED=true CONTAINER_AUTO=true fi fi -# Serve mode requests container tools silently: a server process serves -# many projects, so there is no single project opt-in to confirm and no -# terminal to confirm on. This stays inside the soft model — whether a -# session may use docker/ddev is still decided per session by the -# global and per-project opencode.jsonc permission rules. -if [ "$SERVE_MODE" = true ]; then +# `serve` always resolves container tools silently (a server process +# serves many projects — no single opt-in, no terminal). Other headless +# invocations get them through the project opt-in above (notes are +# stderr-only in headless mode, stdout stays machine-clean). This stays +# inside the soft model — whether a session may use docker/ddev is still +# decided per session by the global and per-project opencode.jsonc +# permission rules. +if [ "${1:-}" = "serve" ]; then CONTAINER_REQUESTED=true CONTAINER_AUTO=false fi @@ -276,16 +307,12 @@ fi # starts immediately. The [Y/n] container question was always answered # with yes in practice, and the kit's state is now visible INSIDE the # TUI (mode row in app_bottom, docs/design/plan-ui-tui-opencode.md) — -# a pause before the TUI would only delay that information. Serve mode -# was never allowed to prompt (headless contract, 0.0.16); it keeps its -# stderr advisory for attached container tools. -if [ "$CONTAINER_AUTO" = true ]; then - if [ -n "$CONTAINER_DOCKER_HOST" ] || [ "$CONTAINER_PODMAN" = true ]; then - printf " ${GREEN}opencode will run with %s.${NC}\n" "$CONTAINER_LABEL" - fi -elif [ "$SERVE_MODE" = true ]; then +# a pause before the TUI would only delay that information. Headless +# mode was never allowed to prompt (0.0.16 contract); its advisory goes +# to stderr only. +if [ "$CONTAINER_AUTO" = true ] || [ "$HEADLESS" = true ]; then if [ -n "$CONTAINER_DOCKER_HOST" ] || [ "$CONTAINER_PODMAN" = true ]; then - printf ' %b\n' "${GREEN}opencode serve: container tools attached via $CONTAINER_LABEL.${NC}" >&2 + note "${GREEN}opencode will run with $CONTAINER_LABEL.${NC}" fi fi diff --git a/files/status.sh b/files/status.sh index 116c68f..c76bde4 100755 --- a/files/status.sh +++ b/files/status.sh @@ -344,6 +344,113 @@ if [ -d /mnt/c ]; then fi fi +# === Root-equivalent access audit (issue #37) ==================================== +# Root-equivalent surfaces BEYOND the kit's own backends: a rootful docker +# daemon or containerd, LXD/libvirt — and, very common on WSL2, the Docker +# Desktop / Rancher Desktop integration sockets under /mnt/wsl, which the +# distro mounts world-usable. Any of these reachable by the agent user +# breaks the "containers ≠ root" guarantee as thoroughly as a root docker +# group membership. All checks are report-only stat math — no privileged +# probe, no prompt, no fix (removing access is the admin's decision). + +# status_groups_hits : prints every group from +# that appears in (word match). +# shellcheck disable=SC2086 # word splitting intended: both are group lists +status_groups_hits() { + for sgh_g in $2; do + case " $1 " in *" $sgh_g "*) printf '%s\n' "$sgh_g" ;; esac + done + return 0 +} + +# status_sock_agent_reachable : exit 0 when the +# agent user can plausibly connect — socket world-writable, or group-writable +# with its group among . Pure stat math, works unprivileged. +status_sock_agent_reachable() { + ssr_mode=$(stat -c %a "$1" 2>/dev/null) || return 1 + [ $((0$ssr_mode & 0002)) -ne 0 ] && return 0 + if [ $((0$ssr_mode & 0020)) -ne 0 ]; then + ssr_grp=$(stat -c %G "$1" 2>/dev/null) + case " $2 " in *" $ssr_grp "*) return 0 ;; esac + fi + return 1 +} + +ui_section "Root-equivalent access (audit)" + +# Groups that are root-equivalent (or credential-equivalent) if the AGENT +# user is a member. The kit never adds them — this catches later manual +# grants (usermod -aG docker opencode "to make something work"). +sra_groups=$(id -nG "$OPENCODE_USER" 2>/dev/null || true) +sra_finding=false +# shellcheck disable=SC2086 # word splitting intended: group lists +sra_red=$(status_groups_hits "$sra_groups" "docker containerd lxd libvirt libvirt-qemu snap disk sudo admin wheel" | tr '\n' ' ') +# shellcheck disable=SC2086 # word splitting intended: group lists +sra_yellow=$(status_groups_hits "$sra_groups" "wireshark adm systemd-journal" | tr '\n' ' ') +sra_red=${sra_red% } ; sra_yellow=${sra_yellow% } +if [ -n "$sra_red" ]; then + ui_kv "groups (root-equiv)" "$OPENCODE_USER in: $sra_red — full root, remove with: sudo gpasswd -d $OPENCODE_USER " "$UI_RED" + sra_finding=true +fi +if [ -n "$sra_yellow" ]; then + ui_kv "groups (sensitive)" "$OPENCODE_USER in: $sra_yellow — log/packet access can leak credentials" "$UI_YELLOW" + sra_finding=true +fi + +# Root-equivalent daemon sockets: system-wide paths plus everything the +# WSL2 desktop integrations mount under /mnt/wsl (Docker Desktop exposes +# its daemon socket world-usable to every WSL distro user by default). +# Override the list via ROOT_EQUIV_SOCKS (same pattern as LEAK_SCAN_DIRS). +sra_sock_list="/var/run/docker.sock /run/containerd/containerd.sock /var/lib/lxd/unix.socket /run/libvirt/libvirt-sock" +sra_sock_list="${ROOT_EQUIV_SOCKS:-$sra_sock_list}" +if [ -d /mnt/wsl ]; then + sra_sock_list="$sra_sock_list $(find /mnt/wsl -maxdepth 4 \( -type s -o -type l \) \( -name docker.sock -o -name podman.sock -o -name containerd.sock -o -name crio.sock \) 2>/dev/null)" +fi +sra_seen="" +for sra_s in $sra_sock_list; do + [ -S "$sra_s" ] || continue + # dedup (symlinked paths resolve to the same socket) + sra_r=$(readlink -f "$sra_s" 2>/dev/null || echo "$sra_s") + case " $sra_seen " in *" $sra_r "*) continue ;; esac + sra_seen="$sra_seen $sra_r" + if status_sock_agent_reachable "$sra_s" "$sra_groups"; then + ui_kv "socket" "$sra_s — AGENT-REACHABLE (root-equivalent daemon)" "$UI_RED" + sra_finding=true + else + ui_kv "socket" "$sra_s — present, not agent-reachable" "$UI_GREEN" + fi +done + +# Windows interop: with a world-executable /mnt/c the agent can run Windows +# binaries as the Windows session user (no Linux root, but full Windows +# profile access). The /mnt/c restriction above (fmask) also blocks this +# exec path — this probe makes the interop outcome explicit. +if [ -e /mnt/c/Windows/System32 ]; then + sra_mode=$(stat -c %a /mnt/c/Windows/System32/cmd.exe 2>/dev/null || true) + if [ -n "$sra_mode" ] && [ $((0$sra_mode & 0001)) -ne 0 ]; then + ui_kv "win interop" "agent can execute Windows binaries (/mnt/c world-executable)" "$UI_RED" + sra_finding=true + else + ui_kv "win interop" "blocked for the agent (/mnt/c restricted)" "$UI_GREEN" + fi +fi + +# The agent user itself must have NO sudo rules (the kit grants rules only +# to the developer, RunAs opencode). Only checkable when status.sh runs as +# root; otherwise silent. +if [ "$(id -u)" -eq 0 ] && command -v sudo >/dev/null 2>&1; then + if sudo -n -l -U "$OPENCODE_USER" 2>/dev/null | grep -Eq '\((ALL|opencode)[^)]*\)' ; then + ui_kv "sudo rules" "$OPENCODE_USER may run sudo — the kit grants it none (investigate)" "$UI_RED" + sra_finding=true + else + ui_kv "sudo rules" "none for $OPENCODE_USER" "$UI_GREEN" + fi +fi + +if [ "$sra_finding" = false ]; then + ui_kv "result" "no root-equivalent access found for $OPENCODE_USER" "$UI_GREEN" +fi + # === Sensitive-file leak scan (report-only) ===================================== # Name-based sweep of scratch directories for files matching the global deny # patterns. The kit protects storage locations, not information flows: a copy diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 5e96bf3..61b2784 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -62,7 +62,7 @@ E 'mkdir -p /home/dev/.config/opencode && printf "%s\n" "{\"model\":\"dummy\"}" # and ~/.claude (the two dirs opencode auto-scans for #

/skills/**/SKILL.md) — the install must MOVE both into # /home/opencode (--yes default) with the sharing-group baseline. -E 'mkdir -p /home/dev/.agents/skills/my-skill /home/dev/.claude/skills/claude-skill && printf "name: my-skill\n---\nbody\n" > /home/dev/.agents/skills/my-skill/SKILL.md && printf "name: claude-skill\n---\nbody\n" > /home/dev/.claude/skills/claude-skill/SKILL.md && chmod 700 /home/dev/.agents /home/dev/.claude && chmod 600 /home/dev/.agents/skills/my-skill/SKILL.md /home/dev/.claude/skills/claude-skill/SKILL.md' +E 'mkdir -p /home/dev/.agents/skills/my-skill /home/dev/.claude/skills/claude-skill && printf "name: my-skill\n---\nbody\n" > /home/dev/.agents/skills/my-skill/SKILL.md && printf "name: claude-skill\n---\nbody\n" > /home/dev/.claude/skills/claude-skill/SKILL.md && printf "agent-notes\n" > /home/dev/.agents/notes.md && printf "oauth-credentials" > /home/dev/.claude/.credentials.json && chmod 700 /home/dev/.agents /home/dev/.claude && chmod 600 /home/dev/.agents/skills/my-skill/SKILL.md /home/dev/.claude/skills/claude-skill/SKILL.md' echo "" echo "--- 1c. ddev migration fixtures (fake ddev + dev registry, issue #15) ---" @@ -412,14 +412,16 @@ check "Config deployed" \ E 'sudo test -f /home/opencode/.config/opencode/opencode.jsonc' check "Agents dir exists" \ E 'sudo test -d /home/opencode/.agents/' -check "issue #19: ~/.agents MOVED into the opencode home (--yes default)" \ +check "issue #19: ~/.agents moved WHOLE into the opencode home (--yes default)" \ E 'test "$(cat /home/opencode/.agents/skills/my-skill/SKILL.md)" = "name: my-skill --- -body"' -check "issue #19: ~/.claude MOVED into the opencode home too" \ +body" && test -f /home/opencode/.agents/notes.md' +check "issue #19: ~/.claude/skills moved into the opencode home" \ E 'test -f /home/opencode/.claude/skills/claude-skill/SKILL.md' -check "issue #19: developer's source dirs are gone after the move" \ - E 'test ! -e /home/dev/.agents && test ! -e /home/dev/.claude' +check "review 0.0.22: ~/.claude/.credentials.json STAYS with the developer (skills-only from .claude)" \ + E 'test -f /home/dev/.claude/.credentials.json && test ! -e /home/opencode/.claude/.credentials.json' +check "issue #19: whole .agents gone after the move, .claude parent stays (skills-only)" \ + E 'test ! -e /home/dev/.agents && test -d /home/dev/.claude && test ! -e /home/dev/.claude/skills' check "issue #19: migrated dirs carry the sharing baseline (setgid + group)" \ E 'test "$(stat -c %U:%G:%a /home/opencode/.agents)" = "opencode:opencode:2775" && test "$(stat -c %U:%G:%a /home/opencode/.claude)" = "opencode:opencode:2775"' check "issue #19: migrated files are group-writable for the developer" \ @@ -707,6 +709,18 @@ check "wrapper serve: listening line also from project dir" \ check "wrapper serve: sudoers keep OPENCODE_SERVER_PASSWORD across sudo" \ E 'sudo grep -q "OPENCODE_SERVER_PASSWORD" /etc/sudoers.d/opencode-permissions-kit' +echo "--- 12e.3 wrapper headless run (orchestrators: cezar, CI, eval harnesses — issue #42) ---" +# Orchestrators spawn `opencode run` from git worktrees / temp checkouts +# and parse stdout (`--format json`). The wrapper must neither refuse the +# non-project CWD nor print the banner on stdout. Without auth the binary +# errors quickly on stderr — the wrapper contract is about stdout staying +# machine-clean, which holds either way. +E 'cd /tmp && timeout 30 /usr/local/bin/opencode run --format json "say ok" > /tmp/wrapper-run.out 2> /tmp/wrapper-run.err; true' +check_fail "wrapper run from non-project dir: no project-dir refusal" \ + E 'grep -q "cannot be started here" /tmp/wrapper-run.out /tmp/wrapper-run.err' +check_fail "wrapper run: no SECURED banner on stdout" \ + E 'grep -q "SECURED BY" /tmp/wrapper-run.out' + echo "" echo "--- 12f. uninstall.sh --dry-run (no-op) ---" E 'bash /usr/local/lib/opencode-permissions-kit/uninstall.sh --yes --dry-run' && \ diff --git a/tests/test-status.sh b/tests/test-status.sh index 1df16e5..d49a509 100755 --- a/tests/test-status.sh +++ b/tests/test-status.sh @@ -159,6 +159,109 @@ else fi chmod 755 "$WORK/locked-runtime" 2>/dev/null || true +# --- 1b. root-equivalent access audit (issue #37) -------------------------------- +# The audit ships two pure helpers (stat math only); they are extracted and +# unit-tested directly, plus static assertions on the section wiring. +if grep -q 'ui_section "Root-equivalent access' "$STATUS" \ + && grep -q 'ROOT_EQUIV_SOCKS' "$STATUS" \ + && grep -q '/mnt/wsl' "$STATUS"; then + pass "status.sh carries the root-equivalent access section (issue #37)" +else + fail "status.sh carries the root-equivalent access section (issue #37)" +fi + +( + . "$UI_LIB" + eval "$(sed -n '/^status_groups_hits()/,/^}/p' "$STATUS")" + eval "$(sed -n '/^status_sock_agent_reachable()/,/^}/p' "$STATUS")" + + # groups: word match, no substring false positives (adm vs admin) + _out=$(status_groups_hits "opencode www-data" "docker sudo admin adm wheel") + [ "$_out" = "" ] && echo GROUPS-CLEAN-OK + _out=$(status_groups_hits "opencode docker adm" "docker sudo admin adm wheel" | tr '\n' ' ') + [ "$_out" = "docker adm " ] && echo GROUPS-HIT-OK + + # socket reachability: real unix socket, perm math without root + _sock="$WORK/fake-daemon.sock" + python3 - "$_sock" <<'PYEOF' +import socket, sys +s = socket.socket(socket.AF_UNIX) +s.bind(sys.argv[1]) +s.listen(1) +PYEOF + _grp=$(id -gn) + chmod 666 "$_sock" + status_sock_agent_reachable "$_sock" "" && echo SOCK-WORLDW-OK + chmod 660 "$_sock"; chgrp "$_grp" "$_sock" + status_sock_agent_reachable "$_sock" "$_grp" && echo SOCK-GROUPW-OK + status_sock_agent_reachable "$_sock" "othergrp" || echo SOCK-GROUPW-DENY-OK + chmod 660 "$_sock"; chgrp "$_grp" "$_sock" + status_sock_agent_reachable "$_sock" "" || echo SOCK-NOOTHER-OK + status_sock_agent_reachable "$WORK/absent.sock" "" || echo SOCK-ABSENT-OK +) > "$WORK/audit.out" 2>&1 +_audit_ok=0 +for _want in GROUPS-CLEAN-OK GROUPS-HIT-OK SOCK-WORLDW-OK SOCK-GROUPW-OK SOCK-GROUPW-DENY-OK SOCK-NOOTHER-OK SOCK-ABSENT-OK; do + if grep -q "$_want" "$WORK/audit.out"; then + _audit_ok=$((_audit_ok + 1)) + else + fail "audit helper: missing $_want ($(cat "$WORK/audit.out"))" + fi +done +[ "$_audit_ok" -eq 7 ] && pass "root-equivalent audit helpers (groups + socket math)" + +# --- 1c. audit section body executes without crashing (review 0.0.22) ----------- +# The helper units above cover the math; this runs the WHOLE section body +# (extraction like the backend case) against a fake agent user + a real +# fake socket, under set -u — a crash here would otherwise only surface on +# an installed host (the e2e grep hits an earlier line and passes anyway). +# || true on the captures: the subshell deliberately runs under set -u and +# its exit status must not kill this script (same pattern as run_case). +AUDIT_SECTION="$(sed -n '/^# === Root-equivalent access audit/,/^# === Sensitive-file leak scan/p' "$STATUS" | sed '$d')" +[ -n "$AUDIT_SECTION" ] || { echo " ${RED}audit section not extractable${NC}"; exit 1; } +_sock2="$WORK/fake-agent-sock" +python3 - "$_sock2" <<'PYEOF' +import socket, sys +s = socket.socket(socket.AF_UNIX) +s.bind(sys.argv[1]) +s.listen(1) +PYEOF +audit_out=$( + ( + set -u; set +e + . "$UI_LIB" + OPENCODE_USER="root" # exists everywhere; id -nG works + ROOT_EQUIV_SOCKS="$_sock2" # the fake socket, world-writable + chmod 666 "$_sock2" 2>/dev/null # bind honors umask: force 666 + eval "$AUDIT_SECTION" + exit 0 + ) 2>&1 +) || true +if printf '%s' "$audit_out" | grep -q "Root-equivalent access" \ + && printf '%s' "$audit_out" | grep -q "AGENT-REACHABLE" \ + && ! printf '%s' "$audit_out" | grep -q "parameter not set"; then + pass "audit section body runs (fake world-writable socket flagged red)" +else + fail "audit section body runs (out=$(printf '%s' "$audit_out" | head -3))" +fi +# The same socket at 660 with a group the agent user is NOT in must NOT be +# flagged reachable (perm-math negative inside the section body). +audit_out2=$( + ( + set -u; set +e + . "$UI_LIB" + OPENCODE_USER="root" + ROOT_EQUIV_SOCKS="$_sock2" + chmod 660 "$_sock2" 2>/dev/null + eval "$AUDIT_SECTION" + exit 0 + ) 2>&1 +) || true +if printf '%s' "$audit_out2" | grep -q "not agent-reachable"; then + pass "audit section body: group-w socket with foreign group stays green" +else + fail "audit section body: foreign-group socket flagged or crashed (out=$(printf '%s' "$audit_out2" | head -3))" +fi + # --- 2. not-installed state: exit 0 + install hint ------------------------------ if ! id opencode >/dev/null 2>&1; then diff --git a/tests/test-wrapper-validation.sh b/tests/test-wrapper-validation.sh index fbd63f6..22e7f07 100755 --- a/tests/test-wrapper-validation.sh +++ b/tests/test-wrapper-validation.sh @@ -265,27 +265,74 @@ else failures=$((failures + 1)) fi -# --- Headless serve mode (third-party UIs like OpenChamber) --- -# OpenChamber et al. spawn `opencode serve` with stdin ignored and parse -# stdout for the "opencode server listening on " line. The wrapper -# must not banner/prompt there (read-on-EOF + set -e would kill it before -# the exec) and must keep stdout clean. +# --- Headless mode (third-party UIs + orchestrators, issue #42) --- +# Ecosystem tools spawn opencode non-interactively: `opencode serve` +# (OpenChamber, cezar, CodeWalk — stdout parsed for the "listening on" +# line), `opencode run` (CI/eval harnesses, `--format json` stdout), +# `opencode acp` (JSON-RPC over stdio) and query subcommands. The wrapper +# must keep stdout machine-clean and must not refuse non-project CWDs +# there (worktrees, temp checkouts). echo "" -echo "--- Serve mode (headless, third-party UIs) ---" +echo "--- Headless mode (third-party tools) ---" -if grep -q 'if \[ "${1:-}" = "serve" \]; then SERVE_MODE=true; fi' "$WRAPPER_FILE"; then - echo " ${GREEN}PASS${NC} wrapper detects the serve subcommand" +if grep -q '^HEADLESS=false' "$WRAPPER_FILE" && grep -q 'serve|acp|models|agent|providers' "$WRAPPER_FILE"; then + echo " ${GREEN}PASS${NC} wrapper classifies headless subcommands (serve/acp/queries)" passed=$((passed + 1)) else - echo " ${RED}FAIL${NC} wrapper lost serve detection" + echo " ${RED}FAIL${NC} wrapper lost headless subcommand classification" failures=$((failures + 1)) fi -if grep -q 'if \[ "\$VALID" != true \] && \[ "\$SERVE_MODE" != true \]; then' "$WRAPPER_FILE"; then - echo " ${GREEN}PASS${NC} serve mode skips the project-dir refusal" +if grep -q 'if \[ "\$VALID" != true \] && \[ "\$HEADLESS" != true \]; then' "$WRAPPER_FILE"; then + echo " ${GREEN}PASS${NC} headless mode skips the project-dir refusal" passed=$((passed + 1)) else - echo " ${RED}FAIL${NC} serve mode still bound to project-dir validation" + echo " ${RED}FAIL${NC} headless mode still bound to project-dir validation" + failures=$((failures + 1)) +fi + +# Classification is executable: extract the block and run it against +# representative argument vectors (same static-extraction technique as +# test-status.sh). tty-less CI makes `run` (no message) headless via the +# stdin check — an interactive terminal would keep it bannered; that +# branch is covered by the `-t 0` grep below. +HL_BLOCK="$(sed -n '/^HEADLESS=false/,/^esac/p' "$WRAPPER_FILE")" +[ -n "$HL_BLOCK" ] || { echo " ${RED}FAIL${NC} headless block not extractable"; failures=$((failures + 1)); } +hl_case() { # + _desc="$1"; _want="$2"; shift 2 + _got=$(set -- "$@"; eval "$HL_BLOCK"; echo "$HEADLESS") + if [ "$_got" = "$_want" ]; then + echo " ${GREEN}PASS${NC} headless: $_desc" + passed=$((passed + 1)) + else + echo " ${RED}FAIL${NC} headless: $_desc (want=$_want got=$_got)" + failures=$((failures + 1)) + fi +} +hl_case "serve is headless" true serve +hl_case "run with message is headless" true run "fix the bug" +hl_case "run --format json is headless" true run --format json "hi" +hl_case "acp is headless" true acp +hl_case "models is headless" true models +hl_case "export is headless" true export sess-123 +hl_case "tui stays interactive" false tui +hl_case "attach stays interactive" false attach +hl_case "no args stays interactive" false +hl_case "flags-only start stays interactive" false --debug + +if grep -q '\-t 0' "$WRAPPER_FILE"; then + echo " ${GREEN}PASS${NC} run without message checks stdin tty (piped = headless)" + passed=$((passed + 1)) +else + echo " ${RED}FAIL${NC} run lost the interactive-tty distinction" + failures=$((failures + 1)) +fi + +if grep -q 'if \[ "${1:-}" = "serve" \]; then' "$WRAPPER_FILE" && grep -q 'CONTAINER_AUTO=false' "$WRAPPER_FILE"; then + echo " ${GREEN}PASS${NC} serve requests container tools without prompting" + passed=$((passed + 1)) +else + echo " ${RED}FAIL${NC} serve lost the silent container-tool request" failures=$((failures + 1)) fi @@ -314,18 +361,20 @@ else fi if grep -A8 '^note()' "$WRAPPER_FILE" | grep -q '>&"\$_fd"\|>&\$_fd' && grep -A8 '^note()' "$WRAPPER_FILE" | grep -q '_fd=2'; then - echo " ${GREEN}PASS${NC} serve-mode diagnostics go to stderr (note helper)" + echo " ${GREEN}PASS${NC} headless diagnostics go to stderr (note helper)" passed=$((passed + 1)) else - echo " ${RED}FAIL${NC} serve-mode diagnostics would pollute stdout" + echo " ${RED}FAIL${NC} headless diagnostics would pollute stdout" failures=$((failures + 1)) fi -if grep -q 'if \[ "\$SERVE_MODE" = true \]; then' "$WRAPPER_FILE" && grep -q 'CONTAINER_AUTO=false' "$WRAPPER_FILE"; then - echo " ${GREEN}PASS${NC} serve mode requests container tools without prompting" +# The container-tools advisory must also route through note() — a raw +# printf to stdout would pollute `opencode run --format json` output. +if ! grep -q 'printf " ${GREEN}opencode will run with' "$WRAPPER_FILE"; then + echo " ${GREEN}PASS${NC} container advisory uses note() (stdout-safe for orchestrators)" passed=$((passed + 1)) else - echo " ${RED}FAIL${NC} serve mode lost the silent container-tool request" + echo " ${RED}FAIL${NC} container advisory prints raw to stdout" failures=$((failures + 1)) fi