Skip to content

Security: onsen-ai/claudia

Security

SECURITY.md

πŸ›‘οΈ Security

Claudia runs untrusted commands from an AI model inside your AWS account. That's... a sentence that should make you uncomfortable. So we built a lot of guardrails.

This document walks through every security layer β€” from the moment a Slack event arrives to the moment a bash command tries to phone home. Think of it as a guided tour through a very paranoid Lambda function.

πŸ“‘ Table of Contents


πŸ—ΊοΈ The Big Picture

Every request passes through multiple checkpoints before anything interesting happens:

flowchart TD
    Slack["πŸ’¬ Slack Event"] --> Sig{"πŸ” Signature<br/>Valid?"}
    Sig -- No --> Reject["❌ 401 Rejected"]
    Sig -- Yes --> Filters{"πŸ€– Bot?<br/>πŸ”„ Retry?<br/>πŸ‘― Duplicate?"}
    Filters -- Yes --> Drop["πŸ’€ Ignored"]
    Filters -- No --> Lock{"πŸ”’ Thread<br/>Lock?"}
    Lock -- Taken --> Busy["⏳ Retry Button"]
    Lock -- Free --> Prompt["πŸ“œ System Prompt<br/>+ Safety Rules"]
    Prompt --> Agent["🧠 Agent Loop<br/>budget + turn limits"]
    Agent --> Tools{"πŸ”§ Tool Use?"}
    Tools -- No --> Done["βœ… Reply to Slack"]
    Tools -- Yes --> Sandbox["πŸ–οΈ Sandboxed Execution"]
    Sandbox --> Redact["πŸ” Redact Secrets"]
    Redact --> Agent

    style Reject fill:#ff6b6b,color:#fff
    style Drop fill:#868e96,color:#fff
    style Prompt fill:#845ef7,color:#fff
    style Sandbox fill:#339af0,color:#fff
Loading

πŸšͺ Getting In the Door

Before Claudia even looks at your message, the request has to prove it's legit.

Slack signature verification runs before the JSON body is parsed β€” so a forged request never touches the business logic. The check uses HMAC-SHA256 with your Slack signing secret, compared via crypto.timingSafeEqual (with a buffer length check, because timing attacks are sneaky). Requests older than 5 minutes are rejected.

Once past the signature check, several filters keep things sane:

  • πŸ”„ Retry rejection β€” Slack retries if your Lambda is slow. We ack the retry immediately so one message doesn't spawn three parallel agents.
  • πŸ€– Bot filter β€” Messages from bots (including Claudia herself) are dropped. No infinite loops today.
  • πŸ‘― Deduplication β€” An @claudia mention fires two Slack events (app_mention + message). We pick one and skip the other.

πŸ”‘ Secrets: Who Gets What

Credentials flow through Claudia carefully, with every token accounted for:

flowchart LR
    SM["🏦 Secrets Manager"] --> Process["process.env<br/>(injected per-invocation)"]
    DDB["πŸ—„οΈ DynamoDB<br/>User Tokens"] --> Process
    Process --> Strip["🧹 Strip from<br/>child processes"]
    Strip --> Bash["🐚 Bash Tool<br/>(clean env)"]

    SVC["πŸ“‹ services config"] -.->|"allowlisted<br/>tokens only"| Bash

    style SM fill:#339af0,color:#fff
    style DDB fill:#339af0,color:#fff
    style Strip fill:#ff922b,color:#fff
Loading

The principle is simple: child processes get nothing by default.

  • Shared secrets (bot token, API keys, OAuth client secrets) live in AWS Secrets Manager β€” never in code or config files.

  • User tokens (PAT + OAuth) are loaded from DynamoDB at invocation start, injected into process.env, and always cleaned up in a finally block.

  • All AWS credentials and user tokens are stripped from child process environments. A bash command can't see your AWS_SECRET_ACCESS_KEY or anyone's GitHub PAT.

  • Want a tool to access a specific service? You have to explicitly allowlist it:

    # config/tools.yml
    bash:
        services: [google]   # Only Google tokens pass through

    Everything else? Stripped. Even env_alias values (like GOOGLE_WORKSPACE_CLI_TOKEN) only flow to tools that list the service.


