Skip to content

Commit 5330bfb

Browse files
anandgupta42claude
andcommitted
fix: harden path sandboxing with symlink protection, safe defaults, and sensitive file guards
- Add `Filesystem.containsReal()` with `realpathSync` to prevent symlink escape attacks (same class of bug as Codex GHSA-w5fx-fh39-j5rw and Claude Code CVE-2025-54794) - Add `isAbsolute(rel)` check to `Filesystem.contains()` for Windows cross-drive bypass - Update `Instance.containsPath()` to use symlink-aware `containsReal()` - Add safe permission defaults: deny `rm -rf`, `git push --force`, `git reset --hard`, `DROP DATABASE`, `TRUNCATE` out of the box - Add `Protected.isSensitiveWrite()` to detect writes to `.git/`, `.ssh/`, `.aws/`, `.env*`, credential files even inside the project boundary - Add `assertSensitiveWrite()` guard to write, edit, and apply_patch tools - Remove resolved TODO comments from `file/index.ts` - Update SECURITY.md, permissions docs, and security FAQ with practical guidance - Add 94 tests including 62 e2e tests covering symlink attacks, path traversal, sensitive file detection, and combined attack scenarios Closes #202 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f896814 commit 5330bfb

14 files changed

Lines changed: 935 additions & 17 deletions

File tree

SECURITY.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,29 @@ submit one that will be an automatic ban from the project.
1212

1313
Altimate Code is an AI-powered data engineering coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.
1414

15-
### No Sandbox
15+
### Permission System
1616

17-
Altimate Code does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
17+
Altimate Code includes a permission system that prompts for confirmation before the agent executes commands, writes files, or accesses resources outside your project. You can configure each tool as `"allow"`, `"ask"`, or `"deny"` — and use pattern-based rules to fine-tune behavior (e.g., allow `dbt run` but deny `rm *`).
1818

19-
If you need true isolation, run Altimate Code inside a Docker container or VM.
19+
The permission system is designed to keep you informed and in control of what the agent does. It includes:
20+
21+
- **Per-tool and per-pattern controls** with wildcard matching
22+
- **Per-agent permission overrides** (e.g., restrict `analyst` to read-only)
23+
- **External directory detection** that prompts when the agent accesses files outside your project
24+
- **Path traversal protection** that blocks attempts to escape the project directory
25+
- **Doom loop detection** that alerts you when the agent repeats failed actions
26+
27+
However, the permission system operates at the application level. It does not provide OS-level sandboxing — the process runs with your user permissions. For high-security environments or when working with sensitive production systems, we recommend running Altimate Code inside a Docker container or VM for additional isolation.
2028

2129
### Server Mode
2230

23-
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
31+
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server any functionality it provides is not a vulnerability.
2432

2533
### Out of Scope
2634

2735
| Category | Rationale |
2836
| ------------------------------- | ----------------------------------------------------------------------- |
2937
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
30-
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
3138
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
3239
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
3340
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |

