# Security Audit Report

**Date**: 2026-04-10
**Last updated**: 2026-05-28 (rev 11 — re-confirmed against migration 024 (`024_update_default_model_to_opus_4_8.py`) and the extended-thinking budget increase (10,000 → 32,000 tokens) in `src/agent/session.py`. **No new security findings.** Migration 024 is a pure data migration (parameterized `UPDATE` of the `model` column across `items`/`agent_config`, with a reversible `down()`) — no shell or SQL-construction surface. Raising the thinking budget changes only token allocation, not the agent-containment boundary, the path guard, or the command filter; all prior remediations stand. Rev 10 notes remain accurate: re-confirmed against migrations 022 (`auto_approve`) and 023 (`pause_message`), plus the auto-review agent at `src/agent/review_agent.py`. The auto-review agent runs as a strictly read-only Claude session — its `can_use_tool` allowlist is `Read`/`Glob`/`Grep` only, with `Edit`/`Write`/`Bash` denied via `PermissionResultDeny`, so the review-agent path does not widen the agent-containment surface; cycle bookkeeping (max 3 retries) lives in `WorkflowService` outside the SDK call. The `pause_message` column is plain user text prepended to the resume prompt — no shell or SQL surface added (parameterized writes via the repo). Finding #5 (dynamic SQL column names) remains remediated and the whitelist constants are `_WRITABLE_*_COLUMNS` (private to each repository module — `_WRITABLE_ITEM_COLUMNS` in `repositories/item_repository.py`, `_WRITABLE_EPIC_COLUMNS` in `repositories/epic_repository.py`). The `AgentConfig` Phase 3 refactor is unchanged.)
**Scope**: Full codebase review of Claude Agents Dashboard
**Threat model**: Localhost single-user developer tool. The server binds to `127.0.0.1` only, is operated by the local developer, and is not designed for network exposure or multi-user access. In multi-repo workspace mode the same threat model applies — agents work in a worktree inside one chosen sibling repo and have read-only access to the other siblings via `add_dirs`, with the path guard hook (`path_guard.py`) preventing writes outside the assigned worktree.

---

## Executive Summary

This audit identified **14 security findings** relevant to a localhost developer tool: 2 High, 5 Medium, and 7 Low/Info. Since the initial audit, **all 9 actionable findings have been remediated**, bringing the application's security posture to **strong for its intended use case**.

The two original high-severity findings (command filter bypass and YOLO mode) have both been addressed with robust fixes. All five medium-severity findings have been resolved, including `.browserhidden` support for project-specific secret patterns. Two low-severity findings (epic color validation and security headers) have also been fixed. The remaining open items are informational or accepted-risk.

Traditional web security concerns (authentication, CORS, rate limiting) are largely irrelevant since the server only listens on `127.0.0.1` for a single local user. This audit focuses on the risks that actually matter: **agent containment**, **file system boundary enforcement**, and **defensive coding practices**.

---

## High Severity — Agent Containment

### 1. Command Filter Bypass via Shell Metacharacters

- **Severity**: High
- **Status**: ✅ **REMEDIATED**
- **File**: `src/agent/command_filter.py`
- **Original issue**: The command filter extracted the first word via `command.split()[0]` and checked it against allowed prefixes. An agent could bypass this with shell operators (`;`, `&&`, `||`, `|`, backticks, `$()`).
- **Fix implemented**: The command filter now defines `SHELL_OPERATORS = [";", "&&", "||", "|", ">>", ">", "<", "$(", "\`"]` and calls `_contains_shell_operators()` to reject any command containing shell operators **before** checking the allowed prefix list. Additionally, `shlex.split()` is used for robust parsing with a fallback that denies commands on malformed input.

### 2. Bash YOLO Mode Has No Guardrails

- **Severity**: High
- **Status**: ✅ **REMEDIATED**
- **File**: `src/services/workflow_service.py`
- **Original issue**: When `bash_yolo` was enabled, agents ran with `permission_mode="bypassPermissions"` with no audit trail, no confirmation, and no visibility into what commands were being executed.
- **Fix implemented**: YOLO mode now has comprehensive tracking and logging:
  - Active YOLO items are tracked via `self._yolo_items` set.
  - A `"⚠️ **YOLO mode active**"` warning is logged as a `"yolo_command"` entry type when YOLO mode activates.
  - Bash commands executed under YOLO mode are tagged with `entry_type = "yolo_command"` and prefixed with `⚡` for visual distinction in the work log.
  - `yolo_mode_changed` WebSocket events are broadcast to the UI so the user has real-time visibility.
  - YOLO tracking is properly cleaned up on cancel/complete.

---

## Medium Severity — File System Boundaries

### 3. Symlink Following in File Browser

- **Severity**: Medium
- **Status**: ✅ **REMEDIATED**
- **File**: `src/web/file_routes.py`
- **Original issue**: The file browser tree scanner followed symlinks, allowing browsing outside the project directory.
- **Fix implemented**: The tree scanner now skips symlinks entirely (`if entry.is_symlink(): continue`). Symlinks are displayed as visual indicators with their target path but are never followed or read. The `get_file_content()` endpoint explicitly blocks reading symlinks (returns 403).

### 4. Unvalidated File Paths in Diff Generation

- **Severity**: Medium
- **Status**: ✅ **REMEDIATED**
- **File**: `src/git/operations.py`
- **Original issue**: Untracked filenames from `git ls-files --others` were used directly to read files without validation.
- **Fix implemented**: Each untracked filename now passes through `validate_file_path()` before use. The resolved path is verified to be within the worktree via string prefix and exact equality checks. Exceptions are caught and the file is skipped on error. Both `get_diff()` and `get_changed_files()` apply this validation.

### 5. Dynamic SQL Column Names

- **Severity**: Medium (code quality)
- **Status**: ✅ **REMEDIATED**
- **File**: `src/services/database_service.py`
- **Original issue**: `update_item()` built SQL with f-string column names from dict keys without validation.
- **Fix implemented**: A `_WRITABLE_ITEM_COLUMNS` whitelist is enforced on every item write (renamed from `ALLOWED_ITEM_COLUMNS` in rev 10; private to the module). As of rev 9 the whitelist lives in `repositories/item_repository.py` (moved out of `database_service.py`) and is checked in `update_fields()` / `transition()` before any SQL is built — invalid keys raise `ValueError`. The equivalent epic whitelist (`_WRITABLE_EPIC_COLUMNS`) is enforced in `repositories/epic_repository.py` (the old `ALLOWED_EPIC_COLUMNS` constant in `database_service.py` was deleted because all writes now route through the repo).

### 6. No CORS Middleware

- **Severity**: Medium
- **Status**: ✅ **REMEDIATED**
- **File**: `src/web/app.py`
- **Original issue**: No `CORSMiddleware` was configured, allowing cross-origin attacks from malicious websites.
- **Fix implemented**: CORS middleware is now configured via `_build_cors_origins()`, which restricts allowed origins to `localhost` and `127.0.0.1` across ports 8000–8019. This prevents malicious websites from making cross-origin requests to the local server.

### 7. Secret File Detection Is Pattern-Based

- **Severity**: Medium
- **Status**: ✅ **REMEDIATED**
- **File**: `src/web/file_routes.py` (`is_secret_file()`)
- **Original issue**: The file browser hid files matching known secret patterns (`.env`, `*.key`, `*.pem`, etc.) but files with non-standard names containing secrets were not detected.
- **Fix implemented**: A `.browserhidden` configuration file (similar to `.gitignore`) is now supported. Projects can define custom glob patterns in a `.browserhidden` file at the project root. The implementation includes:
  - `parse_browserhidden()` parses the config file (comments with `#`, one pattern per line).
  - `load_browserhidden_patterns()` caches parsed patterns with mtime-based invalidation.
  - `matches_browserhidden()` matches filenames and relative paths against patterns.
  - `is_secret_file()` checks both built-in patterns and `.browserhidden` patterns.
  - Patterns are loaded and applied throughout the tree scanner, file content reader, and worktree file browser.

---

## Low / Informational Findings

### 8. Agent Work Log May Contain Sensitive Data

- **Severity**: Low
- **Status**: ⚠️ **OPEN** (accepted risk)
- **Files**: `src/services/notification_service.py`
- **Description**: Agent output (command results, file contents, error messages) is logged to the work log without redaction. If an agent reads a `.env` file or a command prints credentials, they appear in the UI and are stored in the SQLite database.
- **Why it's low for localhost**: The local user already has access to these secrets. The concern is that the work log persists them in the database, making them less ephemeral than they would be in a terminal.
- **Recommendation**: Consider adding optional log redaction for common patterns (`sk-...`, `ghp_...`, API key formats) as a nice-to-have.

### 9. Git Output Not Sanitized

- **Severity**: Low
- **Status**: ⚠️ **OPEN** (cosmetic)
- **File**: `src/git/operations.py`
- **Description**: Git stderr from failed operations may contain ANSI escape codes or control characters, which are passed through to the UI.
- **Mitigating factor**: Jinja2 auto-escapes HTML, and `marked.js` handles the rendering. The risk is cosmetic (garbled output), not exploitable.
- **Recommendation**: Strip ANSI codes from git output for cleaner display.

### 10. Epic Color Input Not Validated

- **Severity**: Low
- **Status**: ✅ **REMEDIATED**
- **File**: `src/models.py`
- **Original issue**: `EpicCreate` accepted any `color: str` without validating against the `EPIC_COLORS` whitelist.
- **Fix implemented**: Both `EpicCreate` and `EpicUpdate` now have a `@field_validator("color")` that validates against `VALID_EPIC_COLOR_KEYS` (derived from `EPIC_COLORS` in `constants.py`). Invalid color values raise a `ValueError` listing the valid options (red, orange, amber, green, teal, blue, purple, pink).

### 11. WebSocket X-Forwarded-For Trusted Unconditionally

- **Severity**: Info
- **Status**: ⚠️ **OPEN** (by design for localhost)
- **File**: `src/web/websocket.py`
- **Description**: `_get_client_ip()` trusts the `X-Forwarded-For` header, taking the first comma-separated value. Also checks `X-Real-IP` before falling back to `websocket.client.host`. On localhost without a reverse proxy, this is irrelevant — all connections come from `127.0.0.1`.
- **Recommendation**: No action needed for localhost use. If ever deployed behind a proxy, add trusted proxy validation.

### 12. No Authentication

- **Severity**: Info (for localhost)
- **Status**: ⚠️ **OPEN** (by design)
- **Files**: `src/web/app.py`, `src/web/routes.py`
- **Description**: No authentication on any endpoint. For a single-user localhost tool, this is the correct design — authentication would add friction with no security benefit since the only user is the local developer.
- **Recommendation**: No action needed. If the application is ever exposed to a network, authentication becomes critical.

### 13. HTTP Only (No TLS)

- **Severity**: Info
- **Status**: ⚠️ **OPEN** (by design)
- **File**: `src/config.py`
- **Description**: Plain HTTP on localhost. Since traffic never leaves the loopback interface, TLS provides no benefit.
- **Recommendation**: No action needed for localhost.

### 14. Missing Security Response Headers

- **Severity**: Info
- **Status**: ✅ **REMEDIATED**
- **File**: `src/web/app.py`
- **Original issue**: No security response headers were set on HTTP responses.
- **Fix implemented**: A `SecurityHeadersMiddleware` (Starlette `BaseHTTPMiddleware`) is registered on the app and sets two headers on every response:
  - `X-Content-Type-Options: nosniff` — prevents MIME type sniffing.
  - `X-Frame-Options: DENY` — prevents clickjacking via iframe embedding.

---

## Summary Table

| # | Finding | Severity | Status | Primary Risk |
|---|---------|----------|--------|-------------|
| 1 | Command filter bypass via shell operators | **High** | ✅ Fixed | Agent escapes command restrictions |
| 2 | Bash YOLO mode has no guardrails | **High** | ✅ Fixed | Agent runs unrestricted on host |
| 3 | File browser follows symlinks | Medium | ✅ Fixed | Browsing outside project boundary |
| 4 | Unvalidated paths in diff generation | Medium | ✅ Fixed | Reading files outside worktree |
| 5 | Dynamic SQL column names | Medium | ✅ Fixed | Fragile code pattern |
| 6 | No CORS middleware | Medium | ✅ Fixed | Cross-origin attacks from websites |
| 7 | Pattern-based secret detection | Medium | ✅ Fixed | Non-standard secrets visible |
| 8 | Sensitive data in work log | Low | ⚠️ Open | Secrets persisted in database |
| 9 | Unsanitized git output | Low | ⚠️ Open | Garbled display |
| 10 | Epic color not validated | Low | ✅ Fixed | Missing input validation |
| 11 | X-Forwarded-For trusted | Info | ⚠️ Open | N/A for localhost |
| 12 | No authentication | Info | ⚠️ Open | By design for localhost |
| 13 | HTTP only | Info | ⚠️ Open | By design for localhost |
| 14 | Missing security headers | Info | ✅ Fixed | Minimal risk on localhost |

---

## Remediation Progress

**9 of 9 actionable findings fixed** (100% remediation rate):

| Priority | Finding | Status |
|----------|---------|--------|
| Do Now | #1 Command filter — shell operator rejection + shlex parsing | ✅ Done |
| Do Now | #6 CORS middleware — localhost-only origin restriction | ✅ Done |
| Do Now | #5 SQL column whitelist — `_WRITABLE_ITEM_COLUMNS` validation (renamed from `ALLOWED_ITEM_COLUMNS` in rev 10) | ✅ Done |
| Consider | #3 Symlink skipping — symlinks displayed but not followed | ✅ Done |
| Consider | #4 Diff path validation — validate_file_path() applied | ✅ Done |
| Consider | #2 YOLO mode logging — command tagging + WebSocket events | ✅ Done |
| Consider | #7 `.browserhidden` config — project-specific secret patterns | ✅ Done |
| Low | #10 Epic color validation — Pydantic field_validator on EpicCreate/EpicUpdate | ✅ Done |
| Low | #14 Security headers — SecurityHeadersMiddleware (nosniff + DENY) | ✅ Done |

### Not Needed for Localhost

- Authentication (#12) — correct design for single-user localhost
- TLS (#13) — no network traffic to encrypt
- Rate limiting — no untrusted clients
- Database encryption — OS file permissions are sufficient
- Session ownership — single user

---

## Architecture Strengths

The codebase has several good security practices worth noting:

- **Path traversal protection**: `validate_file_path()` in `operations.py` blocks `..`, absolute paths, null bytes, and control characters. This is well-implemented and consistently applied across diff generation and file browsing.
- **Parameterized SQL values**: All user-supplied values in SQL queries use `?` placeholders — no string interpolation of values. Column names are now also validated against whitelists.
- **Git operation timeouts**: Configurable timeouts prevent hung processes (300s default, 600s for merges).
- **Worktree isolation**: Each agent gets its own git worktree, preventing agents from interfering with each other's work.
- **Secret file hiding**: The file browser proactively hides common secret files (`.env`, keys, certificates) and supports project-specific `.browserhidden` config files for custom patterns, with mtime-based caching for performance.
- **Command access workflow**: The `request_command_access` MCP tool provides a controlled way for agents to ask permission for new commands, with user approval required.
- **Tool filtering**: The `PreToolUse` hook architecture for command filtering, built-in tool filtering, and path guarding is well-designed and extensible.
- **Path guard**: `PreToolUse` hook (`path_guard.py`) prevents agents from editing files outside their assigned worktree, blocking accidental writes to the main repo or other agents' worktrees.
- **Shell operator rejection**: The command filter comprehensively blocks shell metacharacters before checking allowed prefixes, using `shlex.split()` for robust parsing.
- **CORS protection**: Localhost-only origin restriction prevents cross-origin attacks from malicious websites.
- **YOLO mode visibility**: Even in unrestricted mode, all commands are logged and tagged for audit trail visibility.
- **Symlink safety**: File browser displays symlinks as indicators without following them, preventing directory escape.
- **Security response headers**: `SecurityHeadersMiddleware` adds `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` to all responses.
- **Input validation**: Epic colors are validated against a whitelist via Pydantic `field_validator`, preventing arbitrary input.
- **Stale worktree detection**: Background periodic task detects and notifies about orphaned worktrees, with UI-driven cleanup actions.
- **Read-only auto-review agent**: The optional auto-approve workflow (migration 022) spawns a separate Claude session via `src/agent/review_agent.py` to grade the diff before merge. The reviewer is strictly read-only — its `can_use_tool` callback returns `PermissionResultAllow` for `Read`/`Glob`/`Grep` and `PermissionResultDeny` for everything else (no `Edit`/`Write`/`Bash`). Cycle bookkeeping (max 3 retries) lives in `WorkflowService._auto_approve_retries` outside the SDK call, so a malicious or buggy reviewer cannot loop indefinitely.