🧠 The Prompt Layer

Everything above handles infrastructure β€” but the model itself is a security surface. A user (or a crafty piece of tool output) could try to manipulate Claudia into doing something she shouldn't. The prompt layer is where we set the rules.

flowchart TD
    User["πŸ’¬ User Message"] --> System["πŸ“œ System Prompt<br/>(safety rules)"]
    System --> Model["🧠 Claude"]
    Model --> Decision{"What to do?"}
    Decision -- "πŸ’¬ Reply" --> Slack["βœ… Slack Thread"]
    Decision -- "πŸ”§ Tool call" --> Guardrails{"πŸ›‚ Budget?<br/>Turn limit?<br/>Blocked?"}
    Guardrails -- "Over limit" --> Stop["πŸ›‘ Stop Loop"]
    Guardrails -- "OK" --> Execute["⚑ Execute"]
    Execute --> Output["πŸ“€ Tool Output"]
    Output --> Redact["πŸ” Redact Secrets"]
    Redact --> Model

    style Stop fill:#ff6b6b,color:#fff
    style System fill:#339af0,color:#fff
Loading

πŸ”’ System Prompt Safety Rules

The system prompt (config/prompts/system-prompt.md) includes a <safety> block with hard rules for the model:

  • No prompt leaking β€” Claudia refuses to reveal, repeat, or paraphrase her system prompt or internal instructions, even if asked indirectly.
  • Destructive action confirmation β€” Deleting data, modifying production systems, or any destructive action requires confirming intent with the user first.
  • No secret echoing β€” If credentials or API keys appear in tool output, the model is instructed not to repeat them in its response (backup to the regex-based redaction in Layer 5).
  • Refuse harmful requests β€” Illegal, privacy-violating, or harmful requests get a brief refusal.
  • Data confidentiality β€” All data is treated as internal. No sending it to external services unless the user explicitly authorizes it.
  • Prompt injection detection β€” If tool output or pasted text contains what looks like injected instructions, Claudia flags it to the user instead of following the instructions.

🧰 Tool Awareness

The model doesn't get raw tool access β€” it gets a curated view:

  • The bash tool prompt (config/tools/bash.md) tells the model about the sandbox upfront: read-only filesystem, 30s timeout, blocked patterns, secret redaction. This sets expectations so the model doesn't waste turns fighting the sandbox.
  • Skills are lazy-loaded β€” The model sees a compact catalog in the system prompt (~6K chars) but skill instructions are only loaded when invoked. This keeps the context window focused and prevents prompt bloat from exposing too many capabilities at once.
  • Connected services are declared β€” The {{connected_services}} template variable tells the model exactly which service tokens are available (e.g., github(personal), google(work)). No guessing, no hallucinating access to services that aren't connected.

🏁 Agentic Loop Guardrails

Even if the model goes rogue, the loop itself has hard limits:

Guardrail Default What happens
Max turns 10 Loop stops, result is posted as-is
Max budget $1.00 Loop stops with a :warning: Budget limit reached message
Lambda timeout 15 min AWS kills the process β€” lock expires, next message can proceed

These are checked before each API call, so a runaway agentic loop can't burn through your budget or spin forever. The model doesn't control these limits β€” they're enforced in agent.js, outside the prompt.

πŸ€” What the Model Can't Do

Worth stating explicitly β€” the model has no way to:

  • Change its own system prompt or safety rules
  • Bypass the budget or turn limits
  • Access tools that aren't in allowed_tools
  • See tokens for services not in the tool's services config
  • Disable the egress proxy or modify the allowlist
  • Persist anything beyond /tmp (which is wiped each invocation)

The model can ask to do things via tool calls, but every request passes through the infrastructure layers above. The prompt is the first line of defense; the sandbox is the last.


