From 20ce097bcf55686a9036078a833dab3916c18dcd Mon Sep 17 00:00:00 2001 From: Riyan Dhiman Date: Sat, 28 Mar 2026 03:13:47 +0530 Subject: [PATCH] docs and observability --- .gitignore | 4 +- CONTRIBUTING.md | 81 ++++++ README.md | 383 +++++-------------------- agsec/audit/store.py | 45 ++- agsec/cli/commands/audit.py | 21 +- agsec/cli/commands/check.py | 18 +- agsec/cli/commands/init.py | 25 +- agsec/cli/commands/install.py | 10 +- agsec/cli/commands/mode.py | 39 +++ agsec/cli/config.py | 62 ++++ agsec/cli/main.py | 4 +- agsec/control.py | 13 + agsec/integrations/_base.py | 5 + agsec/templates/policies/03_files.yaml | 10 + docs/cli.md | 80 ++++++ docs/integrations.md | 129 +++++++++ docs/observe-mode.md | 72 +++++ docs/policies.md | 108 +++++++ docs/sdk.md | 135 +++++++++ future_security_items.md | 53 ++++ 20 files changed, 960 insertions(+), 337 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 agsec/cli/commands/mode.py create mode 100644 docs/cli.md create mode 100644 docs/integrations.md create mode 100644 docs/observe-mode.md create mode 100644 docs/policies.md create mode 100644 docs/sdk.md create mode 100644 future_security_items.md diff --git a/.gitignore b/.gitignore index 5b59356..2c191ef 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,6 @@ mcp-servers/ .env .mcp.json - #docs -*.md \ No newline at end of file +*.txt +lp.html \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..836bd9f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing to agsec + +## Setup + +```bash +git clone https://github.com/riyandhiman14/Agent-Sec.git +cd agsec +pip install -e ".[dev]" +``` + +## Running Tests + +```bash +pytest # all tests +pytest tests/ -q # quiet mode +pytest tests/ -v # verbose +pytest tests/test_cli_check.py # specific file +``` + +## Code Style + +The project uses: +- **Black** for formatting (line length 88) +- **isort** for import sorting +- **flake8** for linting + +Run before committing: +```bash +black agsec/ tests/ +isort agsec/ tests/ +flake8 agsec/ tests/ +``` + +## Project Structure + +``` +agsec/ + types.py # Core types (PolicyStatus, PolicyResult) + control.py # ControlLayer orchestrator + registry.py # Action registry + guard.py # Generic @guard decorator + policy/ # Policy engine + engine.py # PolicyEngine (IAM evaluation) + statement.py # Statement dataclass + conditions.py # Operators, matching + resolvers.py # Deep value resolution + loaders.py # YAML parsing + audit/ + store.py # SQLite audit logging + exceptions/ # Structured exception hierarchy + integrations/ + _base.py # Shared PolicyChecker + conditions.py # Fluent API (param, allow, deny, review) + langchain.py # LangChain integration + openai.py # OpenAI SDK integration + anthropic.py # Anthropic SDK integration + cli/ + main.py # CLI entrypoint + mapping.py # Tool name -> action mapping + config.py # Policy/audit/config discovery + commands/ # CLI subcommands + templates/ + policies/ # Default policy files +``` + +## Adding a New Integration + +1. Create `agsec/integrations/your_framework.py` +2. Use `PolicyChecker` from `_base.py` for policy evaluation +3. Use `ToolRule`, `allow`, `deny`, `review`, `param` from `conditions.py` for fluent API +4. Add optional dependency in `pyproject.toml` +5. Write tests in `tests/test_integrations_your_framework.py` +6. Document in `docs/integrations.md` + +## Pull Request Process + +1. Fork and create a feature branch +2. Write tests for new functionality +3. Ensure all tests pass (`pytest`) +4. Update docs if needed +5. Submit PR against `main` diff --git a/README.md b/README.md index 83dd2b5..7d05940 100644 --- a/README.md +++ b/README.md @@ -4,361 +4,134 @@ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -**An action firewall for AI agents.** Before an agent can do anything in the real world, it must pass through agsec. +**Action firewall for AI agents.** Before an agent can do anything, it passes through agsec. ``` Agent wants to act --> agsec evaluates policy --> allow / block / review --> real world ``` ---- +## Why -## The Problem - -AI agents interact with the real world — shell commands, file writes, API calls, payments. Each platform handles safety differently: some have permission prompts, some have sandboxes, some have nothing. But none offer: - -- **Declarative, auditable policies** — like AWS IAM, but for agent actions -- **Consistent rules across platforms** — same policies for Claude Code, Codex, or your custom agent -- **Granular control** — not just "allow bash" or "block bash", but "block bash commands matching this pattern when this condition is true" -- **Audit trail** — who did what, when, and what policy applied - -Built-in controls are binary (allow/block) and platform-specific. Teams running agents in production need policy-as-code with full visibility. - -## The Solution - -agsec is an **IAM-style policy engine** for agent actions. Define what's allowed, what's blocked, and what needs human review — in YAML files, like AWS IAM policies. - -```yaml -# policies/02_bash.yaml -version: "1.0" -default: deny - -statements: - - sid: "BlockFileDelete" - effect: deny - actions: ["bash.execute"] - conditions: - params.command: - op: "regex" - value: "\\brm\\s" - reason: "Agents should not delete files" - - - sid: "AllowBash" - effect: allow - actions: ["bash.execute"] -``` - -**Deny always wins.** Just like IAM. - ---- +AI agents get real access to real systems. agsec gives you one policy layer across all of them — declarative YAML policies, runtime enforcement, full audit trail. Like AWS IAM, but for what agents can do. ## Quick Start -### Install - ```bash pip install agsec +agsec init # create default policies +agsec install claude-code # activate firewall ``` -### 1. Initialize policies +Done. Every tool call is now checked. `rm -rf` blocked, `.env` writes blocked, force push blocked — out of the box. -```bash -agsec init -``` +### Start in Observe Mode -Creates a `policies/` directory with 5 default safety policies: - -``` -policies/ - 01_base.yaml # Default deny, allow reads - 02_bash.yaml # Block rm, DROP TABLE, secret access - 03_files.yaml # Block writes to .env, system dirs - 04_web.yaml # Review external HTTP requests - 05_git.yaml # Block force push, protected branches -``` - -### 2. Hook into your agent +Not ready to block? Audit everything first, block nothing: ```bash -# Claude Code -agsec install claude-code - -# OpenAI Codex -agsec install codex +agsec init --observe # log only, no blocking +agsec audit --stats # see what would be blocked +agsec enforce # start blocking when ready ``` -That's it. The firewall is active. Every tool call is checked against your policies. +## Integrations -### 3. Manage policies +### Claude Code / Codex (hooks) ```bash -# List all active policies -agsec policy list - -# Add a new policy interactively -agsec policy add - -# Remove a policy -agsec policy remove BlockFileDelete - -# Validate policy files -agsec validate -``` - -### 4. View audit logs - -```bash -# Recent actions -agsec audit - -# Summary stats -agsec audit --stats -``` - ---- - -## How It Works - -### Runtime Enforcement - -agsec integrates at the **runtime level** of supported agent platforms. Every action the agent attempts — shell commands, file writes, web requests, API calls — is intercepted and evaluated against your policies *before* execution. - -The agent **cannot bypass** the firewall. Enforcement happens outside the agent's control. - -**Supported platforms:** -- Claude Code -- OpenAI Codex -- Any agent via the Python SDK - -### IAM-Style Policy Evaluation - -Evaluation order (same as AWS IAM): - -1. **Explicit deny always wins** — if any deny rule matches, action is blocked -2. **Review trumps allow** — if a review rule matches, action needs human approval -3. **Explicit allow** — if an allow rule matches, action proceeds -4. **Default policy** — if nothing matches, fall back to default (deny recommended) - ---- - -## Policy Format - -Policies are YAML files in a `policies/` directory. All files are loaded and merged automatically. - -### Full Schema - -```yaml -version: "1.0" -default: deny # deny | allow - -statements: - - sid: "UniqueId" # Statement ID (for audit trail) - effect: deny # deny | allow | review - actions: # Glob patterns - - "bash.execute" - - "file.write" - - "payment.*" - - "*.delete" - conditions: # Optional — when to apply - params.amount: - op: ">" - value: 10000 - context.user_role: - op: "==" - value: "admin" - match: all # all | any (condition logic) - reason: "Human-readable explanation" -``` - -### Action Names - -agsec uses a consistent naming scheme for actions: - -| Action | What It Covers | -|---|---| -| `bash.execute` | Shell commands | -| `file.write` | File creation | -| `file.edit` | File modification | -| `file.read` | File reading | -| `web.fetch` | HTTP requests | -| `web.search` | Web searches | -| `file.glob` | File pattern search | -| `file.grep` | Content search | -| `agent.spawn` | Sub-agent creation | -| `mcp.*` | Any MCP tool calls | - -Use glob patterns in policies: `payment.*`, `*.delete`, `mcp.slack.*` - -### Condition Operators - -| Operator | Description | Example | -|---|---|---| -| `==` | Equals | `value: "admin"` | -| `!=` | Not equals | `value: "guest"` | -| `>` `<` `>=` `<=` | Comparison | `value: 10000` | -| `in` | In list | `value: ["US", "UK"]` | -| `not_in` | Not in list | `value: ["KP", "IR"]` | -| `contains` | Substring match | `value: ".env"` | -| `starts_with` | Prefix match | `value: "https://api."` | -| `ends_with` | Suffix match | `value: ".com"` | -| `regex` | Regex match | `value: "rm\\s+-rf"` | -| `exists` | Field is present | *(no value needed)* | -| `not_exists` | Field is absent | *(no value needed)* | - -### Deep Nested Access - -Access nested fields with dot notation: - -```yaml -conditions: - params.recipient.country: - op: "in" - value: ["KP", "IR", "SY"] - context.request.headers.origin: - op: "ends_with" - value: ".internal.com" -``` - ---- - -## SDK Usage (Programmatic) - -Use agsec directly in your Python code, without the CLI: - -```python -from agsec import ControlLayer - -# Load policies from directory -control = ControlLayer(policy_dir="./policies/") - -# Register actions -@control.register_action("payment.charge") -async def charge(amount, recipient): - return {"charged": amount, "to": recipient} - -# Execute with policy enforcement -result = await control.execute( - "payment.charge", - {"amount": 500, "recipient": {"country": "US"}}, - context={"user_role": "agent"} -) -print(result.policy.status) # PolicyStatus.ALLOW -print(result.result) # {"charged": 500, "to": {"country": "US"}} -``` - -### Sync Usage - -```python -result = control.execute_sync("payment.charge", {"amount": 500, ...}) +agsec install claude-code +agsec install codex ``` -### Dry Run (Check Without Executing) +### LangChain (one line) ```python -# Async -policy = await control.dry_run("payment.charge", {"amount": 50000}) -print(policy.status) # PolicyStatus.REVIEW -print(policy.reason) # "Large payments require review" +from agsec.integrations.langchain import guard, allow, deny, review, param -# Sync -policy = control.dry_run_sync("payment.charge", {"amount": 50000}) +agent = create_react_agent(llm, guard( + allow(search, calculator), + review(send_email), + deny(delete_record), + deny(payment).when(param("amount") > 10000), +)) ``` -### Hooks (Before/After Execution) +### OpenAI / Anthropic / OpenRouter (one line) ```python -control = ControlLayer(policy_dir="./policies/") - -@control.before_hook -def log_action(action, params, context): - print(f"About to execute: {action}") +from agsec.integrations.openai import protect, deny, param -@control.after_hook -def log_result(exec_result): - print(f"Result: {exec_result.result}") +client = protect(OpenAI(), + deny("delete_user"), + deny("payment").when(param("amount") > 10000), +) +# Works with OpenRouter, Groq, Together — anything OpenAI-compatible ``` -### Policy Engine Direct Access +### Any Python function ```python -from agsec.policy import PolicyEngine - -engine = PolicyEngine() -engine.load_from_directory("./policies/") +from agsec import guard -# Evaluate -result = engine.evaluate("bash.execute", {"command": "rm -rf /"}) -print(result.status) # PolicyStatus.BLOCK -print(result.reason) # "Agents should not delete files" -print(result.metadata["sid"]) # "BlockFileDelete" -print(result.metadata["matched_by"]) # "explicit_deny" - -# Validate policies without loading -issues = engine.validate_directory("./policies/") +@guard("email.send") +def send_email(to, subject, body): + ... ``` -### Audit Store - -```python -from agsec.audit import AuditStore +## Policy Example -audit = AuditStore("./audit.db") +```yaml +version: "1.0" +default: deny -# Query logs -executions = audit.get_executions(action="payment.charge", limit=50) +statements: + - sid: "AllowReadOps" + effect: allow + actions: ["file.read", "file.glob", "file.grep"] -# Get stats -stats = audit.get_execution_stats() -# {"total_executions": 142, "allowed": 100, "blocked": 30, "reviewed": 12, "errors": 0} + - sid: "BlockFileDelete" + effect: deny + actions: ["bash.execute"] + conditions: + params.command: + op: "regex" + value: "\\brm\\s" + reason: "Agents should not delete files" -# Export -audit.export_to_json("audit_export.json") + - sid: "AllowBash" + effect: allow + actions: ["bash.execute"] ``` ---- - -## CLI Reference - -| Command | Description | -|---|---| -| `agsec init` | Create `policies/` with default safety policies | -| `agsec policy list` | List all active policy statements | -| `agsec policy add` | Add a new policy (interactive) | -| `agsec policy remove ` | Remove a policy by statement ID | -| `agsec validate [path]` | Validate policy files for errors | -| `agsec install claude-code` | Activate firewall for Claude Code | -| `agsec install codex` | Activate firewall for OpenAI Codex | -| `agsec audit` | View recent audit logs | -| `agsec audit --stats` | View summary statistics | - ---- - -## Default Policies - -`agsec init` ships with these out of the box: - -**01_base.yaml** - Default deny. Allow read operations and agent spawning. +Deny always wins. Same evaluation order as AWS IAM. -**02_bash.yaml** - Block `rm` (all forms), `DROP TABLE`, `TRUNCATE`, secret access via `cat .env`, data exfiltration via `curl --data`. Allow other bash. +## CLI -**03_files.yaml** - Block writes to `.env`, `credentials.json`, `secrets.yaml`, `.ssh/`, `.aws/credentials`, system directories (`/etc/`, `/usr/`). Allow other writes. - -**04_web.yaml** - Review all external HTTP fetches. Allow localhost. Allow web search. - -**05_git.yaml** - Block `git push --force`, push to `main`/`master`/`production`, `git reset --hard`, `git clean -f`. - ---- +```bash +agsec init [--observe] # scaffold policies +agsec install claude-code # activate for Claude Code +agsec install codex # activate for Codex +agsec policy list # see all rules +agsec policy add # add a rule (interactive) +agsec policy remove # remove a rule +agsec validate # check for errors +agsec audit [--stats] # view logs +agsec observe # switch to observe mode +agsec enforce # switch to enforce mode +``` + +## Documentation + +- [Policy Format](docs/policies.md) — schema, operators, conditions, examples +- [CLI Reference](docs/cli.md) — all commands in detail +- [Integrations](docs/integrations.md) — LangChain, OpenAI, Anthropic, Claude Code, Codex +- [SDK Usage](docs/sdk.md) — programmatic Python API +- [Observe Mode](docs/observe-mode.md) — audit first, enforce later ## Contributing -```bash -git clone https://github.com/riyandhiman14/Agent-Sec.git -cd agsec -pip install -e ".[dev]" -pytest -``` - ---- +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines. ## License -Apache 2.0 — see [LICENSE](LICENSE) for details. +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/agsec/audit/store.py b/agsec/audit/store.py index 56758a0..f9a8761 100644 --- a/agsec/audit/store.py +++ b/agsec/audit/store.py @@ -4,24 +4,29 @@ import os import sqlite3 from datetime import datetime -from pathlib import Path from typing import Any, Dict, List, Optional from ..types import ActionExecutionResult class AuditStore: + """SQLite-backed audit store for logging policy decisions. + + Thread-safe: uses check_same_thread=False for file-based databases. + Supports context manager protocol for automatic cleanup. + """ + def __init__(self, db_path: Optional[str] = None): self.db_path = db_path or ":memory:" - # Restrict file permissions for on-disk databases if self.db_path != ":memory:": - parent = os.path.dirname(self.db_path) - if parent: - os.makedirs(parent, mode=0o700, exist_ok=True) + # Restrict file permissions for on-disk databases old_umask = os.umask(0o077) try: - self.conn = sqlite3.connect(self.db_path) + parent = os.path.dirname(self.db_path) + if parent: + os.makedirs(parent, mode=0o700, exist_ok=True) + self.conn = sqlite3.connect(self.db_path, check_same_thread=False) finally: os.umask(old_umask) else: @@ -30,6 +35,20 @@ def __init__(self, db_path: Optional[str] = None): self.conn.row_factory = sqlite3.Row self._init_db() + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self) -> None: + """Close the database connection.""" + if self.conn: + try: + self.conn.close() + except Exception: + pass + def _init_db(self) -> None: self.conn.execute(""" CREATE TABLE IF NOT EXISTS executions ( @@ -57,7 +76,7 @@ def log_execution(self, execution: ActionExecutionResult, context: Optional[Dict json.dumps(execution.result, default=str) if execution.result is not None else None, execution.policy.status.value, execution.policy.reason, - json.dumps(context) if context else None, + json.dumps(context, default=str) if context else None, error )) self.conn.commit() @@ -92,6 +111,12 @@ def get_execution_stats(self) -> Dict[str, Any]: return dict(stats) def export_to_json(self, file_path: str) -> None: - executions = self.get_executions(limit=10000) # Export last 10k - with open(file_path, 'w') as f: - json.dump(executions, f, indent=2) + executions = self.get_executions(limit=10000) + # Write with restricted permissions + old_umask = os.umask(0o077) + try: + fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(executions, f, indent=2) + finally: + os.umask(old_umask) diff --git a/agsec/cli/commands/audit.py b/agsec/cli/commands/audit.py index 70b7971..1f77196 100644 --- a/agsec/cli/commands/audit.py +++ b/agsec/cli/commands/audit.py @@ -6,7 +6,7 @@ import sys from ...audit import AuditStore -from ..config import get_audit_db_path +from ..config import get_audit_db_path, load_mode def register(subparsers): @@ -27,15 +27,22 @@ def run(args): if args.stats: stats = audit.get_execution_stats() + mode = load_mode() if args.as_json: + stats["mode"] = mode print(json.dumps(stats, indent=2)) else: - print("Audit Statistics") - print(f" Total: {stats['total_executions']}") - print(f" Allowed: {stats['allowed']}") - print(f" Blocked: {stats['blocked']}") - print(f" Reviewed: {stats['reviewed']}") - print(f" Errors: {stats['errors']}") + mode_label = "OBSERVE" if mode == "observe" else "ENFORCE" + print(f"Audit Statistics ({mode_label} mode)") + print(f" Total: {stats['total_executions']}") + print(f" Allowed: {stats['allowed']}") + if mode == "observe": + print(f" Would block: {stats['blocked']}") + print(f" Would review: {stats['reviewed']}") + else: + print(f" Blocked: {stats['blocked']}") + print(f" Reviewed: {stats['reviewed']}") + print(f" Errors: {stats['errors']}") return executions = audit.get_executions(action=args.action, limit=args.limit) diff --git a/agsec/cli/commands/check.py b/agsec/cli/commands/check.py index 50fa46f..851000d 100644 --- a/agsec/cli/commands/check.py +++ b/agsec/cli/commands/check.py @@ -8,7 +8,7 @@ from ...audit import AuditStore from ...policy import PolicyEngine from ...types import ActionExecutionResult, PolicyResult, PolicyStatus -from ..config import find_policy_dir, get_audit_db_path +from ..config import find_policy_dir, get_audit_db_path, load_mode from ..mapping import map_tool_to_action @@ -47,6 +47,12 @@ def run(args): else: action, params = raw.get("action", "unknown"), raw.get("params", {}) + # Validate types + if not isinstance(action, str): + action = str(action) + if not isinstance(params, dict): + params = {} + # Build context from hook metadata context = {} for key in ("session_id", "cwd", "permission_mode"): @@ -77,6 +83,10 @@ def run(args): # Evaluate result = engine.evaluate(action, params, context) + # Check mode (observe vs enforce) + mode = load_mode() + context["agsec_mode"] = mode + # Audit log (never fail the check due to audit) try: audit = AuditStore(get_audit_db_path()) @@ -85,7 +95,11 @@ def run(args): except Exception: pass - # Output based on format + # Observe mode: log everything but always allow + if mode == "observe": + sys.exit(0) + + # Enforce mode: act on policy decision if result.status == PolicyStatus.ALLOW: sys.exit(0) diff --git a/agsec/cli/commands/init.py b/agsec/cli/commands/init.py index febbbd7..fb96495 100644 --- a/agsec/cli/commands/init.py +++ b/agsec/cli/commands/init.py @@ -5,12 +5,14 @@ import os import shutil -from ..config import get_templates_dir +from ..config import get_templates_dir, set_mode def register(subparsers): p = subparsers.add_parser("init", help="Initialize agsec policies in current directory") p.add_argument("--dir", default="policies", help="Directory name (default: policies)") + p.add_argument("--observe", action="store_true", + help="Start in observe mode (audit everything, block nothing)") p.set_defaults(func=run) @@ -29,12 +31,23 @@ def run(args): shutil.copytree(templates, target) + # Set mode + mode = "observe" if args.observe else "enforce" + config_path = set_mode(mode) + + mode_label = "OBSERVE" if args.observe else "ENFORCE" files = sorted(os.listdir(target)) - print(f"Created {target}/ with {len(files)} policy files:") + print(f"Created {target}/ with {len(files)} policy files ({mode_label} mode)") for f in files: print(f" {f}") print() - print("Next steps:") - print(" 1. Edit policies to match your needs") - print(" 2. Run 'agsec validate' to check for errors") - print(" 3. Run 'agsec install claude-code' or 'agsec install codex' to activate") + + if args.observe: + print("Observe mode: all actions are ALLOWED but logged.") + print("Run 'agsec audit --stats' to see what would be blocked.") + print("Run 'agsec enforce' when ready to start blocking.") + else: + print("Next steps:") + print(" 1. Edit policies to match your needs") + print(" 2. Run 'agsec validate' to check for errors") + print(" 3. Run 'agsec install claude-code' or 'agsec install codex' to activate") diff --git a/agsec/cli/commands/install.py b/agsec/cli/commands/install.py index 4a3c6b5..b9fc963 100644 --- a/agsec/cli/commands/install.py +++ b/agsec/cli/commands/install.py @@ -37,7 +37,7 @@ def run(args): def _install_claude_code(project_dir: str): claude_dir = os.path.join(project_dir, ".claude") - os.makedirs(claude_dir, exist_ok=True) + os.makedirs(claude_dir, mode=0o700, exist_ok=True) settings_path = os.path.join(claude_dir, "settings.json") agsec_cmd = _find_agsec_bin() @@ -90,7 +90,8 @@ def _install_claude_code(project_dir: str): pre_tool_hooks.append(new_hook) - with open(settings_path, "w") as f: + fd = os.open(settings_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump(settings, f, indent=2) print("agsec hook installed for Claude Code.") @@ -103,7 +104,7 @@ def _install_claude_code(project_dir: str): def _install_codex(project_dir: str): # Codex uses ~/.codex/ or project-level config codex_dir = os.path.join(project_dir, ".codex") - os.makedirs(codex_dir, exist_ok=True) + os.makedirs(codex_dir, mode=0o700, exist_ok=True) hooks_path = os.path.join(codex_dir, "hooks.json") agsec_cmd = _find_agsec_bin() @@ -133,7 +134,8 @@ def _install_codex(project_dir: str): except json.JSONDecodeError: pass - with open(hooks_path, "w") as f: + fd = os.open(hooks_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump(hooks_config, f, indent=2) print("agsec hook installed for Codex.") diff --git a/agsec/cli/commands/mode.py b/agsec/cli/commands/mode.py new file mode 100644 index 0000000..f8701a2 --- /dev/null +++ b/agsec/cli/commands/mode.py @@ -0,0 +1,39 @@ +"""agsec observe / agsec enforce — switch between modes.""" + +from __future__ import annotations + +from ..config import load_mode, set_mode + + +def register_observe(subparsers): + p = subparsers.add_parser("observe", help="Switch to observe mode (audit only, no blocking)") + p.set_defaults(func=run_observe) + + +def register_enforce(subparsers): + p = subparsers.add_parser("enforce", help="Switch to enforce mode (policies enforced)") + p.set_defaults(func=run_enforce) + + +def run_observe(args): + current = load_mode() + if current == "observe": + print("Already in OBSERVE mode.") + return + config_path = set_mode("observe") + print("Switched to OBSERVE mode.") + print(" All actions are allowed but logged.") + print(" Run 'agsec audit --stats' to see what would be blocked.") + print(" Run 'agsec enforce' when ready to start blocking.") + print(f" Config: {config_path}") + + +def run_enforce(args): + current = load_mode() + if current == "enforce": + print("Already in ENFORCE mode.") + return + config_path = set_mode("enforce") + print("Switched to ENFORCE mode.") + print(" Policies are now enforced. Blocked actions will be denied.") + print(f" Config: {config_path}") diff --git a/agsec/cli/config.py b/agsec/cli/config.py index c94213f..7da71b6 100644 --- a/agsec/cli/config.py +++ b/agsec/cli/config.py @@ -4,6 +4,10 @@ import os +import yaml + +CONFIG_FILENAME = ".agsec.yaml" + def find_policy_dir(start_dir: str | None = None) -> str: """Find the policies directory by walking up from start_dir. @@ -47,3 +51,61 @@ def get_audit_db_path() -> str: def get_templates_dir() -> str: """Return path to bundled policy templates.""" return os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates", "policies") + + +def find_config_path(start_dir: str | None = None) -> str | None: + """Find .agsec.yaml config file by walking up from start_dir.""" + env_mode = os.environ.get("AGSEC_MODE") + if env_mode: + return None # env var overrides file + + start = os.path.abspath(start_dir or os.getcwd()) + current = start + for _ in range(20): + path = os.path.join(current, CONFIG_FILENAME) + if os.path.isfile(path): + return path + parent = os.path.dirname(current) + if parent == current: + break + current = parent + return None + + +def load_mode(start_dir: str | None = None) -> str: + """Return 'observe' or 'enforce'. Default: 'enforce'.""" + # Env var takes priority + env_mode = os.environ.get("AGSEC_MODE") + if env_mode in ("observe", "enforce"): + return env_mode + + config_path = find_config_path(start_dir) + if config_path: + try: + with open(config_path, "r") as f: + doc = yaml.safe_load(f) or {} + return doc.get("mode", "enforce") + except Exception: + pass + return "enforce" + + +def set_mode(mode: str, start_dir: str | None = None) -> str: + """Write mode to .agsec.yaml. Returns path of config file.""" + config_path = find_config_path(start_dir) + if config_path: + try: + with open(config_path, "r") as f: + doc = yaml.safe_load(f) or {} + except Exception: + doc = {} + doc["mode"] = mode + with open(config_path, "w") as f: + yaml.dump(doc, f, default_flow_style=False, sort_keys=False) + return config_path + + # No config file found — create one in cwd + config_path = os.path.join(os.getcwd(), CONFIG_FILENAME) + with open(config_path, "w") as f: + yaml.dump({"mode": mode}, f, default_flow_style=False, sort_keys=False) + return config_path diff --git a/agsec/cli/main.py b/agsec/cli/main.py index b5447d5..0793a38 100644 --- a/agsec/cli/main.py +++ b/agsec/cli/main.py @@ -13,7 +13,7 @@ def main(): ) subparsers = parser.add_subparsers(dest="command") - from .commands import audit, check, init, install, policy, validate + from .commands import audit, check, init, install, mode, policy, validate init.register(subparsers) check.register(subparsers) @@ -21,6 +21,8 @@ def main(): install.register(subparsers) policy.register(subparsers) audit.register(subparsers) + mode.register_observe(subparsers) + mode.register_enforce(subparsers) args = parser.parse_args() if not args.command: diff --git a/agsec/control.py b/agsec/control.py index abf0293..3812fe6 100644 --- a/agsec/control.py +++ b/agsec/control.py @@ -39,6 +39,19 @@ def __init__( self._before_hooks: List[Callable] = [] self._after_hooks: List[Callable] = [] + # -- Cleanup -- + + def close(self) -> None: + """Close the audit store connection.""" + if self.audit_store: + self.audit_store.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + # -- Action registration -- def register_action(self, name: str): diff --git a/agsec/integrations/_base.py b/agsec/integrations/_base.py index 4c3094f..7389330 100644 --- a/agsec/integrations/_base.py +++ b/agsec/integrations/_base.py @@ -98,6 +98,11 @@ def _ensure_loaded(self) -> None: self._loaded = True + def close(self) -> None: + """Close the audit store connection.""" + if self._audit_store: + self._audit_store.close() + def check( self, action: str, params: Dict[str, Any], context: Optional[Dict[str, Any]] = None ) -> PolicyResult: diff --git a/agsec/templates/policies/03_files.yaml b/agsec/templates/policies/03_files.yaml index b8510cf..10fe233 100644 --- a/agsec/templates/policies/03_files.yaml +++ b/agsec/templates/policies/03_files.yaml @@ -11,6 +11,16 @@ statements: value: "(\\.env$|\\.env\\..+|credentials\\.json|secrets\\.ya?ml|\\.ssh/|id_rsa|\\.aws/credentials|\\.gcloud/|service[_-]account.*\\.json)" reason: "Writing to sensitive files is blocked" + # Block writes to agsec config and hook files (prevent policy tampering) + - sid: "BlockWriteAgsecConfig" + effect: deny + actions: ["file.write", "file.edit"] + conditions: + params.file_path: + op: "regex" + value: "(\\.agsec\\.yaml|policies/.*\\.ya?ml|\\.claude/settings.*\\.json|\\.codex/hooks\\.json)" + reason: "Writing to agsec policies or hook config is blocked" + # Block writes to system directories - sid: "BlockWriteSystemDirs" effect: deny diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..5755153 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,80 @@ +# CLI Reference + +## Setup + +### `agsec init [--observe] [--dir DIR]` + +Scaffold a policies directory with default safety policies. + +```bash +agsec init # enforce mode (default) +agsec init --observe # observe mode (audit only, no blocking) +agsec init --dir .agsec/policies # custom directory +``` + +### `agsec install claude-code|codex` + +Activate the firewall for an agent platform. + +```bash +agsec install claude-code +agsec install codex +``` + +## Policy Management + +### `agsec policy list` + +Show all active policy statements across all files. + +### `agsec policy add` + +Add a new policy statement interactively. Walks you through: +1. Effect (deny/allow/review) +2. Actions to match +3. Conditions (pattern matching) +4. Reason and statement ID + +Also supports non-interactive mode: +```bash +agsec policy add --no-interactive --sid BlockX --effect deny --actions "bash.execute" --reason "Blocked" +``` + +### `agsec policy remove ` + +Remove a policy statement by its statement ID. + +### `agsec validate [path]` + +Validate policy files for syntax errors, missing fields, invalid operators. + +```bash +agsec validate # auto-discover policies directory +agsec validate policies/ # specific directory +agsec validate policy.yaml # single file +``` + +## Mode + +### `agsec observe` + +Switch to observe mode. All actions are allowed but every policy decision is logged. Use `agsec audit --stats` to see what would be blocked. + +### `agsec enforce` + +Switch to enforce mode. Policies are enforced — blocked actions are denied. + +## Audit + +### `agsec audit [--stats] [--action NAME] [--limit N] [--json]` + +Query audit logs. + +```bash +agsec audit # recent 20 actions +agsec audit --stats # summary statistics +agsec audit --action bash.execute --limit 50 +agsec audit --json # machine-readable output +``` + +In observe mode, stats show "would block" instead of "blocked". diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..fc56635 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,129 @@ +# Integrations + +agsec works with any agent platform. One policy engine, same audit trail everywhere. + +## Claude Code + +```bash +pip install agsec +agsec init +agsec install claude-code +``` + +Enforcement is at the runtime level. The agent cannot bypass it. + +## OpenAI Codex + +```bash +pip install agsec +agsec init +agsec install codex +``` + +## LangChain + +```bash +pip install agsec[langchain] +``` + +```python +from agsec.integrations.langchain import guard, allow, deny, review, param + +agent = create_react_agent(llm, guard( + allow(search, calculator), + review(send_email), + deny(delete_record), + deny(payment).when(param("amount") > 10000), +)) +``` + +Rules: +- Bare tool = allow +- `allow(tool1, tool2)` = explicitly allow +- `deny(tool)` = block +- `review(tool)` = require human approval +- `.when(param("x") > 10)` = conditional + +Action names are `tool.{tool_name}`. + +Also works with YAML policies: +```python +agent = create_react_agent(llm, guard( + search, calculator, send_email, + policy_dir="./policies/", +)) +``` + +## OpenAI SDK + +Works with OpenAI, OpenRouter, Groq, Together, Fireworks — anything OpenAI-compatible. + +```bash +pip install agsec[openai] +``` + +```python +from openai import OpenAI +from agsec.integrations.openai import protect, deny, param + +client = protect(OpenAI(), + deny("delete_user"), + deny("payment").when(param("amount") > 10000), +) + +# Use client normally — tool calls are checked automatically +response = client.chat.completions.create(model="gpt-4", messages=[...], tools=[...]) +``` + +OpenRouter: +```python +client = protect(OpenAI(base_url="https://openrouter.ai/api/v1", api_key="..."), + deny("dangerous_tool"), +) +``` + +Blocked tool calls are filtered from the response. Check `response._agsec_blocked` for details. + +**Note:** Streaming (`stream=True`) is not yet supported. Use `stream=False`. + +## Anthropic SDK + +```bash +pip install agsec[anthropic] +``` + +```python +from anthropic import Anthropic +from agsec.integrations.anthropic import protect, deny, param + +client = protect(Anthropic(), + deny("delete_user"), + deny("payment").when(param("amount") > 10000), +) + +response = client.messages.create(model="claude-sonnet-4-20250514", messages=[...], tools=[...]) +``` + +Same behavior as OpenAI — blocked `tool_use` blocks are filtered from the response. + +## Generic Python (any framework) + +```python +from agsec import guard + +@guard("email.send") +def send_email(to, subject, body): + ... + +@guard("payment.charge", agent="billing-agent") +async def charge(amount, recipient): + ... +``` + +Works with sync and async functions. Raises `PolicyViolationError` on block. + +## Install All + +```bash +pip install agsec[all] # openai + anthropic + langchain +``` diff --git a/docs/observe-mode.md b/docs/observe-mode.md new file mode 100644 index 0000000..21fd3c0 --- /dev/null +++ b/docs/observe-mode.md @@ -0,0 +1,72 @@ +# Observe Mode + +Observe mode lets you deploy agsec without blocking anything. Every action is allowed, but every policy decision is logged — including what would have been blocked. + +## Why + +Deploying a firewall blind is risky. You don't know what your agent actually does until you watch it. Observe mode lets you: + +1. See real agent behavior in production +2. Identify what would be blocked by your policies +3. Tune policies based on actual data +4. Switch to enforce when confident + +## Setup + +### New Project + +```bash +agsec init --observe +agsec install claude-code +``` + +### Existing Project + +```bash +agsec observe +``` + +## The Flow + +``` +1. agsec init --observe # deploy with logging only +2. Agent runs normally # everything is allowed +3. agsec audit --stats # see what would be blocked +4. Edit policies as needed # tune based on real data +5. agsec enforce # start blocking +``` + +## Commands + +```bash +agsec observe # switch to observe mode +agsec enforce # switch to enforce mode +``` + +## Audit in Observe Mode + +```bash +$ agsec audit --stats +Audit Statistics (OBSERVE mode) + Total: 142 + Allowed: 100 + Would block: 30 + Would review: 12 + Errors: 0 +``` + +The "would block" and "would review" counts show actions that policies evaluated as BLOCK/REVIEW but were allowed through because of observe mode. + +## How It Works + +- A `.agsec.yaml` config file stores the current mode +- `agsec check` reads this config before evaluating +- In observe mode: policies are evaluated, decisions are logged, but exit code is always 0 (allow) +- In enforce mode: policies are evaluated, decisions are logged, and blocked actions exit with non-zero + +## Environment Variable Override + +```bash +AGSEC_MODE=observe agsec check ... # force observe +AGSEC_MODE=enforce agsec check ... # force enforce +``` diff --git a/docs/policies.md b/docs/policies.md new file mode 100644 index 0000000..9064f06 --- /dev/null +++ b/docs/policies.md @@ -0,0 +1,108 @@ +# Policy Format + +Policies are YAML files in a `policies/` directory. All files are loaded and merged automatically in alphabetical order. Deny from any file wins. + +## Schema + +```yaml +version: "1.0" +default: deny # deny | allow + +statements: + - sid: "UniqueId" # Statement ID (for audit trail + debugging) + effect: deny # deny | allow | review + actions: # Glob patterns + - "bash.execute" + - "file.write" + - "payment.*" + - "*.delete" + conditions: # Optional — when to apply + params.amount: + op: ">" + value: 10000 + context.user_role: + op: "==" + value: "admin" + match: all # all | any (condition logic) + reason: "Human-readable explanation" +``` + +## Evaluation Order + +Same as AWS IAM: + +1. **Explicit deny always wins** — any matching deny rule blocks the action +2. **Review trumps allow** — review rules pause for human approval +3. **Explicit allow** — matching allow rule permits the action +4. **Default policy** — if nothing matches, fall back to default (deny recommended) + +## Action Names + +| Action | What It Covers | +|---|---| +| `bash.execute` | Shell commands | +| `file.write` | File creation | +| `file.edit` | File modification | +| `file.read` | File reading | +| `web.fetch` | HTTP requests | +| `web.search` | Web searches | +| `file.glob` | File pattern search | +| `file.grep` | Content search | +| `agent.spawn` | Sub-agent creation | +| `mcp.*` | Any MCP tool calls | +| `internal.*` | IDE/platform internals (always allowed) | + +Use glob patterns: `payment.*`, `*.delete`, `mcp.slack.*` + +## Condition Operators + +| Operator | Description | Example | +|---|---|---| +| `==` | Equals | `value: "admin"` | +| `!=` | Not equals | `value: "guest"` | +| `>` `<` `>=` `<=` | Comparison | `value: 10000` | +| `in` | In list | `value: ["US", "UK"]` | +| `not_in` | Not in list | `value: ["KP", "IR"]` | +| `contains` | Substring match | `value: ".env"` | +| `starts_with` | Prefix match | `value: "https://api."` | +| `ends_with` | Suffix match | `value: ".com"` | +| `regex` | Regex match | `value: "rm\\s+-rf"` | +| `exists` | Field is present | *(no value needed)* | +| `not_exists` | Field is absent | *(no value needed)* | + +## Deep Nested Access + +Access nested fields with dot notation: + +```yaml +conditions: + params.recipient.country: + op: "in" + value: ["KP", "IR", "SY"] + context.request.headers.origin: + op: "ends_with" + value: ".internal.com" +``` + +## Default Policies + +`agsec init` ships with 5 policy files: + +- **01_base.yaml** — Default deny. Allow reads, agent spawn, internal tools. +- **02_bash.yaml** — Block `rm`, `DROP TABLE`, secret access, data exfiltration. Allow other bash. +- **03_files.yaml** — Block writes to `.env`, credentials, system dirs, agsec config. Allow other writes. +- **04_web.yaml** — Review external HTTP fetches. Allow localhost and web search. +- **05_git.yaml** — Block force push, protected branches, `reset --hard`. + +## Multiple Policy Files + +All `.yaml`/`.yml` files in the policies directory are loaded and merged. Use numbered prefixes for ordering: + +``` +policies/ + 01_base.yaml # loaded first — sets default + 02_bash.yaml # bash rules + 03_custom.yaml # your custom rules +``` + +A deny in any file overrides an allow in any other file. diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..e75a7d6 --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,135 @@ +# SDK Usage + +Use agsec programmatically in Python without the CLI. + +## ControlLayer + +The main orchestrator — registers actions, evaluates policies, executes with audit logging. + +```python +from agsec import ControlLayer + +control = ControlLayer(policy_dir="./policies/") + +@control.register_action("payment.charge") +async def charge(amount, recipient): + return {"charged": amount, "to": recipient} + +result = await control.execute( + "payment.charge", + {"amount": 500, "recipient": {"country": "US"}}, + context={"user_role": "agent"} +) +print(result.policy.status) # PolicyStatus.ALLOW +print(result.result) # {"charged": 500, ...} +``` + +### Sync Usage + +```python +result = control.execute_sync("payment.charge", {"amount": 500, ...}) +``` + +### Dry Run + +Check policy without executing: + +```python +policy = await control.dry_run("payment.charge", {"amount": 50000}) +print(policy.status) # PolicyStatus.REVIEW + +# Sync +policy = control.dry_run_sync("payment.charge", {"amount": 50000}) +``` + +### Hooks + +```python +@control.before_hook +def log_action(action, params, context): + print(f"About to execute: {action}") + +@control.after_hook +def log_result(exec_result): + print(f"Result: {exec_result.result}") +``` + +### Context Manager + +```python +with ControlLayer(policy_dir="./policies/") as control: + result = control.execute_sync("action", {}) +# Connection automatically closed +``` + +## PolicyEngine + +Direct access to the policy evaluation engine. + +```python +from agsec.policy import PolicyEngine + +engine = PolicyEngine() +engine.load_from_directory("./policies/") + +result = engine.evaluate("bash.execute", {"command": "rm -rf /"}) +print(result.status) # PolicyStatus.BLOCK +print(result.reason) # "Agents should not delete files" +print(result.metadata["sid"]) # "BlockFileDelete" +print(result.metadata["matched_by"]) # "explicit_deny" +``` + +### Validation + +```python +# Validate without loading +issues = engine.validate_directory("./policies/") +# Returns {"filename": ["issue1", "issue2"]} — empty means valid + +issues = engine.validate_file("policy.yaml") +``` + +## AuditStore + +SQLite-backed audit logging. + +```python +from agsec.audit import AuditStore + +with AuditStore("./audit.db") as audit: + executions = audit.get_executions(action="payment.charge", limit=50) + stats = audit.get_execution_stats() + audit.export_to_json("export.json") +``` + +Stats returns: +```python +{"total_executions": 142, "allowed": 100, "blocked": 30, "reviewed": 12, "errors": 0} +``` + +## Types + +```python +from agsec.types import PolicyStatus, PolicyResult, ActionExecutionResult + +# PolicyStatus enum +PolicyStatus.ALLOW +PolicyStatus.BLOCK +PolicyStatus.REVIEW +``` + +## Exceptions + +All exceptions inherit from `AgsecError`: + +```python +from agsec.exceptions import PolicyViolationError, ActionExecutionError + +try: + result = control.execute_sync("action", {}) +except PolicyViolationError as e: + print(e.code) # "POLICY_VIOLATION" + print(e.details) # {"action": "...", "reason": "..."} +except ActionExecutionError as e: + print(e.original_error) +``` diff --git a/future_security_items.md b/future_security_items.md new file mode 100644 index 0000000..4a7369f --- /dev/null +++ b/future_security_items.md @@ -0,0 +1,53 @@ +# Future Security Items + +## 1. Input Normalization Before Policy Evaluation + +**Found during:** Internal testing (developer bypassed global hook using base64 encoding) + +**Problem:** Policy conditions use pattern matching (regex, contains, etc.) on raw input strings. If inputs are encoded, obfuscated, or use unicode escapes, patterns won't match. + +**Bypass examples:** +- Base64: `echo "cm0gLXJmIC8=" | base64 -d | sh` — the command string doesn't contain `rm` +- Unicode escapes in JSON: `D\u0052OP TABLE` (decoded by JSON parser, but other encodings may not be) +- String concatenation: `"r" + "m" + " -rf /"` +- Hex encoding, URL encoding, etc. + +**Who's affected:** +- CLI hooks (Claude Code/Codex): Lower risk — the runtime sends tool arguments as-is, agent can't control encoding +- SDK integrations (OpenAI/Anthropic protect()): Higher risk — LLM could generate obfuscated tool arguments +- Generic @guard decorator: Medium risk — depends on caller + +**Current mitigation:** None. Same limitation as WAFs, SAST tools, and every pattern-based security tool. + +**Future fix options:** +- Normalize inputs before evaluation (decode common encodings, collapse whitespace, lowercase) +- Add a normalization step in PolicyEngine.evaluate() before condition matching +- For bash commands specifically: parse the command into AST instead of regex matching +- For SQL: use a SQL parser instead of string matching +- Add a `normalize: true` flag on policy statements + +**Priority:** Medium — this is a known limitation of pattern-based security. Defense-in-depth (multiple layers) is the standard approach. But we should fix it before enterprise customers rely on it. + +## 2. Hook Bypass via Settings File Modification + +**Problem:** If an agent has file write access, it could modify `.claude/settings.json` to remove the agsec hook. + +**Current mitigation:** The hook blocks file writes to sensitive paths, but `.claude/settings.json` is not in the blocked list. + +**Future fix:** Add `.claude/settings.json` and `.codex/hooks.json` to the default deny list in `03_files.yaml`. + +## 3. Policy File Tampering + +**Problem:** If an agent can write to the `policies/` directory, it could modify or delete policy files to weaken enforcement. + +**Current mitigation:** None. + +**Future fix:** Add `policies/*.yaml` and `.agsec.yaml` to the default deny list. Consider integrity hashing of policy files. + +## 4. Streaming Response Bypass + +**Problem:** `protect()` for OpenAI/Anthropic raises `NotImplementedError` for streaming. If a developer catches and ignores this, streaming calls bypass all checks. + +**Current mitigation:** Explicit error rather than silent pass-through. + +**Future fix:** Implement streaming response interception — wrap the stream generator and check tool calls as they appear in chunks.