Skip to content
Merged
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
26 changes: 26 additions & 0 deletions docker/base-image/agent_server/routers/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,29 @@ def _clone_status() -> str:
return "ok"


GUARDRAIL_REGISTRATION = "/etc/claude-code/managed-settings.json"


def _guardrails_registration() -> str:
"""#3105: per-request check that the guardrail hook registration is intact.

startup.sh checks once at boot, but `developer` holds NOPASSWD sudo, so the
file can be removed or loosened later in the container's life. Claude Code
then runs no hooks and logs nothing. Enum only, like `clone_status`.
A sudo rewrite that keeps root:root 0444 still reads "ok"; the bash_deny
sudo patterns are the other half of this control.
"""
try:
st = os.stat(GUARDRAIL_REGISTRATION)
except OSError:
return "missing"
if st.st_uid != 0:
return "not_root_owned"
if os.access(GUARDRAIL_REGISTRATION, os.W_OK):
return "writable"
return "ok"


@router.get("/")
async def root():
"""Root endpoint - no UI, just API info"""
Expand Down Expand Up @@ -191,6 +214,9 @@ async def health_check():
# reporting a running-but-empty agent as healthy. Enum only — no
# agent-controlled strings on this unauthenticated endpoint.
"clone_status": _clone_status(),
# #3105: "ok"|"missing"|"not_root_owned"|"writable" — stat of the
# guardrail registration on every request, not only at boot.
"guardrails_registration": _guardrails_registration(),
"diagnostics": _diagnostics(),
}