🐚 Command Execution: Five Layers of "No"

When the model decides to run a bash command, it has to survive a gauntlet:

Layer 1: 🚫 Blocked Patterns

A regex blocklist catches dangerous commands before they execute:

Blocked Why
rm -rf / Obviously
:(){ :|:& };: Fork bomb. Nice try.
curl ... | sh Pipe-to-shell = remote code execution
cat ~/.ssh/id_rsa Reading private keys, .env, .pem, AWS creds
nc, ncat, socat, telnet Raw TCP β€” bypasses the egress proxy
mkfs, dd if= Disk destruction

Custom patterns can be added in config/tools.yml β†’ bash.blocked_patterns.

Layer 2: 🌐 Egress Proxy

Even if a command runs, it can't talk to arbitrary servers. An in-process HTTP forward proxy intercepts outbound traffic:

flowchart LR
    Cmd["🐚 curl github.com"] --> Proxy{"πŸ›‚ Proxy<br/>Allowed?"}
    Proxy -- "βœ… github.com" --> Internet["🌐 Internet"]
    Proxy -- "❌ evil.com" --> Block["🚫 403 Forbidden"]

    style Block fill:#ff6b6b,color:#fff
    style Internet fill:#51cf66,color:#fff
Loading

How it works:

  1. Before the agent loop starts, proxy.js spins up an HTTP server on 127.0.0.1 (random port)
  2. HTTP_PROXY and HTTPS_PROXY env vars are injected into child processes (both upper and lowercase β€” curl likes lowercase, wget likes uppercase, Python checks both 🀷)
  3. Every HTTP request and HTTPS CONNECT is checked against the hostname allowlist
  4. Allowed? Forwarded. Not on the list? 403 Forbidden, logged to the Lambda's console (visible in CloudWatch Logs alongside all other Lambda output).
  5. Proxy shuts down in the finally block β€” no leaked servers

The allowlist supports exact matches and wildcards:

# config/tools.yml
bash:
    proxy:
        enabled: true
        allowed_hosts:
            - github.com
            - api.github.com
            - "*.googleapis.com"   # All Google API subdomains
            - pypi.org
            - registry.npmjs.org

For the raw-socket holdouts (nc, socat, etc.) that ignore proxy env vars β€” those are caught by Layer 1's blocked patterns. Belt and suspenders.

Layer 3: πŸ“¦ Resource Limits

Commands run under ulimit constraints:

Resource Limit
Virtual memory 512 MB
Processes 64
File size 100 MB

Layer 4: ⏱️ Timeouts & Truncation

  • Commands time out after 30 seconds (configurable)
  • Output is capped at 16KB β€” if it's longer, you get the first 20% and last 80% with a [...truncated...] marker in between
  • PATH is restricted to /usr/local/bin:/usr/bin:/bin β€” no fishing around in weird directories

Layer 5: πŸ” Output Redaction

Even after all that, command output is scanned for leaked secrets before it goes back to the model:

Pattern Example
AWS access keys AKIA...
GitHub PATs ghp_..., gho_..., ghu_...
Slack tokens xoxb-..., xoxp-...
Anthropic / OpenAI keys sk-ant-..., sk-...
Private key headers -----BEGIN RSA PRIVATE KEY-----

Anything matching gets replaced with [REDACTED]. The model never sees the raw value.


πŸ—ƒοΈ Sessions & Storage

Thread locking prevents two simultaneous invocations from stepping on each other. Locks use S3 conditional writes (IfNoneMatch: '*') with UUID ownership β€” only the lock owner can release it. Stale locks (from crashed Lambdas) are taken over after expiry.

/tmp cleanup runs at the start of every invocation. Warm Lambda containers can have leftover files from previous runs β€” those get wiped before we start.

Storage archives (persisted /tmp between turns) use tar -h to dereference symlinks. This prevents a symlink attack where a crafted link could exfiltrate files outside /tmp. Archives are capped at 50MB (configurable).


πŸ” Token Storage (DynamoDB)