docs/docs/configure/permissions.md

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ For tools that accept arguments (like `bash`), use pattern matching:
4949
}
5050
```
5151

52-
Patterns are matched in order -- first match wins. Use `*` as a wildcard.
52+
Patterns are matched in order — last matching rule wins. Use `*` as a wildcard. Place your most specific rules first and your catch-all `"*"` rule last.
5353

5454
## Per-Agent Permissions
5555

@@ -104,3 +104,125 @@ Set permissions via environment variable:
104104
export ALTIMATE_CLI_PERMISSION='{"bash":"deny","write":"deny"}'
105105
altimate
106106
```
107+
108+
## Recommended Configurations
109+
110+
### Data Engineering (Default — Balanced)
111+
112+
A good starting point for most data engineering workflows. Allows safe read operations, prompts for writes and commands:
113+
114+
```json
115+
{
116+
"permission": {
117+
"read": "allow",
118+
"glob": "allow",
119+
"grep": "allow",
120+
"list": "allow",
121+
"edit": "ask",
122+
"write": "ask",
123+
"bash": {
124+
"dbt *": "allow",
125+
"git status": "allow",
126+
"git diff *": "allow",
127+
"git log *": "allow",
128+
"ls *": "allow",
129+
"cat *": "allow",
130+
"rm *": "deny",
131+
"DROP *": "deny",
132+
"DELETE *": "deny",
133+
"TRUNCATE *": "deny",
134+
"*": "ask"
135+
},
136+
"external_directory": "ask"
137+
}
138+
}
139+
```
140+
141+
### Strict (Production-Adjacent Work)
142+
143+
When working near production systems. Blocks destructive operations entirely and requires confirmation for everything else:
144+
145+
```json
146+
{
147+
"permission": {
148+
"read": "allow",
149+
"glob": "allow",
150+
"grep": "allow",
151+
"list": "allow",
152+
"edit": "ask",
153+
"write": "ask",
154+
"bash": {
155+
"dbt *": "ask",
156+
"git status": "allow",
157+
"rm *": "deny",
158+
"DROP *": "deny",
159+
"DELETE *": "deny",
160+
"TRUNCATE *": "deny",
161+
"ALTER *": "deny",
162+
"git push *": "deny",
163+
"git reset *": "deny",
164+
"*": "ask"
165+
},
166+
"external_directory": "deny"
167+
}
168+
}
169+
```
170+
171+
### Per-Agent Lockdown
172+
173+
Give each agent only the permissions it needs:
174+
175+
```json
176+
{
177+
"agent": {
178+
"analyst": {
179+
"permission": {
180+
"write": "deny",
181+
"edit": "deny",
182+
"bash": {
183+
"SELECT *": "allow",
184+
"dbt docs *": "allow",
185+
"*": "deny"
186+
}
187+
}
188+
},
189+
"builder": {
190+
"permission": {
191+
"bash": {
192+
"dbt *": "allow",
193+
"git *": "ask",
194+
"DROP *": "deny",
195+
"*": "ask"
196+
}
197+
}
198+
}
199+
}
200+
}
201+
```
202+
203+
## How Permissions Work
204+
205+
When the agent wants to use a tool, the permission system evaluates your rules in order:
206+
207+
1. **Config rules** — from `altimate-code.json`
208+
2. **Agent-level rules** — per-agent overrides
209+
3. **Session approvals** — patterns you've approved with "Allow always" during the current session
210+
211+
If a rule matches, it applies. If no rule matches, the default is `"ask"` — you'll be prompted.
212+
213+
When prompted, you have three choices:
214+
215+
| Choice | Effect |
216+
|--------|--------|
217+
| **Allow once** | Approves this single action |
218+
| **Allow always** | Approves this pattern for the rest of the session |
219+
| **Reject** | Blocks the action (optionally with feedback for the agent) |
220+
221+
"Allow always" approvals persist for your current session only. They reset when you restart Altimate Code.
222+
223+
## Tips
224+
225+
- **Start with `"ask"` and relax as you build confidence.** You can always approve patterns with "Allow always" during a session.
226+
- **Use `"deny"` for truly dangerous commands** like `rm *`, `DROP *`, `git push --force *`, and `git reset --hard *`. These are blocked even if other rules would allow them.
227+
- **Use per-agent permissions** to enforce least-privilege. An analyst doesn't need write access. A builder doesn't need `DROP`.
228+
- **Review the prompt before approving.** The TUI shows you exactly what will run — including diffs for file edits and the full command for bash operations.

docs/docs/security-faq.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,48 @@ For additional safety:
198198
- Run against a **staging environment** before production
199199
- Use the `analyst` agent with restricted permissions for ad-hoc queries
200200

