-
Notifications
You must be signed in to change notification settings - Fork 0
OD-78: Harden the Claude agent (least-privilege two-user + auth proxy) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
df79bfe
3fbe1f5
eeaa45b
eced7a1
e7dfd2b
af62233
f01a3c8
4116112
f6ed946
40c0663
708bd3d
84c83a3
a495fb4
805a74f
be76971
8bbe7f3
5588401
02c285b
2abe2e2
41b5dd9
80057a7
af4d75b
bd5808b
236802e
177edc0
73f28a2
ae4d65b
8e8c0e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,5 @@ | ||
| CODACY_API_TOKEN= | ||
| ANTHROPIC_API_KEY= | ||
| GEMINI_API_KEY= | ||
|
|
||
| # Defaults to the current directory if not set | ||
| # SOURCE_PATH=/path/to/repo |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # CLAUDE.md | ||
|
|
||
| This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | ||
|
|
||
| ## What This Is | ||
|
|
||
| A Docker image that runs an AI-powered Codacy configuration skill. Claude Code (the `claude` CLI) is the runtime; the container provides a least-privilege sandbox with the right tools and an outbound firewall. The actual configuration logic lives in the **`configure-codacy-cloud` skill**, pulled from [`codacy/codacy-skills`](https://github.com/codacy/codacy-skills) at image build time and baked into `/home/agent/.claude/commands/`. | ||
|
|
||
| The container **does not run local static analysis**. It tunes a repository's Codacy Cloud configuration via Cloud reanalysis only. | ||
|
|
||
| ## Build and Run | ||
|
|
||
| ```bash | ||
| # Build image | ||
| docker compose build | ||
|
|
||
| # Run against the current directory (set SOURCE_PATH to point elsewhere) | ||
| docker compose run --rm codacy-ai | ||
| ``` | ||
|
|
||
| Required env vars in `.env` (copy from `.env.example`): `CODACY_API_TOKEN` + `ANTHROPIC_API_KEY`. `CODACY_API_TOKEN` is a Codacy **Account API Token** (account-scoped — there is no repo-scoped token that can drive cloud config). The mounted `/workspace` must be a git checkout whose `origin` maps to a repo already on Codacy with a finished analysis. | ||
|
|
||
| ## Two Pipelines | ||
|
|
||
| **`local-pipeline.sh`** (default `CMD`): for developers. Mounts `/workspace` from host. Runs `/configure-codacy-cloud` via Claude (Haiku, `--permission-mode dontAsk`). | ||
|
|
||
| **`server-pipeline.sh`**: for the Active Analysis Manager (AAM) in production (k8s). Validates required env vars, clones the repo via `GIT_TOKEN` (then scrubs the token from the remote URL), runs `/configure-codacy-cloud`, sanitizes the summary, and PUT-uploads a JSONL summary to `RESULT_UPLOAD_URL` (presigned S3). Exit code 2 = upload failure; non-zero from skill = skill failure. | ||
|
|
||
| Additional vars required for server pipeline: `GIT_TOKEN`, `CODACY_PROVIDER` (`gh`/`ghe`/`gl`/`gle`/`bb`), `CODACY_ORG_NAME`, `CODACY_REPO_NAME`, `RESULT_UPLOAD_URL`. | ||
|
|
||
| To test server pipeline locally (firewall blocks git providers — skip it with `RUNNING_IN_K8S=true`): | ||
| ```bash | ||
| docker run --rm -it \ | ||
| -v codacy-tool-cache:/home/runner/.codacy \ | ||
| -e RUNNING_IN_K8S=true \ | ||
| -e CODACY_API_TOKEN -e ANTHROPIC_API_KEY -e GIT_TOKEN \ | ||
| -e CODACY_PROVIDER=gh -e CODACY_ORG_NAME=your-org -e CODACY_REPO_NAME=your-repo \ | ||
| -e RESULT_UPLOAD_URL=https://httpbin.org/put \ | ||
| --entrypoint /usr/local/bin/server-pipeline.sh \ | ||
| codacy/autoconfig | ||
| ``` | ||
|
|
||
| ## Security model (OD-78) | ||
|
|
||
| The agent runs least-privilege so a prompt injection from the untrusted `/workspace` cannot steal a secret. Two OS users: | ||
|
|
||
| - **`runner` (uid 1001)** — holds the Codacy credentials (`/home/runner/.codacy`, mode 700) and runs the Anthropic auth proxy (`anthropic-proxy.js`) that holds the real `ANTHROPIC_API_KEY`. | ||
| - **`agent` (uid 1002)** — runs `claude -p`. Its environment contains **no real secret**: `ANTHROPIC_BASE_URL` points at the local proxy with a dummy token; `CODACY_API_TOKEN`/`GIT_TOKEN`/`GEMINI_API_KEY` are unset. It reaches the Codacy CLIs only through `/usr/local/bin/codacy{,-analysis}` shims that `sudo -u runner` the real binaries (renamed `*-real`). | ||
|
|
||
| The entrypoint runs as root: firewall → Codacy login as runner (token via env, never argv) → start proxy as runner → scrub env → `exec runuser -u agent`. Network egress is an iptables IP allowlist **plus** a dnsmasq DNS allowlist (only Anthropic + Codacy resolve; everything else is sinkholed to `0.0.0.0`, and only root may reach the upstream resolver). Claude runs on Haiku with `--permission-mode dontAsk` and a managed-settings lock (`/etc/claude-code/managed-settings.json`). | ||
|
|
||
| Verify with `./docker/test-hardening.sh` (adversarial probes). Probes 1–12 need no live keys; the opt-in `cli` / `e2e` probes need a throwaway Codacy account token + a Codacy-tracked git checkout. Overview: `docs/hardening-overview.md`. | ||
|
|
||
| ## Container Architecture | ||
|
|
||
| **Entrypoint** (`entrypoint.sh`): firewall init (skipped when `RUNNING_IN_K8S=true`) → Codacy login as `runner` → start Anthropic proxy as `runner` → prepare shared setgid `/workspace/.codacy` → scrub env and `exec runuser -u agent -- … "$@"`. | ||
|
|
||
| **Firewall** (`init-firewall.sh`): iptables + ipset IP allowlist (`api.anthropic.com`, `statsig.anthropic.com`, `generativelanguage.googleapis.com`, `oauth2.googleapis.com`, `api.codacy.com`, `app.codacy.com`, `app.dev.codacy.org`, `app.staging.codacy.org`) + a local dnsmasq DNS allowlist for the same domains. Logs blocked connections via `/dev/kmsg`. In k8s, egress is handled by NetworkPolicy instead (firewall skipped). | ||
|
|
||
| **Skills** baked into `/home/agent/.claude/commands/`: `configure-codacy-cloud`, `configure-codacy`, `codacy-analysis-cli`, `codacy-cloud-cli`. The Dockerfile uses `ADD https://api.github.com/.../refs/heads/master` as a cache-buster so `docker build` always fetches the latest skills without `--no-cache`. | ||
|
|
||
| **Installed CLIs** (npm globals): `claude` (`@anthropic-ai/claude-code`), `gemini` (present but unused), `codacy` (`@codacy/codacy-cloud-cli`), `codacy-analysis` (`@codacy/analysis-cli`). Claude permissions in `claude-settings.json` are tightened (no `WebFetch`/`Glob`/`Grep`; `Read`/`Write`/`Edit` scoped to `/workspace`; secret-path + network-binary denies; Bash prefix allowlist). | ||
|
|
||
| **Runtimes** available for tools: Java (default-jdk-headless), Python 3, Ruby, Go 1.26, shellcheck. | ||
|
|
||
| **Volume** `codacy-tool-cache` → `/home/runner/.codacy`: persists downloaded tool binaries and Trivy DB across container runs. | ||
|
|
||
| ## Updating Skills | ||
|
|
||
| Skills are fetched from `codacy-skills` master at build time. To pick up skill changes, rebuild: | ||
| ```bash | ||
| docker compose build | ||
| ``` | ||
| The `ADD` cache-buster in the Dockerfile invalidates the layer when `codacy-skills` master moves. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // Minimal localhost proxy. Holds the real Anthropic API key in THIS process's | ||
| // environment (owned by `runner`) and injects it into every upstream request, | ||
| // overwriting whatever dummy credential the agent sent. The agent (a different | ||
| // UID) cannot read this process's /proc/<pid>/environ, so the key stays secret. | ||
| const http = require('http'); | ||
| const https = require('https'); | ||
|
|
||
| const PORT = parseInt(process.env.ANTHROPIC_PROXY_PORT || '8118', 10); | ||
| const REAL_KEY = process.env.ANTHROPIC_API_KEY; | ||
| const UPSTREAM = 'api.anthropic.com'; | ||
|
|
||
| if (!REAL_KEY) { | ||
| console.error('anthropic-proxy: ANTHROPIC_API_KEY not set; refusing to start'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const server = http.createServer((req, res) => { | ||
| const headers = { ...req.headers, host: UPSTREAM }; | ||
| // Replace any client-supplied auth with the real key. | ||
| delete headers['authorization']; | ||
| headers['x-api-key'] = REAL_KEY; | ||
| headers['anthropic-version'] = headers['anthropic-version'] || '2023-06-01'; | ||
|
|
||
| const upstream = https.request( | ||
| { hostname: UPSTREAM, port: 443, path: req.url, method: req.method, headers }, | ||
| (up) => { res.writeHead(up.statusCode, up.headers); up.pipe(res); } | ||
| ); | ||
| upstream.on('error', (e) => { res.writeHead(502); res.end('proxy error: ' + e.message); }); | ||
| req.pipe(upstream); | ||
| }); | ||
|
|
||
| server.listen(PORT, '127.0.0.1', () => console.error(`anthropic-proxy listening on 127.0.0.1:${PORT}`)); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,20 @@ | ||||||||||||||||||||||||||||||
| #!/usr/bin/env bash | ||||||||||||||||||||||||||||||
| # Runner-side launcher for the Codacy CLIs. Loads the Codacy token from a | ||||||||||||||||||||||||||||||
| # runner-only file into the environment (the CLI reads CODACY_API_TOKEN at | ||||||||||||||||||||||||||||||
| # runtime — no persisted login needed) and execs the real CLI. Invoked as | ||||||||||||||||||||||||||||||
| # `runner` via the sudo shim; the agent (a different uid) cannot read the token | ||||||||||||||||||||||||||||||
| # file (600, runner-owned) nor this process's /proc environ. | ||||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||||
| name="$1"; shift | ||||||||||||||||||||||||||||||
| # Allowlist the CLI name — the agent reaches this via a sudo rule that permits | ||||||||||||||||||||||||||||||
| # any arguments, so without this an attacker could pass a traversal path | ||||||||||||||||||||||||||||||
| # (e.g. ../../workspace/evil) to run an arbitrary binary as `runner` with the | ||||||||||||||||||||||||||||||
| # token loaded. Only the two real Codacy CLIs are permitted. | ||||||||||||||||||||||||||||||
| case "$name" in | ||||||||||||||||||||||||||||||
| codacy|codacy-analysis) ;; | ||||||||||||||||||||||||||||||
| *) echo "codacy-run: unauthorized CLI name '$name'" >&2; exit 1 ;; | ||||||||||||||||||||||||||||||
| esac | ||||||||||||||||||||||||||||||
| if [ -f /run/codacy/codacy.env ]; then | ||||||||||||||||||||||||||||||
| set -a; . /run/codacy/codacy.env; set +a | ||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||
| exec "/usr/local/bin/${name}-real" "$@" | ||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical Security Vulnerability: Arbitrary Code Execution / Privilege Escalation The This completely bypasses the privilege separation boundary, allowing a compromised Remediation:
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9e-prefix commit: |
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.