diff --git a/.github/scripts/test-bootstrap-state.sh b/.github/scripts/test-bootstrap-state.sh index b961939..cb66f9b 100755 --- a/.github/scripts/test-bootstrap-state.sh +++ b/.github/scripts/test-bootstrap-state.sh @@ -110,6 +110,48 @@ assert_eq "malformed file untouched" 'this is not json' "$(cat "$f5")" strip_host_hooks "$tmp/does-not-exist.json" && rc=0 || rc=$? assert_eq "missing file exits 0" '0' "$rc" +# --- ensure_rtk_sandbox_write ----------------------------------------------- +rtk_dir="$tmp/rtk-data" + +# Case 7: empty settings gains sandbox.filesystem.allowWrite +f7="$tmp/c7.json" +printf '{}' >"$f7" +ensure_rtk_sandbox_write "$f7" "$rtk_dir" +assert_eq "empty settings gets allowWrite" "$rtk_dir" \ + "$(jq -r '.sandbox.filesystem.allowWrite[0]' "$f7")" + +# Case 8: existing allowWrite entries are preserved, path appended +f8="$tmp/c8.json" +printf '{"sandbox":{"filesystem":{"allowWrite":["/tmp/build"]}}}' >"$f8" +ensure_rtk_sandbox_write "$f8" "$rtk_dir" +assert_eq "existing allowWrite preserved+appended" \ + '["/tmp/build","'"$rtk_dir"'"]' \ + "$(jq -c '.sandbox.filesystem.allowWrite' "$f8")" + +# Case 9: idempotent when the path is already listed +cp "$f8" "$tmp/c8.once" +ensure_rtk_sandbox_write "$f8" "$rtk_dir" +if cmp -s "$tmp/c8.once" "$f8"; then + assert_eq "allowWrite idempotent" "same" "same" +else + assert_eq "allowWrite idempotent" "same" "different" +fi + +# Case 10: malformed JSON / missing file are no-ops +f10="$tmp/c10.json" +printf 'not json' >"$f10" +ensure_rtk_sandbox_write "$f10" "$rtk_dir" && rc=0 || rc=$? +assert_eq "sandbox patch malformed JSON exits 0" '0' "$rc" +assert_eq "sandbox patch malformed file untouched" 'not json' "$(cat "$f10")" +ensure_rtk_sandbox_write "$tmp/no-such-settings.json" "$rtk_dir" && rc=0 || rc=$? +assert_eq "sandbox patch missing file exits 0" '0' "$rc" + +# Case 11: non-dict sandbox is left alone (don't clobber) +f11="$tmp/c11.json" +printf '{"sandbox":"off"}' >"$f11" +ensure_rtk_sandbox_write "$f11" "$rtk_dir" +assert_eq "non-dict sandbox untouched" '{"sandbox":"off"}' "$(cat "$f11")" + echo echo "passed=$pass failed=$fail" [[ "$fail" -eq 0 ]] || exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d1c22..2609aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to aidc are tracked here. Format follows [Keep a Changelog]( ### Fixed +- **rtk tracking DB permission denied.** rtk 0.44.2+ pre-creates `~/.local/share/rtk/history.db` owner-only and now surfaces `Permission denied (os error 13)` instead of swallowing it. Bootstrap now (a) ensures that data dir exists `0700` and is owned by `vscode`, (b) adds it to Claude Code `sandbox.filesystem.allowWrite` so sandboxed `rtk` can write the DB, and (c) does not write the hook-installed marker when `rtk init` fails, so the next start retries. The image also `mkdir`s the dir at build time. - **RTK install path.** Dockerfile previously set `RTK_INSTALL_DIR=/usr/local/bin` on the wrong side of the pipe (`VAR=val cmd1 | cmd2` scopes `VAR` to `cmd1`), so the installer fell back to `$HOME/.local/bin` while running as root — the binary landed in `/root/.local/bin/rtk` and was invisible to the `vscode` user. Env var moved onto the `sh` side of the pipe. ### Removed diff --git a/docs/security.md b/docs/security.md index 582e018..669fea6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -117,6 +117,13 @@ The image ships [`rtk`](https://github.com/rtk-ai/rtk) (Rust Token Killer — a rtk is auto-initialised the first time a fresh `claude_home` volume is created: `bootstrap-state.sh init` runs `rtk init --global --auto-patch --hook-only` (non-interactive; installs just the hook, no `RTK.md`/`CLAUDE.md` rewrite since both are seeded from the host), then drops a marker at `~/.claude/.aidc-agent-hooks-installed` so it isn't rerun on every container restart. `aidc destroy -f` wipes the volume and the marker, so the next `aidc up` re-applies the hook cleanly. +rtk's tracking DB lives at `~/.local/share/rtk/history.db`. From rtk 0.44.2 the binary pre-creates that file owner-only and **fails loud** if the open returns EACCES (previously the error was swallowed, so `rtk gain` just stayed at zero). Two aidc-specific causes: + +1. The data dir missing or not writable by `vscode` (the image now `mkdir`s it `0700` at build time; bootstrap also creates/chowns it on every start). +2. Claude Code's sandbox `allowWrite` list does not include that path, so every sandboxed `rtk` call gets `Permission denied (os error 13)`. Bootstrap idempotently adds `/home/vscode/.local/share/rtk` to `sandbox.filesystem.allowWrite` in the in-container `~/.claude/settings.json`. + +If `rtk init` itself fails, the hook-installed marker is **not** written, so the next container start retries. + The host's own agent hooks — SafeDep's `gryph` audit layer, and `cot` (whose command is a macOS-only binary path) — are host-side concerns: in-container transcripts auto-sync back to the host on container start and exit, so observability happens there rather than in the VM. `bootstrap-state.sh` strips those host-only hook entries from the seeded `settings.json` on every sync (preserving rtk and any user hooks), so the VM never carries hooks that can't run inside it. Verify: diff --git a/templates/devcontainer/Dockerfile.tmpl b/templates/devcontainer/Dockerfile.tmpl index bae5a6f..f17bddb 100644 --- a/templates/devcontainer/Dockerfile.tmpl +++ b/templates/devcontainer/Dockerfile.tmpl @@ -203,11 +203,13 @@ RUN pmg setup install \ # volume mounted at ~/.grok at runtime cannot shadow it. RUN mkdir -p \ /home/vscode/.local/bin \ + /home/vscode/.local/share/rtk \ /home/vscode/.claude \ /home/vscode/.codex \ /home/vscode/.config/opencode \ /home/vscode/.cursor-agent \ /home/vscode/.grok \ + && chmod 0700 /home/vscode/.local/share/rtk \ && curl -fsSL https://claude.ai/install.sh | bash \ && curl -fsSL https://chatgpt.com/codex/install.sh | sh \ && curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path \ diff --git a/templates/devcontainer/scripts/bootstrap-state.sh.tmpl b/templates/devcontainer/scripts/bootstrap-state.sh.tmpl index 41f039d..5ecefa3 100644 --- a/templates/devcontainer/scripts/bootstrap-state.sh.tmpl +++ b/templates/devcontainer/scripts/bootstrap-state.sh.tmpl @@ -140,10 +140,82 @@ sync_gitconfig_overlay() { fi } +# rtk 0.44.2+ pre-creates ~/.local/share/rtk/history.db with owner-only +# mode and now *propagates* that open() failure (it used to swallow it): +# Failed to pre-create private DB file: .../history.db: Permission denied +# Two independent causes show up as that error inside aidc: +# 1. The data dir is missing or not writable by vscode (root-owned leftover). +# 2. Claude Code's sandbox allowWrite list does not include the data dir +# (rtk-ai/rtk#320) — every sandboxed `rtk` then gets EACCES. +ensure_rtk_data_dir() { + local dir="${1:-$home_dir/.local/share/rtk}" + mkdir -p "$dir" 2>/dev/null || true + if [[ ! -d "$dir" || ! -w "$dir" ]]; then + if command -v sudo >/dev/null 2>&1; then + sudo -n mkdir -p "$dir" 2>/dev/null || true + sudo -n chown "$(id -u):$(id -g)" "$dir" 2>/dev/null || true + fi + fi + chmod 0700 "$dir" 2>/dev/null || true +} + +# Idempotently add $dir to settings.json sandbox.filesystem.allowWrite so +# Claude Code's sandbox lets rtk create/write history.db. No-op when the +# file is missing or not JSON; never removes existing allowWrite entries. +ensure_rtk_sandbox_write() { + local f="$1" + local dir="$2" + [[ -f "$f" ]] || return 0 + [[ -n "$dir" ]] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + python3 - "$f" "$dir" <<'PY' +import json, sys +path, data_dir = sys.argv[1], sys.argv[2] +try: + cfg = json.load(open(path)) +except (FileNotFoundError, ValueError): + sys.exit(0) +if not isinstance(cfg, dict): + sys.exit(0) +sandbox = cfg.get("sandbox") +if sandbox is None: + sandbox = {} + cfg["sandbox"] = sandbox +elif not isinstance(sandbox, dict): + sys.exit(0) +fs = sandbox.get("filesystem") +if fs is None: + fs = {} + sandbox["filesystem"] = fs +elif not isinstance(fs, dict): + sys.exit(0) +aw = fs.get("allowWrite") +if not isinstance(aw, list): + aw = [] +if data_dir in aw: + sys.exit(0) +aw.append(data_dir) +fs["allowWrite"] = aw +with open(path, "w") as fh: + json.dump(cfg, fh, indent=2) + fh.write("\n") +PY +} + install_agent_hooks() { # Marker lives in the claude_home volume so it survives container restarts # but is wiped by 'aidc destroy', triggering a fresh re-init. local marker="$home_dir/.claude/.aidc-agent-hooks-installed" + local rtk_dir="$home_dir/.local/share/rtk" + local settings="$home_dir/.claude/settings.json" + + # Always (re)assert the data dir + sandbox allowWrite. Volumes that already + # have the marker — including ones where an older bootstrap wrote the marker + # after a failed `rtk init` — still need the dir/allowlist or every sandboxed + # rtk call keeps failing with EACCES. + ensure_rtk_data_dir "$rtk_dir" + ensure_rtk_sandbox_write "$settings" "$rtk_dir" + [[ -f "$marker" ]] && return 0 # rtk is the only agent hook wired inside the VM. gryph/cot run on the host @@ -151,9 +223,15 @@ install_agent_hooks() { # makes init non-interactive; --hook-only adds just the PreToolUse/Bash hook # without re-writing CLAUDE.md/RTK.md (both already seeded from the host). if command -v rtk >/dev/null 2>&1; then - rtk init --global --auto-patch --hook-only >/dev/null 2>&1 \ - || echo "[bootstrap] rtk init --global failed" >&2 + if ! rtk init --global --auto-patch --hook-only; then + echo "[bootstrap] rtk init --global failed" >&2 + # Do not write the marker: retry hook install on the next start. + ensure_rtk_sandbox_write "$settings" "$rtk_dir" + return 0 + fi fi + # rtk init --auto-patch may rewrite settings.json; re-apply allowWrite. + ensure_rtk_sandbox_write "$settings" "$rtk_dir" mkdir -p "$(dirname "$marker")" touch "$marker"