User tokens in DynamoDB get the full treatment:

  • πŸ”’ Encryption at rest β€” AWS-managed keys
  • βͺ Point-in-time recovery β€” Accidentally deleted tokens can be restored
  • ⏰ TTL auto-cleanup β€” Tokens expire after 365 days, OAuth state params after 10 minutes
  • 🏎️ Optimistic locking β€” Concurrent OAuth token refreshes don't race
  • 🎟️ CSRF protection β€” OAuth state params are one-time-use, consumed atomically via DeleteItem with ReturnValues

🧹 Input Validation

The little things that prevent injection attacks:

  • Token labels reject # and whitespace (prevents DynamoDB key injection)
  • OAuth error responses are HTML-escaped (no XSS via error messages)
  • Slack UI output escapes user labels in mrkdwn (no formatting injection)
  • Admin CLI uses execFileSync instead of execSync (no shell injection via user input)

πŸ—οΈ Infrastructure

  • Lambda container β€” Read-only filesystem (except /tmp), minimal installed tooling
  • IAM least privilege β€” The Lambda role can only access its own S3 bucket, DynamoDB table, Secrets Manager secret, and self-invoke
  • S3 β€” Public access blocked, SSL enforced, SSE-S3 encryption. Lifecycle rules auto-delete: locks after 1 day, everything else after 30 days
  • DynamoDB β€” PAY_PER_REQUEST (no pre-provisioned capacity to abuse), encrypted, PITR enabled
  • Deployment safety β€” env.js blocks sls deploy if you have uncommitted changes. Every deploy maps to a git commit.

πŸ“Š Resource Limits at a Glance

What Limit Why
Agentic loop turns 10 Prevent infinite tool loops
Budget per invocation $1.00 Cost protection
Lambda timeout 15 min AWS hard limit
Lock TTL 5.5 min Stale lock recovery
Command timeout 30s Runaway processes
Command output 16 KB Context window protection
Storage archive 50 MB Disk abuse prevention
Token TTL 365 days Credential hygiene
OAuth state TTL 10 min CSRF window
Skill catalog 16K chars Prompt budget

πŸ§€ The Swiss Cheese Model

No single layer is perfect. But they stack:

flowchart TB
    Attack["🎯 Attack Vector"] --> L0["πŸ“œ Prompt Safety Rules"]
    L0 --> L1["🏁 Budget + Turn Limits"]
    L1 --> L2["🚫 Blocked Patterns"]
    L2 --> L3["🌐 Egress Proxy"]
    L3 --> L4["πŸ“¦ Resource Limits"]
    L4 --> L5["πŸ”’ Env Stripping"]
    L5 --> L6["πŸ” Output Redaction"]
    L6 --> L7["πŸ–οΈ Lambda Isolation"]
    L7 --> Safe["βœ… Contained"]

    style Attack fill:#ff6b6b,color:#fff
    style L0 fill:#845ef7,color:#fff
    style Safe fill:#51cf66,color:#fff
Loading

Like Swiss cheese β€” each slice has holes, but stack enough slices and nothing gets through.

Security Model

Every layer in this stack is best-effort β€” none are airtight on their own. That's by design. The goal is defense in depth: pattern matching catches the obvious stuff, the proxy catches network exfiltration, env stripping prevents credential leakage, and Lambda's Firecracker micro-VM provides the hard isolation boundary at the bottom.

The overall security posture depends on all layers working together. If you're evaluating Claudia for your environment, the key questions are:

  • Is the egress allowlist tight enough? The default is conservative, but review it for your use case.
  • Are the blocked patterns sufficient? You can add custom patterns in config/tools.yml.
  • Is Lambda container isolation acceptable as the hard boundary? For most workloads, yes β€” Firecracker micro-VMs are battle-tested. For high-security environments, consider adding VPC network policies as an additional layer.

If you find a security issue, please report it responsibly β€” open an issue or reach out directly. We take these seriously and will respond quickly. πŸ§€

There aren't any published security advisories