OD-78: Harden the Claude agent (least-privilege two-user + auth proxy)#3
OD-78: Harden the Claude agent (least-privilege two-user + auth proxy)#3andrzej-janczak wants to merge 28 commits into
Conversation
Hybrid approach: keep deterministic OS boundary (iptables + two-user) as load-bearing control, layer first-party Claude Code hardening (env-scrub, managed-settings, dontAsk, native gateway BASE_URL, Read-deny) as cheap depth. DNS hardening promoted to in-scope (CVE-2025-55284). Rejects sandbox-runtime as net boundary (weakened in unprivileged Docker) and self-hosted LiteLLM (supply-chain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop Gemini (not in use): remove pipeline branch, env var, extension install. Bash policy: prefix-allowlist first with broad fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 TDD tasks driven by a docker-based adversarial probe harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epo-scope mitigation (OD-78) It is a Codacy Account API Token with account-wide blast radius; the cloud-config flow needs that scope, so it cannot be narrowed. OS-level unreadability is therefore the only control. Add follow-up to ask Codacy about a narrower token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…D-78) Plain-language explainer of the two-user hardening: threat model, sudo-shim, auth proxy, startup sequence, network allowlists, before/ after, verification. All security jargon defined for non-specialists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add --model haiku to the configure-codacy-cloud invocation; passes through the auth proxy as a request param. Note sonnet fallback if Haiku underperforms on the skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… remote (OD-78) Test run showed the skill auto-detects the target repo from the git remote and stops if /workspace isn't a Codacy-tracked git checkout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add --model haiku to the claude invocation (local + server). Passes through as a request param; reduces cost vs the default model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(OD-78) Lets the container reach Codacy dev/staging environments. Added to the iptables ipset (init-firewall.sh) and the planned dnsmasq DNS allowlist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_as_agent skips only the firewall (RUNNING_IN_K8S) for fast, quiet keyless probes; smoke asserts the final command runs as the agent user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(OD-78) Implements Task 2. Deviation from plan: real CLIs renamed to <name>-real in /usr/local/bin (same dir keeps the relative npm symlink valid) rather than moved to /opt/cli, which would break the relative symlink. Shim at the original name execs the -real binary as runner via NOPASSWD sudo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume (OD-78) Implements Task 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…crubbed env (OD-78) Implements Task 4. Runs as root: firewall, codacy login as runner (token via env not argv), start proxy as runner, then env -i drop to agent with only non-secret vars + ANTHROPIC_BASE_URL pointing at the local proxy. Uses /usr/local/bin/codacy-real (matches Task 2 rename). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er (OD-78) Implements Task 5. Proxy runs as runner, injects the real key; agent holds only a dummy and cannot read the proxy's /proc environ. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (OD-78) Implements Task 6. Drop WebFetch/Glob/Grep, scope Read/Write/Edit to /workspace, deny secret paths + network binaries, Bash prefix allowlist. Managed settings disable bypass mode. Settings move to /home/agent. Both pipelines run --permission-mode dontAsk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…off (OD-78) Implements Task 7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-78) Implements Task 8. server-pipeline strips the token from the remote URL after cloning and runs summary-sanitize.sh before the PUT upload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ps (OD-78) Implements Task 9. dnsmasq forwards only allowlisted domains to an upstream resolver (root-only egress, owner-matched) and --ipset adds resolved IPs to allowed-domains on the fly (no CDN race); everything else resolves to 0.0.0.0. Closes DNS-tunnel exfiltration; the agent's only DNS path is the local resolver. dnsmasq added to the image. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt (OD-78) Implements Task 10. local-pipeline no longer branches on ANTHROPIC_API_KEY (absent in the scrubbed agent env — it uses the proxy via ANTHROPIC_BASE_URL); the key requirement moves to the entrypoint. Removed GEMINI_API_KEY from compose and .env.example. The gemini binary stays in the image but is never invoked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements Task 11 (code). Not in ALL_PROBES; run via ./docker/test-hardening.sh cli|e2e with real fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements Task 12. New CLAUDE.md (with Security model section) + README updates: drop Gemini, /home/runner volume path, DNS allowlist incl dev/staging, least-privilege agent summary + test-hardening pointer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in (OD-78) Testing with a real token showed 'codacy login' does NOT persist creds from the env var. The CLI reads CODACY_API_TOKEN at runtime instead, so the entrypoint now stages the token in a runner-only file (/run/codacy, 600, outside the persisted volume) and a runner-side launcher (codacy-run, reached via the sudo shim) loads it before exec'ing the real CLI. Verified 'codacy repo' works as the agent with no token in the agent env. Resolves the spec's flagged codacy-login open item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n Bash allow (OD-78) Two fixes from live e2e: - CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 made claude require bubblewrap and refuse to start; removed. Redundant here — the entrypoint env -i already gives the agent a clean, secret-free env, so there is nothing for subprocess-scrub to protect. - Under dontAsk the Bash prefix-allowlist blocked the skill's helper commands (sed/cat/scripts), stalling it. Fell back to Bash(*) per the plan, keeping the deny list (curl/wget/ssh/dig/...) and scoped Read/Write/Edit. The OS layer (env scrub, two-user, proxy, firewall, DNS) is the real boundary; the agent still holds no readable secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12/12 keyless probes pass; credential path verified; full e2e completed through the hardened stack with a clean (secret-free) summary. Records the three e2e iterations and the 5 defects found+fixed during testing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a robust least-privilege security hardening model (OD-78) for the containerized Claude agent by separating execution into a privileged runner user (holding secrets and running an Anthropic auth proxy) and an unprivileged agent user (running Claude Code with a dummy key). It also adds a local DNS allowlist via dnsmasq to prevent DNS-tunneling exfiltration, tightens Claude's tool permissions, and introduces a comprehensive verification harness. The review feedback highlights three important issues: a critical command injection vulnerability in docker/codacy-run.sh due to unvalidated CLI names, a permission bug in docker/entrypoint.sh preventing the agent user from writing to /workspace in the server pipeline, and inconsistent path formats in the claude-settings.json Read deny rules that could lead to security bypasses.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| name="$1"; shift | ||
| if [ -f /run/codacy/codacy.env ]; then | ||
| set -a; . /run/codacy/codacy.env; set +a | ||
| fi | ||
| exec "/usr/local/bin/${name}-real" "$@" |
There was a problem hiding this comment.
Critical Security Vulnerability: Arbitrary Code Execution / Privilege Escalation
The codacy-run.sh script executes /usr/local/bin/${name}-real where name is the first argument passed from the unprivileged agent user via sudo. Because there is no validation or sanitization on name, an attacker can use directory traversal (e.g., ../../workspace/evil) to execute an arbitrary executable located in the writable /workspace directory as the runner user.
This completely bypasses the privilege separation boundary, allowing a compromised agent to run arbitrary commands as runner and exfiltrate the CODACY_API_TOKEN or ANTHROPIC_API_KEY.
Remediation:
Strictly validate that name is either codacy or codacy-analysis before executing it.
| name="$1"; shift | |
| if [ -f /run/codacy/codacy.env ]; then | |
| set -a; . /run/codacy/codacy.env; set +a | |
| fi | |
| exec "/usr/local/bin/${name}-real" "$@" | |
| name="$1"; shift | |
| if [[ "$name" != "codacy" && "$name" != "codacy-analysis" ]]; then | |
| echo "ERROR: Unauthorized CLI name: $name" >&2 | |
| exit 1 | |
| fi | |
| if [ -f /run/codacy/codacy.env ]; then | |
| set -a; . /run/codacy/codacy.env; set +a | |
| fi | |
| exec "/usr/local/bin/${name}-real" "$@" |
There was a problem hiding this comment.
Fixed in 9e-prefix commit: codacy-run.sh now allowlists the CLI name to exactly codacy/codacy-analysis (case statement) and exits non-zero otherwise, so the traversal path you showed is rejected before exec. Verified: sudo -u runner codacy-run ../../workspace/evil → "unauthorized CLI name"; legit codacy still works.
| mkdir -p /workspace/.codacy | ||
| chown runner:codacy /workspace/.codacy 2>/dev/null || true | ||
| chmod 2775 /workspace/.codacy 2>/dev/null || true | ||
| umask 002 |
There was a problem hiding this comment.
High Severity Bug: agent user cannot write to /workspace in server pipeline
In docker/Dockerfile, /workspace is created as the working directory while running as root, meaning it is owned by root:root with 755 permissions.
In server-pipeline.sh, the script runs as the unprivileged agent user and attempts to clone the repository into /workspace. This will fail with a "Permission denied" error because the agent user does not have write permissions to /workspace.
Remediation:
In docker/entrypoint.sh (while still running as root), change the ownership or permissions of /workspace to allow the agent user (or the shared codacy group) to write to it.
| mkdir -p /workspace/.codacy | |
| chown runner:codacy /workspace/.codacy 2>/dev/null || true | |
| chmod 2775 /workspace/.codacy 2>/dev/null || true | |
| umask 002 | |
| mkdir -p /workspace/.codacy | |
| chown agent:codacy /workspace 2>/dev/null || true | |
| chmod 775 /workspace 2>/dev/null || true | |
| chown runner:codacy /workspace/.codacy 2>/dev/null || true | |
| chmod 2775 /workspace/.codacy 2>/dev/null || true | |
| umask 002 |
There was a problem hiding this comment.
Fixed: the entrypoint now chown agent:codacy /workspace + chmod 2775 (setgid) before dropping privilege, so the server-mode git clone (run as agent) can write it. Also stopped pre-creating /workspace/.codacy in the entrypoint — that additionally broke the clone (non-empty target); the skill creates it, and both users share primary group codacy so the handoff still works.
There was a problem hiding this comment.
Follow-up: chowning /workspace to agent (for the server clone) tripped git's dubious-ownership guard when the Codacy CLI auto-detects the repo as runner — broke the skill with "Could not detect repository from git remote". Added git config --system --add safe.directory /workspace so both users' git ops trust the checkout regardless of owner. Verified codacy repo now auto-detects the repo.
| "Read(/home/runner/**)", | ||
| "Read(//run/codacy/**)", | ||
| "Read(//proc/**)", | ||
| "Read(/etc/sudoers.d/**)", |
There was a problem hiding this comment.
Potential Security Bypass: Inconsistent path format in Read deny rules
In claude-settings.json, some deny rules use double slashes (e.g., //run/codacy/** and //proc/**) because Claude Code uses double slashes for absolute paths. However, other rules like /home/runner/** and /etc/sudoers.d/** only use a single slash.
If Claude Code indeed normalizes absolute paths to start with //, the single-slash rules might be ineffective, allowing the agent to read those sensitive directories.
Remediation:
Use the double-slash format consistently for all absolute path deny rules, or include both formats to ensure complete coverage.
| "Read(/home/runner/**)", | |
| "Read(//run/codacy/**)", | |
| "Read(//proc/**)", | |
| "Read(/etc/sudoers.d/**)", | |
| "Read(//home/runner/**)", | |
| "Read(//run/codacy/**)", | |
| "Read(//proc/**)", | |
| "Read(//etc/sudoers.d/**)", |
There was a problem hiding this comment.
Fixed: each secret-path Read deny now lists both the single- and double-slash form (/home/runner/** and //home/runner/**, etc.) so it matches regardless of how Claude Code normalizes absolute paths. Note these denies are defense-in-depth — the load-bearing protection is OS perms (700 runner-owned dirs, distinct uid), which block the agent even if a rule format is off.
…d (OD-78) Untrack the superpowers spec/plan and test logs (dev scaffolding) and gitignore them + .DS_Store. Keep docs/hardening-overview.md tracked (it's the product-facing design overview, already referenced from README). Fix stale superpowers references in CLAUDE.md and the overview footer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lash denies (OD-78) Addresses gemini-code-assist review on PR #3: - codacy-run.sh: allowlist the CLI name to {codacy,codacy-analysis} — the sudo rule permits any args, so an unvalidated name allowed path traversal (../../workspace/evil) to run an arbitrary binary as runner with the token. - entrypoint.sh: chown /workspace agent:codacy + setgid so the server-mode git clone (run as agent) can write it; stop pre-creating /workspace/.codacy (it made server clone fail on a non-empty dir). Shared primary group + umask keep the runner<->agent config handoff working. - claude-settings.json: include both single- and double-slash forms of the secret-path Read denies for coverage regardless of path normalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…detect the repo (OD-78)
Follow-up to the workspace-ownership fix: chowning /workspace to agent
tripped git's dubious-ownership guard when the Codacy CLI (running as
runner) auto-detects the repo from the git remote, breaking the skill
('Could not detect repository from git remote'). Trust /workspace
system-wide so both users' git operations work regardless of owner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OD-78 — Harden the Claude agent in the autoconfig container
Runs the agent least-privilege so a prompt injection from the untrusted
/workspacecannot read or exfiltrate a secret. Security is enforced at the OS/network layer, not by the model's permission policy.Linear: OD-78
Solution is documented here: https://github.com/codacy/autoconfig-setup-container/blob/ae4d65b971a0cb2066b19b64fe29bf866a395255/docs/hardening-overview.md
Local test:
Command:
Agent unable to read env file ✅
Approach
Two OS users in one container:
runner(uid 1001) — holds the Codacy token and runs an Anthropic auth proxy that holds the real API key.agent(uid 1002) — runsclaude -pwith no readable secret. Reaches the Codacy CLIs only through asudo→runnershim; reaches Anthropic only through the local proxy with a dummy token.The entrypoint runs as root: firewall → stage Codacy token in a runner-only file → start proxy as runner →
env -iscrub →exec runuser -u agent.What changed
Dockerfile,codacy-shim.sh,codacy-run.sh) — real CLIs renamed*-real; agent invokes them only asrunner; token loaded from/run/codacy/codacy.env(700, runner-only, outside the persisted volume).anthropic-proxy.js) — real key held byrunner; agent getsANTHROPIC_BASE_URL+ a dummy token.entrypoint.sh) — pre-auth, proxy start, env scrub, drop-priv; setgid/workspace/.codacyfor the runner↔agent config handoff.init-firewall.sh) — local dnsmasq forwards only allowlisted domains (incl.app.dev/app.staging.codacy.org),--ipsetauto-allows resolved IPs, everything else sinkholed to0.0.0.0; upstream reachable by root only. Closes DNS-tunnel exfiltration.claude-settings.json,managed-settings.json) — dropWebFetch/Glob/Grep; scopeRead/Write/Editto/workspace; deny secret paths + network binaries; managed-settings lock (disableBypassPermissionsMode);--permission-mode dontAsk.Bash(*)retained (the skill needs many helpers; OS layer is the boundary).ANTHROPIC_API_KEYrequired.docker/test-hardening.sh) — 12 keyless adversarial probes + opt-incli/e2e.Testing
/procunreadable, shim, proxy 401-on-dummy, DNS sinkhole, summary sanitize, …).configure-codacy-cloudworkflow against a real Codacy repo; the produced summary was secret-free. Three iterations surfaced and fixed real defects (bubblewrap dependency,codacy loginnon-persistence, dnsmasq forwarding, Bash-allowlist). Seedocs/test-results-hardening-2026-06-12.md./run/codacy/codacy.envwas denied (filesystem 700 + Read-deny).Notes / follow-ups
Bash(*)rather than a prefix allowlist — by design; the OS layer is the security boundary.CODACY_API_TOKENis an account-scoped Account API Token and cannot be narrowed; follow-up: ask Codacy whether a narrower token can drive cloud config.🤖 Generated with Claude Code