Expand Down
8 changes: 8 additions & 0 deletions docker/base-image/hooks/guardrails-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
{
"pattern": "\\bshutdown\\s+-[hr]\\b|\\breboot\\s+-f\\b|\\bpoweroff\\b",
"reason": "host shutdown"
},
{
"pattern": "(?s)\\A(?=.*\\bsudo\\b).*(?:/etc/claude-code|/opt/trinity|/etc/sudoers)|\\bvisudo\\b",
"reason": "sudo touching the guardrail registration, hooks or sudoers"
},
{
"pattern": "\\bsudo(?:\\s+(?:-[ugCDhprtUT](?:\\S+|\\s+\\S+)|--[a-z][\\w-]*(?:=\\S+)?|-(?![ugCDhprtUT])[a-zA-Z]+))*?\\s+(?:-[a-zA-Z]*[is][a-zA-Z]*|--login|--shell)(?=\\s|$)|\\bsudo(?:\\s+(?:-[ugCDhprtUT](?:\\S+|\\s+\\S+)|--[a-z][\\w-]*(?:=\\S+)?|-(?![ugCDhprtUT])[a-zA-Z]+))*?\\s+(?:/\\S*/)?su(?=\\s|$|[;&|)])|\\bsudo(?:\\s+(?:-[ugCDhprtUT](?:\\S+|\\s+\\S+)|--[a-z][\\w-]*(?:=\\S+)?|-(?![ugCDhprtUT])[a-zA-Z]+))*?\\s+(?:/\\S*/)?(?:bash|sh|zsh|dash|ksh|fish)(?=\\s*(?:$|[;&|)]|-[il]\\b|--login\\b))|(?:^|[;&|(])\\s*(?:/\\S*/)?su(?=\\s|$|[;&|)])",
"reason": "interactive root shell"
}
],
"path_deny": [
Expand Down
2 changes: 1 addition & 1 deletion docs/memory/architecture/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ CRED-002 file-injection model (Invariant #12): `.env` (KEY=VALUE source of truth
- **Internal API security (C-003)**: `/api/internal/` endpoints (scheduler, agent containers) require the `X-Internal-Secret` header; falls back to `SECRET_KEY` if `INTERNAL_API_SECRET` unset.
- **Agent-server inbound auth (#1159)** (details in [agent-server-authentication.md](../feature-flows/agent-server-authentication.md)): every backend→agent call carries a per-agent `X-Trinity-Agent-Token` = `HMAC-SHA256(AGENT_AUTH_SECRET, "trinity-agent-auth:v1:"+name)` — *derived*, not stored; the master lives only in backend env, so a compromised agent can't compute a sibling's token. A **pure-ASGI** middleware (`docker/base-image/agent_server/middleware/auth.py`) enforces it on **all** HTTP **and** WS routes via constant-time compare, exempting only exact `/health` (+ `OPTIONS`) — pure-ASGI (not `BaseHTTPMiddleware`) so it gates WS scopes too and never buffers SSE. The dead unauthenticated `/ws/chat` route (ran arbitrary Claude) was removed; CORS dropped (internal-only). Grace path: empty `TRINITY_AGENT_AUTH_TOKEN` → allow (old-image); `check_agent_auth_token_env_matches` forces a one-pass recreate so a missing/stale token re-injects. Backend fail-closed (`derive_agent_token` raises on empty secret; `start.sh` auto-generates the hex32 master, all three compose files forward it (dev, prod, and the #2280 hosted file)). Callers route through `services/agent_auth.py`; a static guard (`tests/unit/test_agent_auth_header_guard.py`) fails any raw `agent-{name}:8000` caller that skips them.
- **WebSocket security (C-002, #550)**: single-use ticket auth — see [Real-time Delivery](integrations.md#real-time-delivery-reliability-003-306).
- **Guardrail registration is root-owned (ent#345)**: the GUARD-002 hooks are registered in `/etc/claude-code/managed-settings.json` — Claude Code's admin-controlled managed-settings path, which takes precedence over user/project settings and sits **outside** the agent's HOME and the git-synced tree. It ships root:root `0444` inside a root-owned `0755` dir, so the file cannot be rewritten and the dir cannot be used to replace it or shadow it via `managed-settings.d`. It used to be `/home/developer/.claude/settings.json`, owned by `developer` at 0644 *inside the synced repo*: the guarded agent could edit its own guardrail registration (one `Edit` disables the bash deny-list, file guard, read-only guard and output-scanner for every later tool call — observed in the wild during #2036), and a push to the agent's repo could set it from outside. No platform-owned `settings.json` is shipped into `~/.claude` any more. The failure mode of the new path is **silent** (no registration ⇒ no hooks, nothing logged), so `startup.sh` asserts existence + non-writability each boot and logs `GUARDRAILS: ERROR …`; it reports and continues, since a registration fault must not become a fleet outage (a `/health` signal is the tracked follow-up). Read-only mode does not conflict: since #887 it writes a config file the baked hook reads, not a second registration.
- **Guardrail registration is root-owned (ent#345)**: the GUARD-002 hooks are registered in `/etc/claude-code/managed-settings.json` — Claude Code's admin-controlled managed-settings path, which takes precedence over user/project settings and sits **outside** the agent's HOME and the git-synced tree. It ships root:root `0444` inside a root-owned `0755` dir, so without `sudo` the file cannot be rewritten and the dir cannot be used to replace it or shadow it via `managed-settings.d`. `developer` holds `NOPASSWD:ALL`, so `sudo` can still rewrite it (see the root-ownership note below); `bash_deny` denies the obvious spellings (any `sudo` command naming `/etc/claude-code`, `/opt/trinity` or `/etc/sudoers`, `visudo`, and root shells via `sudo -i`/`-s`/`su`/bare shell, #3105). That is a speed bump against a determined agent. It used to be `/home/developer/.claude/settings.json`, owned by `developer` at 0644 *inside the synced repo*: the guarded agent could edit its own guardrail registration (one `Edit` disables the bash deny-list, file guard, read-only guard and output-scanner for every later tool call — observed in the wild during #2036), and a push to the agent's repo could set it from outside. No platform-owned `settings.json` is shipped into `~/.claude` any more. The failure mode of the new path is **silent** (no registration ⇒ no hooks, nothing logged), so `startup.sh` asserts existence + non-writability each boot and logs `GUARDRAILS: ERROR …`; it reports and continues, since a registration fault must not become a fleet outage. The agent's `/health` re-stats the file on every request and reports `guardrails_registration` (`ok`/`missing`/`not_root_owned`/`writable`, enum only, #3105); a `sudo` rewrite that keeps root:root `0444` still reads `ok`. Read-only mode does not conflict: since #887 it writes a config file the baked hook reads, not a second registration.
- **Git credentials never enter a remote URL (ent#615)**: a remote Trinity persists carries **no userinfo**; the credential is resolved per operation by the `trinity` **git credential helper** (`/usr/local/bin/git-credential-trinity`, registered in `/etc/gitconfig`), which git speaks to over stdin — a channel that never reaches argv and is never persisted. Before this, every agent's remote was `<scheme>://oauth2:<PAT>@<host>/…`, so the fleet-wide PAT was at rest in `.git/config` on the workspace volume AND expanded into `git-remote-https`'s argv on every fetch/push (including the 60s sync-health poll), from where `ps` → the agent server's reaped-cmdline logging → Vector → the host log files → the logs API carried it into **another agent's LLM context**; ent#292 closed the last hop of that chain and was rated P0. Three properties are load-bearing and each cost a design iteration: the helper is registered as **`trinity`**, never as its filename (git prepends `git-credential-` to any non-absolute value, so the filename resolves to a command that does not exist and the helper silently never runs — a fleet-wide fetch/push outage that no source-only CI can see); it is registered **unscoped** and matches protocol+host **exactly, port-inclusive**, inside itself, because `TRINITY_GIT_BASE_URL` is a *runtime* value a build-time `credential.<base>.helper` could not know (a suffix match here would instead be an exfiltration primitive); and its ladder is **`.env` → baked `Config.Env` → `/home/developer/.trinity/git-credential`**, the INVERSE of `startup.sh`'s, because `startup.sh` runs at boot where baked env is the freshly-recreated truth while the helper runs in steady state where `Config.Env` is immutable without a recreate and `.env` carries the live-injected token — baked-env-first would make a global rotation (#1967) a silent no-op until the old token is revoked. **Root ownership is neither confidentiality nor a boundary**: `GITHUB_PAT` remains in the agent's env and `.env` and invoking the helper returns it, and `developer` holds `NOPASSWD:ALL`, so an agent that wants to rewrite `/usr/local/bin/git-credential-trinity` or `/etc/gitconfig` can `sudo` and do it. What root ownership buys is that nothing rewrites them BY ACCIDENT (an errant `pip install`, a template hook, a skill writing to `$HOME`) — do not build a guarantee on it. The structural fix is a broker outside the container (ent#558, AAuth). Standing rule for every path that touches a remote: **never strip a credential you have not already replaced** — `git_service.scrub_git_remote_tokens` proves the helper resolves (by **exit code**; `git credential fill` prints the credential on stdout and the sweep's output crosses the `docker exec` boundary into the platform log) before it strips anything, and refuses-and-reports otherwise. **The report must be earned**: both config writes are `|| true`, so a container whose exec can read but not write (`agent_full_capabilities=false` withholds `DAC_OVERRIDE`, and the home is 0700 `developer`-owned) silently kept its token while the counter was incremented anyway — the sweep now re-reads and counts only what is really gone. For the same reason it reports `root_readable`: all-zeros is ALSO what a clean agent reports, so without that flag *“could not even look”* is indistinguishable from success on exactly the hardened installs that act on it (the #1638 class). Requirements: `security.md` §20.9b, `github.md` §11.16.
- **Frontend XSS (H-005)**: all markdown rendering uses DOMPurify via `utils/markdown.js`; no direct `v-html` with unsanitized content.
- **Rate limiting (#1023)**: shared sliding-window limiter `services/rate_limiter.py` — Redis sorted-set rolling window (no fixed-window boundary burst), fail-open with bounded per-worker in-process fallback; `enforce(key, limit, window)` raises 429 + `Retry-After`, `check(key, limit, window)` is the non-raising variant for background loops. New request-rate limits reuse this primitive — don't hand-roll Redis counters. Current consumers: webhook trigger (#1023), agent reports (#918), operator-queue create ingestion (per-agent + fleet, `check`, #1632). Intentionally NOT unified under it: the auth login/OTP limiters in `routers/auth.py` are failure-counters (increment on failure, reset on success) — a different pattern. A global ASGI middleware with a route→policy table is a tracked follow-up.
Expand Down
6 changes: 3 additions & 3 deletions docs/memory/feature-flows/agent-guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ Runs as **root** via `sudo` from `startup.sh`. Reads baseline + `$AGENT_GUARDRAI

Immutable, root-owned `0444`. Contents:

- **`bash_deny`** (10 regex+reason rules): recursive root/home delete, `chmod 777`, pipe-to-shell, git force-push, `kill -9 1`, raw `dd of=/dev/sd…`, `mkfs.*`, fork bomb, read-only-config tamper, host shutdown/reboot/poweroff.
- **`bash_deny`** (12 regex+reason rules): recursive root/home delete, `chmod 777`, pipe-to-shell, git force-push, `kill -9 1`, raw `dd of=/dev/sd…`, `mkfs.*`, fork bomb, read-only-config tamper, host shutdown/reboot/poweroff, `sudo` naming `/etc/claude-code`/`/opt/trinity`/`/etc/sudoers` or `visudo`, root shells (`sudo -i`/`-s`/`--login`/`--shell`, `sudo su`, `sudo <shell>` with no script, bare `su`) (#3105).
- **`path_deny`** (12 globs): `.env`, `.env.*`, `.mcp.json`, `.credentials.enc`, `~/.ssh/*`, `~/.aws/*`, `~/.gcp/*`, `~/.claude/settings.json`, `~/.claude/settings.local.json`, `~/.trinity/read-only-config.json`, `/opt/trinity/*`, `/etc/claude-code/*`.
- **`credential_patterns`** (9): Anthropic key/OAuth, OpenAI, GitHub PAT (classic + fine-grained), AWS access key, Slack bot/user token, Google API key.
- **Budgets**: `max_turns_chat: 50`, `max_turns_task: 50`, `execution_timeout_sec: 1800`.
Expand Down Expand Up @@ -304,8 +304,8 @@ See [read-only-mode.md](read-only-mode.md).
## Security Properties

1. **Baseline immutable** — image-baked, root-owned `0444`; agent cannot read-modify-write it.
2. **Runtime config root-generated** — written by `sudo` at boot, `0444`; agent process cannot rewrite it post-start.
3. **Hooks tamper-proof** — in `/opt/trinity/*` (path-denied) and registered in `~/.claude/settings.json` (also path-denied).
2. **Runtime config root-generated** — written by `sudo` at boot, `0444`; agent process cannot rewrite it post-start without `sudo` (`developer` holds `NOPASSWD:ALL`; `bash_deny` refuses the obvious `sudo` spellings, #3105).
3. **Hooks tamper-resistant** — in `/opt/trinity/*` (path-denied) and registered in root-owned `/etc/claude-code/managed-settings.json` (also path-denied; ent#345). `sudo` can still rewrite both; `bash_deny` refuses the obvious spellings and agent `/health` reports `guardrails_registration` per request (#3105).
4. **Override scope minimal** — numeric caps + literal substrings only; regex and credential scanner are platform-owned.
5. **Fail-closed everywhere** — bad stdin, bad config, or any uncaught hook exception → deny (exit 2).
6. **No value leakage** — credential scanner logs pattern *names*, never matched secrets.
Expand Down
23 changes: 16 additions & 7 deletions docs/memory/requirements/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@
- **Requirement ID**: GUARD-001
- **Priority**: HIGH
- **Description**: Deterministic safety guardrails for autonomous agent execution. Prevents costly mistakes (destructive commands, credential leaks, runaway loops, unauthorized network access) through layered enforcement baked into the base image and agent-server.py — not relying on model compliance alone.
- **Design Principle**: Trinity controls the base image, the agent server, and the deployment pipeline. Guardrails are injected infrastructure-level, not advisory. Agents cannot opt out.
- **Design Principle**: Trinity controls the base image, the agent server, and the deployment pipeline. Guardrails are injected infrastructure-level, not advisory. Agents cannot opt out without `sudo`. The agent user holds `NOPASSWD:ALL` by design, so a `sudo` write can remove the registration; the Bash deny-list refuses the obvious spellings and `/health` reports the registration state per request (28.2.1, #3105).

### 28.2 Claude Code Hooks Injection (GUARD-002)
- **Status**: ✅ Implemented (#140)
Expand Down Expand Up @@ -681,26 +681,35 @@
admin-controlled managed-settings path, which takes precedence over user and
project settings and sits outside the synced tree, closing the self-edit and
inbound-git vectors together. Root-owned `0444` inside a root-owned `0755`
directory: the file cannot be rewritten and the directory cannot be used to
directory: without `sudo` the file cannot be rewritten and the directory cannot be used to
replace it or shadow it with a `managed-settings.d` drop-in. No platform-owned
`settings.json` is shipped into `~/.claude` at all any more, so there is nothing
there to edit away.
- **Fail-open is the risk this creates, and it is handled**: if the registration is
missing or writable, Claude Code simply runs no hooks — silently. `startup.sh`
asserts both properties on every boot and logs `GUARDRAILS: ERROR …` (Vector
captures it); it reports and continues rather than refusing to boot, so a
registration problem cannot become a fleet outage. An operator-visible signal on
`/health` (the `clone_status` pattern, #1439) is the tracked follow-up.
registration problem cannot become a fleet outage. The agent `/health` stats the
file on every request and reports `guardrails_registration`
(`ok`/`missing`/`not_root_owned`/`writable`, enum only, the `clone_status`
pattern from #1439), so a change after boot is visible (#3105).
- **`sudo` route (#3105)**: `developer` holds `NOPASSWD:ALL` by design, so root
ownership stops accidental rewrites only. `bash_deny` refuses any `sudo` command
naming `/etc/claude-code`, `/opt/trinity` or `/etc/sudoers`, `visudo`, and root
shells (`sudo -i`/`-s`/`--login`/`--shell`, `sudo su`, `sudo <shell>` with no
script, bare `su`). A regex is a speed bump: variables, encodings or a script
file get past it. A `sudo` rewrite that keeps root:root `0444` reads `ok` on
`/health`.
- **Interaction checked**: read-only mode (#887) no longer registers a hook of its
own — it writes `~/.trinity/read-only-config.json`, which the baked
`read-only-guard.py` reads — so there is exactly one live registration and the
managed file cannot clobber a runtime-written one.
`read_only._remove_legacy_settings_hook` still cleans up pre-#887 leftovers.
- **Both paths stay in the three write-deny lists**
(`_FILE_WRITE_DENY_PATTERNS` / `guardrails-baseline.json::path_deny` /
`EDIT_PROTECTED_PATHS`) as defence in depth, not as the primary control. Note
`bash-guardrail.py` does **not** consult `path_deny`, so before this change the
Bash route to the registration was open even though the Edit route was denied.
`EDIT_PROTECTED_PATHS`) as defence in depth, not as the primary control. `bash-guardrail.py` does
**not** consult `path_deny`; the Bash route is covered by the `bash_deny` `sudo`
patterns (#3105).
- **Legacy in-tree copy**: `~/.claude/settings.json` sits on the **durable home
volume**, so rebuilding the image does not remove it from an existing agent —
leaving a second registration (precedence-dependent) and a live #2036 leak
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"version": "1.1",
"date": "2026-09-30",
"mode": "diff",
"scope": "all",
"confidence_gate": 8,
"branch": "feature/3105-sudo-guardrail-deny",
"issue": "abilityai/trinity#3105",
"phases_run": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14
],
"attack_surface": {
"bash_hook_patterns": 2,
"health_fields": 1
},
"findings": [],
"fixed_during_audit": [
{
"severity": "HIGH",
"confidence": 8,
"status": "VERIFIED",
"category": "guardrail-bypass",
"title": "bash_deny regex backtracking outlasts the hook timeout",
"file": "docker/base-image/hooks/guardrails-baseline.json",
"test": "tests/unit/test_3105_sudo_guardrail_deny.py::test_patterns_do_not_backtrack"
}
],
"appendix": [
"/health guardrails_registration enum visible to agent-network peers",
"regex evasion via variables/encoding is a documented limit"
],
"supply_chain_summary": "unchanged",
"filter_stats": {
"candidates": 4,
"reported": 0,
"fixed": 1
},
"totals": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
},
"trend": "n/a (diff)"
}
Loading
Loading