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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ Projects can be stored in any directory (registered in `~/.autoforge/registry.db
- `.autoforge/features.db` - SQLite database with feature test cases
- `.autoforge/.agent.lock` - Lock file to prevent multiple agent instances
- `.autoforge/allowed_commands.yaml` - Project-specific bash command allowlist (optional)
- `.autoforge/denied_commands.jsonl` - Log of bash commands blocked by the security hook (gitignored)
- `.autoforge/.gitignore` - Ignores runtime files
- `.claude/skills/playwright-cli/` - Playwright CLI skill for browser automation
- `.playwright/cli.config.json` - Browser configuration (headless, viewport, etc.)
Expand Down Expand Up @@ -393,6 +394,11 @@ blocked_commands:
- Blocklisted commands (sudo, dd, shutdown, etc.) can NEVER be allowed
- Org-level blocked commands cannot be overridden by project configs

**Denied Command Log:**
- Every blocked Bash command is appended to `.autoforge/denied_commands.jsonl` (timestamp, command, full command, reason) so users can review it and extend their allowlists
- Rotates to `denied_commands.jsonl.1` above ~1 MB; gitignored
- API: `GET /api/projects/{name}/denied-commands` (aggregated by command + recent entries), `DELETE` to clear

**Files:**
- `security.py` - Command validation logic and hardcoded blocklist
- `test_security.py` - Unit tests for security system
Expand Down
12 changes: 12 additions & 0 deletions autoforge_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
.claude_settings.expand.*.json
.progress_cache
.migration_version
denied_commands.jsonl
denied_commands.jsonl.1
"""


Expand Down Expand Up @@ -178,6 +180,16 @@ def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path:
return project_dir / ".autoforge" / f".claude_settings.expand.{uuid_hex}.json"


def get_denied_commands_log_path(project_dir: Path) -> Path:
"""Return the path to ``denied_commands.jsonl``.

The security hook appends one JSON line per blocked Bash command so users
can review what the agent tried to run and extend their allowlists.
Always stored in ``.autoforge/``.
"""
return project_dir / ".autoforge" / "denied_commands.jsonl"


# ---------------------------------------------------------------------------
# Lock-file safety check
# ---------------------------------------------------------------------------
Expand Down
26 changes: 25 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This directory contains example configuration files for controlling which bash c
- [Pattern Matching](#pattern-matching)
- [Common Use Cases](#common-use-cases)
- [Security Best Practices](#security-best-practices)
- [Reviewing Denied Commands](#reviewing-denied-commands)

---

Expand Down Expand Up @@ -388,11 +389,34 @@ These commands are **NEVER allowed**, even with user approval:

---

## Reviewing Denied Commands

Every Bash command the security hook blocks is recorded in
`{project}/.autoforge/denied_commands.jsonl` (one JSON object per line), so you can
review afterwards which tools the agent wanted (e.g. `pytest`, `openssl`) and add the
safe ones to `allowed_commands.yaml`:

```json
{"timestamp": "2026-01-01T12:00:00+00:00", "command": "pytest", "full_command": "pytest -q tests/", "reason": "Command 'pytest' is not allowed."}
```

- `command` is the specific command that caused the block; `full_command` is the whole command line (truncated to 2000 chars)
- The log is gitignored and rotates to `denied_commands.jsonl.1` once it exceeds ~1 MB
- With the server running, `GET /api/projects/{name}/denied-commands` returns the commands aggregated by name (count, last seen, example) plus the most recent entries; `DELETE` on the same URL clears the log

Quick summary from the shell:

```bash
jq -r .command .autoforge/denied_commands.jsonl | sort | uniq -c | sort -rn
```

---

## Troubleshooting

### Error: "Command 'X' is not allowed"

**Solution:** Add the command to your project config:
**Solution:** Add the command to your project config (see [Reviewing Denied Commands](#reviewing-denied-commands) to find every command that was blocked):
```yaml
# In .autoforge/allowed_commands.yaml
commands:
Expand Down
151 changes: 136 additions & 15 deletions security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
Uses an allowlist approach - only explicitly permitted commands can run.
"""

import json
import logging
import os
import re
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -893,6 +895,112 @@ def is_command_allowed(command: str, allowed_commands: set[str]) -> bool:
return False


# Rotate the denied-commands log once it grows beyond this size
DENIED_COMMANDS_LOG_MAX_BYTES = 1_000_000
# Truncate stored command strings to keep log entries small
DENIED_COMMANDS_MAX_COMMAND_CHARS = 2000


def record_denied_command(
project_dir: Optional[Path],
command: str,
denied_command: Optional[str],
reason: str,
) -> None:
"""
Append a blocked Bash command to the project's denied-commands log.

The log (``.autoforge/denied_commands.jsonl``) lets users review which
commands the agent tried to run so they can extend their allowlists.
Failures are logged and swallowed: recording must never affect the
security decision itself.

Args:
project_dir: Project directory, or None (nothing is recorded)
command: The full command string the agent attempted
denied_command: The specific command that caused the block, if known
reason: The block reason returned to the agent
"""
if project_dir is None:
return

from autoforge_paths import get_denied_commands_log_path

entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"command": denied_command,
"full_command": command[:DENIED_COMMANDS_MAX_COMMAND_CHARS],
"reason": reason.splitlines()[0] if reason else "",
}

try:
log_path = get_denied_commands_log_path(project_dir)
log_path.parent.mkdir(parents=True, exist_ok=True)
if log_path.exists() and log_path.stat().st_size > DENIED_COMMANDS_LOG_MAX_BYTES:
log_path.replace(log_path.with_name(log_path.name + ".1"))
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except OSError as e:
logger.warning(f"Failed to record denied command for {project_dir}: {e}")


def load_denied_commands(project_dir: Path) -> list[dict]:
"""
Load all recorded denied-command entries for a project (oldest first).

Includes the rotated ``.1`` log if present. Malformed lines are skipped.
"""
from autoforge_paths import get_denied_commands_log_path

log_path = get_denied_commands_log_path(project_dir)
entries: list[dict] = []
for path in (log_path.with_name(log_path.name + ".1"), log_path):
if not path.exists():
continue
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as e:
logger.warning(f"Failed to read denied commands log {path}: {e}")
continue
for line in lines:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(entry, dict):
entries.append(entry)
return entries


def summarize_denied_commands(entries: list[dict]) -> list[dict]:
"""
Aggregate denied-command entries by command name.

Returns:
List of dicts with ``command``, ``count``, ``last_seen`` and
``example`` (most recent full command), sorted by count descending.
"""
summary: dict[str, dict] = {}
for entry in entries:
name = entry.get("command") or "(unparseable)"
item = summary.setdefault(name, {"command": name, "count": 0, "last_seen": "", "example": ""})
item["count"] += 1
timestamp = entry.get("timestamp") or ""
if timestamp >= item["last_seen"]:
item["last_seen"] = timestamp
item["example"] = entry.get("full_command") or ""
return sorted(summary.values(), key=lambda i: (-i["count"], i["command"]))


def clear_denied_commands(project_dir: Path) -> None:
"""Delete the project's denied-commands log (including rotated log)."""
from autoforge_paths import get_denied_commands_log_path

log_path = get_denied_commands_log_path(project_dir)
for path in (log_path, log_path.with_name(log_path.name + ".1")):
path.unlink(missing_ok=True)


async def bash_security_hook(input_data, tool_use_id=None, context=None):
"""
Pre-tool-use hook that validates bash commands using an allowlist.
Expand All @@ -914,6 +1022,26 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
if not command:
return {}

# Get project directory from context
project_dir = None
if context and isinstance(context, dict):
project_dir_str = context.get("project_dir")
if project_dir_str:
project_dir = Path(project_dir_str)

result, denied_command = _validate_bash_command(command, project_dir)
if result.get("decision") == "block":
record_denied_command(project_dir, command, denied_command, result.get("reason", ""))
return result


def _validate_bash_command(command: str, project_dir: Optional[Path]) -> tuple[dict, Optional[str]]:
"""
Validate a Bash command string against the effective allow/block lists.

Returns:
Tuple of (hook result, name of the command that caused a block or None)
"""
# Extract all commands from the command string
commands = extract_commands(command)

Expand All @@ -922,14 +1050,7 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
return {
"decision": "block",
"reason": f"Could not parse command for security validation: {command}",
}

# Get project directory from context
project_dir = None
if context and isinstance(context, dict):
project_dir_str = context.get("project_dir")
if project_dir_str:
project_dir = Path(project_dir_str)
}, None

# Get effective commands using hierarchy resolution
allowed_commands, blocked_commands = get_effective_commands(project_dir)
Expand All @@ -947,7 +1068,7 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
return {
"decision": "block",
"reason": f"Command '{cmd}' is blocked at organization level and cannot be approved.",
}
}, cmd

# Check allowlist (with pattern matching)
if not is_command_allowed(cmd, allowed_commands):
Expand All @@ -960,7 +1081,7 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
return {
"decision": "block",
"reason": error_msg,
}
}, cmd

# Additional validation for sensitive commands
if cmd in COMMANDS_NEEDING_EXTRA_VALIDATION:
Expand All @@ -979,18 +1100,18 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
extra_procs = pkill_processes - DEFAULT_PKILL_PROCESSES
allowed, reason = validate_pkill_command(cmd_segment, extra_procs if extra_procs else None)
if not allowed:
return {"decision": "block", "reason": reason}
return {"decision": "block", "reason": reason}, cmd
elif cmd == "chmod":
allowed, reason = validate_chmod_command(cmd_segment)
if not allowed:
return {"decision": "block", "reason": reason}
return {"decision": "block", "reason": reason}, cmd
elif cmd == "init.sh":
allowed, reason = validate_init_script(cmd_segment)
if not allowed:
return {"decision": "block", "reason": reason}
return {"decision": "block", "reason": reason}, cmd
elif cmd == "playwright-cli":
allowed, reason = validate_playwright_command(cmd_segment)
if not allowed:
return {"decision": "block", "reason": reason}
return {"decision": "block", "reason": reason}, cmd

return {}
return {}, None
62 changes: 62 additions & 0 deletions server/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from fastapi import APIRouter, HTTPException

from ..schemas import (
DeniedCommandEntry,
DeniedCommandsResponse,
DeniedCommandSummary,
ProjectCreate,
ProjectDetail,
ProjectPrompts,
Expand Down Expand Up @@ -394,6 +397,65 @@ async def get_project_stats_endpoint(name: str):
return get_project_stats(project_dir)


# Maximum number of individual denied-command entries returned by the API
DENIED_COMMANDS_RECENT_LIMIT = 100


def _get_existing_project_dir(name: str) -> Path:
"""Resolve a registered project's directory or raise 404."""
(_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()

name = validate_project_name(name)
project_dir = get_project_path(name)

if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")

if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")

return Path(project_dir)


@router.get("/{name}/denied-commands", response_model=DeniedCommandsResponse)
async def get_denied_commands(name: str):
"""List Bash commands the agent tried to run but were blocked by the security hook."""
_init_imports()
from security import load_denied_commands, summarize_denied_commands

project_dir = _get_existing_project_dir(name)
entries = load_denied_commands(project_dir)

return DeniedCommandsResponse(
total=len(entries),
commands=[DeniedCommandSummary(**item) for item in summarize_denied_commands(entries)],
recent=[
DeniedCommandEntry(
timestamp=str(e.get("timestamp") or ""),
command=e.get("command"),
full_command=str(e.get("full_command") or ""),
reason=str(e.get("reason") or ""),
)
for e in reversed(entries[-DENIED_COMMANDS_RECENT_LIMIT:])
],
)


@router.delete("/{name}/denied-commands")
async def clear_denied_commands_endpoint(name: str):
"""Clear the denied-commands log for a project."""
_init_imports()
from security import clear_denied_commands

project_dir = _get_existing_project_dir(name)
try:
clear_denied_commands(project_dir)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to clear denied commands: {e}")

return {"success": True, "message": "Denied commands cleared"}


@router.post("/{name}/reset")
async def reset_project(name: str, full_reset: bool = False):
"""
Expand Down
Loading