201+
## What protections does Altimate Code have for file access?
202+
203+
Altimate Code includes several layers of protection to keep the agent within your project:
204+
205+
- **Project boundary enforcement** — File operations check that paths stay within your project directory (or git worktree for monorepos). Attempts to read or write outside the project trigger an `external_directory` permission prompt.
206+
- **Path traversal blocking** — Paths containing `../` sequences that would escape the project are rejected with an "Access denied" error.
207+
- **Bash command analysis** — The bash tool parses commands with tree-sitter to detect file operations (`rm`, `cp`, `mv`, etc.) targeting paths outside your project, and prompts for permission.
208+
- **Non-git project safety** — For projects outside a git repository, the boundary is strictly the working directory (not the entire filesystem).
209+
210+
These protections operate at the application level. For additional isolation, you can run Altimate Code inside a Docker container or VM.
211+
212+
## Best practices for staying safe
213+
214+
1. **Review before approving.** The permission prompt shows you exactly what will happen — diffs for file edits, the full command for bash. Take a moment to read it.
215+
216+
2. **Deny destructive commands.** Add these to your `altimate-code.json` to block the most dangerous operations regardless of other rules:
217+
218+
```json
219+
{
220+
"permission": {
221+
"bash": {
222+
"rm -rf *": "deny",
223+
"DROP *": "deny",
224+
"DELETE *": "deny",
225+
"git push --force *": "deny",
226+
"git reset --hard *": "deny",
227+
"*": "ask"
228+
}
229+
}
230+
}
231+
```
232+
233+
3. **Use per-agent permissions.** Give each agent only what it needs. The `analyst` agent doesn't need write access. See [Permissions](configure/permissions.md) for examples.
234+
235+
4. **Use read-only database credentials for exploration.** When using the agent for analysis or ad-hoc queries, connect with a read-only database user.
236+
237+
5. **Work on a branch.** Let the agent work on a feature branch so you can review changes before merging. Git gives you a full safety net.
238+
239+
6. **Back up before large operations.** If the agent is about to make sweeping changes, commit your current state first. You can always `git stash` or revert.
240+
241+
7. **Use Docker for sensitive environments.** If you're working with production systems or sensitive data, running Altimate Code in a container provides OS-level isolation on top of the permission system.
242+
201243
## Where should I report security vulnerabilities?
202244

203245
**Do not open public GitHub issues for security vulnerabilities.** Instead, email **security@altimate.ai** with a description, reproduction steps, and your severity assessment. You'll receive acknowledgment within 48 hours. See the full [Security Policy](https://github.com/AltimateAI/altimate-code/blob/main/SECURITY.md) for details.

packages/opencode/src/agent/agent.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,23 @@ export namespace Agent {
8080
"*.env.*": "ask",
8181
"*.env.example": "allow",
8282
},
83+
// Safety defaults: deny destructive commands that are rarely intentional.
84+
// Users can override these in altimate-code.json if needed.
85+
bash: {
86+
"rm -rf *": "deny",
87+
"rm -fr *": "deny",
88+
"rmdir /s *": "deny",
89+
"git push --force *": "deny",
90+
"git push -f *": "deny",
91+
"git reset --hard *": "deny",
92+
"git clean -fd *": "deny",
93+
"git clean -f *": "deny",
94+
"git checkout -- .": "deny",
95+
"DROP DATABASE *": "deny",
96+
"DROP SCHEMA *": "deny",
97+
"TRUNCATE *": "deny",
98+
"*": "ask",
99+
},
83100
})
84101
const user = PermissionNext.fromConfig(cfg.permission ?? {})
85102

