Skip to content

Commit ba6d83c

Browse files
committed
Add SECURITY.md, LLM retry with backoff, pre-flight cost checks, and shell classifier improvements
- Document complete security model, permission modes, sandboxing, MCP authentication, credential handling, and concurrent access in SECURITY.md - Add exponential-backoff retry with jitter (LLMRetryConfig) to all three LLM adapters for transient HTTP errors (429, 5xx, network failures) - Add estimate_cost_preflight and RunBudget.check_cost_preflight to reject calls that would exceed the cost budget before making the request - Replace naive token-based shell operator detection with quote-aware _has_unquoted_shell_operator to correctly classify quoted operators as inspect - Reject apply_patch when old text appears more than once in the target file - Add property-test-style shell classification suite with 40+ test cases
1 parent 52b3289 commit ba6d83c

6 files changed

Lines changed: 505 additions & 31 deletions

File tree

SECURITY.md

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Security
2+
3+
## Threat Model
4+
5+
TeaAgent is a governance-first agent harness that gives an LLM-controlled agent access to
6+
a workspace directory through registered tools. The primary threats are:
7+
8+
1. **Model misbehavior** — the LLM generates tool calls that escape the workspace,
9+
execute dangerous shell commands, or exfiltrate data.
10+
2. **Untrusted MCP clients** — remote MCP clients that attempt unauthorized tool
11+
execution or credential theft.
12+
3. **Network attackers** — attackers on the same network that intercept MCP HTTP
13+
traffic or replay authenticated sessions.
14+
4. **Multi-tenant workspace collision** — concurrent agent runs on the same workspace
15+
root corrupting each other's state.
16+
5. **Prompt injection** — attacker-controlled content in workspace files influencing
17+
the agent's decision-making loop.
18+
19+
TeaAgent assumes the LLM provider and local process boundary are trusted. It does
20+
**not** protect against a compromised Python runtime, operating system, or LLM provider
21+
infrastructure.
22+
23+
## Permission Modes
24+
25+
| Mode | Read | File Write | Shell Mutate | Approval Required |
26+
|-----------------------|------|------------|-------------|--------------------|
27+
| `read-only` | Yes | No | No | N/A |
28+
| `workspace-write` | Yes | Yes | No | N/A |
29+
| `prompt` | Yes | Conditional| Conditional | Human-in-the-loop |
30+
| `allow` | Yes | Yes | Yes | Session-scoped |
31+
| `danger-full-access` | Yes | Yes | Yes | None |
32+
33+
Even in `danger-full-access`, the harness enforces:
34+
- Workspace path confinement (tools reject `../`, absolute paths, symlink escapes)
35+
- File size limits (`max_read_bytes`, `max_write_bytes`, `max_shell_output_bytes`)
36+
- Shell command size limits (`max_shell_command_bytes`)
37+
- Shell command timeout ceiling (`max_shell_timeout_seconds`)
38+
- Iteration and tool-call budgets
39+
40+
## Shell Sandbox
41+
42+
Shell commands are classified as **inspect** or **mutate** before execution:
43+
44+
- **Inspect**: `pwd`, `ls`, `find`, `rg`, `grep`, `cat`, `head`, `tail`, `wc`,
45+
safe `git` subcommands (`status`, `diff`, `log`, `show`, `branch`, `grep`)
46+
- **Mutate**: everything else
47+
48+
### Classification Algorithm
49+
50+
The classifier (`workspace_tools.py:classify_shell_command_policy`) uses quote-aware
51+
scanning to detect unquoted shell operators (`>`, `<`, `|`, `&&`, `;`, `` ` ``, `$(`).
52+
Quoted operators (e.g., `git log --grep='>'`) are correctly classified as inspect
53+
because the shell treats them as string content, not operators.
54+
55+
**Property tests** (`tests/test_workspace_tools.py:ShellClassifierPropertyTests`)
56+
verify that:
57+
- All inspect commands are classified as inspect
58+
- All mutate commands are classified as mutate
59+
- Quoted operators do not trigger mutate
60+
- Actual redirect/pipe/chain operators trigger mutate
61+
- Command substitution triggers mutate
62+
- Workspace-escape paths trigger mutate
63+
64+
### Inspect Execution
65+
66+
Inspect commands are executed via `shlex.split()` with `shell=False`, which:
67+
- Prevents shell metacharacter expansion
68+
- Prevents command chaining (the `;`, `&&`, `||` operators are rejected by the
69+
classifier *before* reaching execution)
70+
- Rejects workspace-escaping arguments (`/`, `~`, `..`)
71+
72+
### Known Limitations
73+
74+
- The classifier uses a heuristic approach. An adversarial prompt may discover edge
75+
cases. Report findings as described below.
76+
- `shell_arg_escapes_workspace` only checks literal path prefixes; it does not
77+
expand environment variables or glob patterns.
78+
- Commands that match the inspect allowlist but spawn subprocesses (e.g., `git`
79+
hooks, custom pager configurations) can execute arbitrary code.
80+
81+
## MCP HTTP Security
82+
83+
### Bind Enforcement
84+
85+
The MCP HTTP server enforces authentication at two layers:
86+
87+
1. **CLI layer** (`cli/__init__.py:mcp_serve_command`): refuses to start on a
88+
non-loopback host (`0.0.0.0`, `::`, external IPs) unless `--auth-token` or
89+
`--oauth-issuer` is provided.
90+
2. **Library layer** (`mcp_http.py:build_mcp_http_server`): raises `ValueError` when
91+
constructed with a non-loopback host and no `auth_token` or `oauth_server`.
92+
93+
### Authentication Options
94+
95+
- **Bearer token**: static shared secret via `--auth-token`. Every request must
96+
include `Authorization: Bearer <token>`.
97+
- **OAuth 2.1 with DPoP**: proof-of-possession token binding via
98+
`--oauth-issuer` + `--oauth-signing-key`. Access tokens are bound to the
99+
client's DPoP key, preventing token replay by network observers.
100+
101+
### Origin Control
102+
103+
Pass `--allowed-origin` (repeatable) to restrict browser-originated requests.
104+
Without it, all origins are accepted.
105+
106+
### Transport
107+
108+
The MCP HTTP server does not support TLS natively. When serving on non-loopback
109+
hosts, place a reverse proxy (nginx, Caddy, Cloudflare Tunnel) with TLS termination
110+
in front of the server.
111+
112+
## Code Mode
113+
114+
Code Mode executes LLM-generated Python code in a **child process** sandbox with:
115+
116+
- AST allow-list validation (limited node types, restricted builtins)
117+
- `RLIMIT_CPU` (CPU time limit)
118+
- Wall-clock timeout (process termination)
119+
- Best-effort `RLIMIT_AS` (memory limit; silently no-op on macOS)
120+
121+
Code Mode is **not** a production sandbox:
122+
- No seccomp, cgroups, namespaces, or VM-level isolation
123+
- `exec()` runs inside the same Python interpreter
124+
- Memory limits are advisory on macOS
125+
126+
For production use, defer Code Mode code execution to a container, V8 isolate, or
127+
managed execution service.
128+
129+
## File System Access
130+
131+
All workspace tools enforce path confinement through `resolve_workspace_path()`:
132+
- Paths are resolved relative to the workspace root
133+
- `../` escapes, absolute paths, and symlink escapes are rejected
134+
- `.git` directories are excluded from `list_files` and `search_text`
135+
136+
Write tools (`workspace_write_file`, `workspace_apply_patch`, `workspace_edit_at_hash`)
137+
enforce `max_write_bytes` before any write occurs.
138+
139+
## Edit Safety
140+
141+
- `workspace_apply_patch` requires the `old` text to appear **exactly once** in the
142+
target file. If it appears multiple times, the edit is rejected — the caller must
143+
provide more context or use `workspace_edit_at_hash`.
144+
- `workspace_edit_at_hash` uses CRC32 line anchors (`LINE#HASH|content`). If the
145+
hash of the target line has changed (stale read), the edit is rejected.
146+
147+
## Audit Trail
148+
149+
Every tool call, approval decision, iteration, and final result is recorded in the
150+
audit log with:
151+
- Per-run JSONL persistence (file mode `0600`)
152+
- Argument redaction for sensitive keys (API keys, passwords, tokens, secrets)
153+
- Result content redaction for stdout/stderr
154+
- Truncation of long strings at 20,000 characters
155+
156+
Audit logs are append-only. TeaAgent does not rotate or expire audit files — set up
157+
external log rotation for long-running deployments.
158+
159+
## Credential Handling
160+
161+
- LLM API keys are read from environment variables only; never from files or
162+
command-line arguments
163+
- MCP `--auth-token` and `--oauth-signing-key` are command-line arguments (visible
164+
in `ps`). Prefer environment variables or a secrets manager for production
165+
- Audit logs redact keys matching `api_key`, `authorization`, `credential`,
166+
`password`, `secret`, `token` in any casing
167+
168+
## LLM Provider Resilience
169+
170+
- The LLM adapter layer includes configurable exponential-backoff retry
171+
(`LLMRetryConfig`) for transient errors (HTTP 429, 5xx, connection failures).
172+
- Cost budget pre-flight (`RunBudget.check_cost_preflight`) estimates the maximum
173+
possible cost of an LLM call before spending money, rejecting calls that would
174+
exceed the budget.
175+
- Every run has hard limits on iterations and tool calls, enforced by `AgentRunner`.
176+
177+
## Concurrent Access
178+
179+
`RunStore`, `UltraworkStore`, and `MemoryCatalog` use plain JSONL file append
180+
without file locking. Concurrent agent runs on the same workspace root may produce
181+
interleaved log lines. For production multi-worker scenarios:
182+
- Use separate workspace roots per worker
183+
- Or replace the JSONL backend with a transactional store (SQLite, PostgreSQL)
184+
185+
## Reporting a Vulnerability
186+
187+
Report security vulnerabilities to the project maintainers. Do not file public
188+
issues for security-sensitive findings.
189+
190+
**Scope**: vulnerabilities in TeaAgent's harness logic, tool governance, sandbox
191+
escape vectors, or authentication bypasses.
192+
193+
**Out of scope**: vulnerabilities in LLM providers, the Python standard library,
194+
the operating system, or upstream dependencies.

teaagent/budget.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from dataclasses import dataclass
44

5+
from teaagent.errors import BudgetExceededError
6+
from teaagent.llm import estimate_cost_preflight
7+
58

69
@dataclass(frozen=True)
710
class RunBudget:
@@ -16,3 +19,19 @@ def validate(self) -> None:
1619
raise ValueError('max_tool_calls must be >= 0')
1720
if self.max_estimated_cost_cents < 0:
1821
raise ValueError('max_estimated_cost_cents must be >= 0')
22+
23+
def check_cost_preflight(
24+
self,
25+
provider: str,
26+
model: str,
27+
approx_input_chars: int,
28+
max_output_tokens: int,
29+
) -> None:
30+
estimated = estimate_cost_preflight(
31+
provider, model, approx_input_chars, max_output_tokens
32+
)
33+
if estimated > self.max_estimated_cost_cents:
34+
raise BudgetExceededError(
35+
f'pre-flight cost estimate {estimated:.2f}c exceeds budget '
36+
f'{self.max_estimated_cost_cents}c'
37+
)

teaagent/chat_agent.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from teaagent.budget import RunBudget
1111
from teaagent.context import ContextCompactor
1212
from teaagent.heartbeat import Heartbeat
13-
from teaagent.llm import LLMAdapter, LLMMessage, LLMRequest
13+
from teaagent.llm import (
14+
LLMAdapter,
15+
LLMMessage,
16+
LLMRequest,
17+
)
1418
from teaagent.memory import MemoryCatalog, memory_entries_to_prompt
1519
from teaagent.policy import ApprovalPolicy, PermissionMode
1620
from teaagent.prompt import (
@@ -52,6 +56,7 @@ def __init__(
5256
*,
5357
adapter: LLMAdapter,
5458
registry: ToolRegistry,
59+
budget: Optional[RunBudget] = None,
5560
project_instructions: str = '',
5661
model: Optional[str] = None,
5762
task_spec: Optional[str] = None,
@@ -60,6 +65,7 @@ def __init__(
6065
) -> None:
6166
self.adapter = adapter
6267
self.registry = registry
68+
self.budget = budget
6369
self.project_instructions = project_instructions
6470
self.model = model
6571
self.task_spec = task_spec
@@ -74,6 +80,17 @@ def decide(self, context: dict) -> Decision:
7480
project_instructions=self.project_instructions,
7581
task_spec=self.task_spec,
7682
)
83+
84+
if self.budget is not None and self.model:
85+
resolved_model = self.model
86+
approx_input = len(prompt.system) + len(prompt.user)
87+
self.budget.check_cost_preflight(
88+
provider=self.adapter.provider,
89+
model=resolved_model,
90+
approx_input_chars=approx_input,
91+
max_output_tokens=1024,
92+
)
93+
7794
response = self.adapter.complete(
7895
LLMRequest(
7996
system=prompt.system,
@@ -108,9 +125,13 @@ def run_chat_agent(
108125
memories = memory_entries_to_prompt(
109126
MemoryCatalog(config.root).search(task, limit=config.memory_limit)
110127
)
128+
runner_budget = RunBudget(
129+
max_iterations=config.max_iterations, max_tool_calls=config.max_tool_calls
130+
)
111131
engine = ModelDecisionEngine(
112132
adapter=adapter,
113133
registry=tool_registry,
134+
budget=runner_budget,
114135
project_instructions=project_instructions,
115136
model=config.model,
116137
task_spec=task_spec,
@@ -121,9 +142,7 @@ def run_chat_agent(
121142
runner = AgentRunner(
122143
registry=tool_registry,
123144
audit=audit_logger,
124-
budget=RunBudget(
125-
max_iterations=config.max_iterations, max_tool_calls=config.max_tool_calls
126-
),
145+
budget=runner_budget,
127146
approval_policy=ApprovalPolicy(
128147
approved_call_ids=config.approved_call_ids,
129148
allow_all_destructive=config.allow_destructive,

0 commit comments

Comments
 (0)