Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/scripts/test-bootstrap-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions templates/devcontainer/Dockerfile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
82 changes: 80 additions & 2 deletions templates/devcontainer/scripts/bootstrap-state.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -140,20 +140,98 @@ 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
# (sessions auto-sync), so neither is installed nor hooked here. --auto-patch
# 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"
Expand Down