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.
- πΊοΈ The Big Picture
- πͺ Getting In the Door
- π Secrets: Who Gets What
- π§ The Prompt Layer
- π Command Execution: Five Layers of "No"
- ποΈ Sessions & Storage
- π Token Storage (DynamoDB)
- π§Ή Input Validation
- ποΈ Infrastructure
- π Resource Limits at a Glance
- π§ The Swiss Cheese Model
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
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
@claudiamention fires two Slack events (app_mention+message). We pick one and skip the other.
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
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 afinallyblock. -
All AWS credentials and user tokens are stripped from child process environments. A bash command can't see your
AWS_SECRET_ACCESS_KEYor 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_aliasvalues (likeGOOGLE_WORKSPACE_CLI_TOKEN) only flow to tools that list the service.
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
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.
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.
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.
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
servicesconfig - 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.
When the model decides to run a bash command, it has to survive a gauntlet:
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.
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
How it works:
- Before the agent loop starts,
proxy.jsspins up an HTTP server on127.0.0.1(random port) HTTP_PROXYandHTTPS_PROXYenv vars are injected into child processes (both upper and lowercase βcurllikes lowercase,wgetlikes uppercase, Python checks both π€·)- Every HTTP request and HTTPS
CONNECTis checked against the hostname allowlist - Allowed? Forwarded. Not on the list? 403 Forbidden, logged to the Lambda's console (visible in CloudWatch Logs alongside all other Lambda output).
- Proxy shuts down in the
finallyblock β 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.orgFor the raw-socket holdouts (nc, socat, etc.) that ignore proxy env vars β those are caught by Layer 1's blocked patterns. Belt and suspenders.
Commands run under ulimit constraints:
| Resource | Limit |
|---|---|
| Virtual memory | 512 MB |
| Processes | 64 |
| File size | 100 MB |
- 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 PATHis restricted to/usr/local/bin:/usr/bin:/binβ no fishing around in weird directories
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.
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).
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
DeleteItemwithReturnValues
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
execFileSyncinstead ofexecSync(no shell injection via user input)
- 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.jsblockssls deployif you have uncommitted changes. Every deploy maps to a git commit.
| 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 |
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
Like Swiss cheese β each slice has holes, but stack enough slices and nothing gets through.
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. π§