packages/opencode/src/file/index.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,8 +500,6 @@ export namespace File {
500500
const project = Instance.project
501501
const full = path.join(Instance.directory, file)
502502

503-
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
504-
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
505503
if (!Instance.containsPath(full)) {
506504
throw new Error(`Access denied: path escapes project directory`)
507505
}
@@ -580,8 +578,6 @@ export namespace File {
580578
}
581579
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
582580

583-
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
584-
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
585581
if (!Instance.containsPath(resolved)) {
586582
throw new Error(`Access denied: path escapes project directory`)
587583
}

packages/opencode/src/file/protected.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes"
3737

3838
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
3939

40+
/**
41+
* Directories and file patterns that should require explicit permission before
42+
* write operations, even when they are located inside the project boundary.
43+
* These contain credentials, version control state, or configuration that
44+
* should not be modified without the user's awareness.
45+
*/
46+
const SENSITIVE_DIRS = [
47+
".git",
48+
".ssh",
49+
".gnupg",
50+
".aws",
51+
".azure",
52+
".gcloud",
53+
".kube",
54+
".docker",
55+
]
56+
57+
const SENSITIVE_FILES = [
58+
".env",
59+
".env.local",
60+
".env.production",
61+
".env.staging",
62+
".env.development",
63+
".npmrc",
64+
".pypirc",
65+
".netrc",
66+
"credentials.json",
67+
"service-account.json",
68+
"id_rsa",
69+
"id_ed25519",
70+
]
71+
4072
export namespace Protected {
4173
/** Directory basenames to skip when scanning the home directory. */
4274
export function names(): ReadonlySet<string> {
@@ -56,4 +88,30 @@ export namespace Protected {
5688
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
5789
return []
5890
}
91+
92+
/**
93+
* Check if a file path targets a sensitive directory or file that should
94+
* require explicit user permission before modification, even inside the project.
95+
* Returns the name of the matched sensitive pattern, or undefined if not sensitive.
96+
*/
97+
export function isSensitiveWrite(filepath: string): string | undefined {
98+
const segments = filepath.split(path.sep)
99+
const filename = segments[segments.length - 1] ?? ""
100+
101+
// Check if any path segment is a sensitive directory
102+
for (const segment of segments) {
103+
if (SENSITIVE_DIRS.includes(segment)) {
104+
return segment
105+
}
106+
}
107+
108+
// Check if the filename matches a sensitive file pattern
109+
for (const pattern of SENSITIVE_FILES) {
110+
if (filename === pattern) return pattern
111+
// Match .env.* variants (e.g., .env.local.bak)
112+
if (pattern === ".env" && filename.startsWith(".env.")) return filename
113+
}
114+
115+
return undefined
116+
}
59117
}

packages/opencode/src/project/instance.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,15 @@ export const Instance = {
9393
/**
9494
* Check if a path is within the project boundary.
9595
* Returns true if path is inside Instance.directory OR Instance.worktree.
96+
* Uses symlink-aware resolution to prevent symlink escape attacks.
9697
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
9798
*/
9899
containsPath(filepath: string) {
99-
if (Filesystem.contains(Instance.directory, filepath)) return true
100+
if (Filesystem.containsReal(Instance.directory, filepath)) return true
100101
// Non-git projects set worktree to "/" which would match ANY absolute path.
101102
// Skip worktree check in this case to preserve external_directory permissions.
102103
if (Instance.worktree === "/") return false
103-
return Filesystem.contains(Instance.worktree, filepath)
104+
return Filesystem.containsReal(Instance.worktree, filepath)
104105
},
105106
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
106107
return State.create(() => Instance.directory, init, dispose)

packages/opencode/src/tool/apply_patch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { FileWatcher } from "../file/watcher"
77
import { Instance } from "../project/instance"
88
import { Patch } from "../patch"
99
import { createTwoFilesPatch, diffLines } from "diff"
10-
import { assertExternalDirectory } from "./external-directory"
10+
import { assertExternalDirectory, assertSensitiveWrite } from "./external-directory"
1111
import { trimDiff } from "./edit"
1212
import { LSP } from "../lsp"
1313
import { Filesystem } from "../util/filesystem"
@@ -60,6 +60,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
6060
for (const hunk of hunks) {
6161
const filePath = path.resolve(Instance.directory, hunk.path)
6262
await assertExternalDirectory(ctx, filePath)
63+
await assertSensitiveWrite(ctx, filePath)
6364

6465
switch (hunk.type) {
6566
case "add": {

packages/opencode/src/tool/edit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { FileTime } from "../file/time"
1616
import { Filesystem } from "../util/filesystem"
1717
import { Instance } from "../project/instance"
1818
import { Snapshot } from "@/snapshot"
19-
import { assertExternalDirectory } from "./external-directory"
19+
import { assertExternalDirectory, assertSensitiveWrite } from "./external-directory"
2020

2121
const MAX_DIAGNOSTICS_PER_FILE = 20
2222

@@ -52,6 +52,7 @@ export const EditTool = Tool.define("edit", {
5252

5353
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
5454
await assertExternalDirectory(ctx, filePath)
55+
await assertSensitiveWrite(ctx, filePath)
5556

5657
let diff = ""
5758
let contentOld = ""

0 commit comments

Comments
 (0)