diff --git a/SECURITY.md b/SECURITY.md index 524826c90b..5bd33d5477 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,22 +12,29 @@ submit one that will be an automatic ban from the project. 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. -### No Sandbox +### Permission System -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. +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 *`). -If you need true isolation, run Altimate Code inside a Docker container or VM. +The permission system is designed to keep you informed and in control of what the agent does. It includes: + +- **Per-tool and per-pattern controls** with wildcard matching +- **Per-agent permission overrides** (e.g., restrict `analyst` to read-only) +- **External directory detection** that prompts when the agent accesses files outside your project +- **Path traversal protection** that blocks attempts to escape the project directory +- **Doom loop detection** that alerts you when the agent repeats failed actions + +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. ### Server Mode -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. +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. ### Out of Scope | Category | Rationale | | ------------------------------- | ----------------------------------------------------------------------- | | **Server access when opted-in** | If you enable server mode, API access is expected behavior | -| **Sandbox escapes** | The permission system is not a sandbox (see above) | | **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | | **MCP server behavior** | External MCP servers you configure are outside our trust boundary | | **Malicious config files** | Users control their own config; modifying it is not an attack vector | diff --git a/docs/docs/configure/permissions.md b/docs/docs/configure/permissions.md index 8fb9df7b24..e6b5658fdd 100644 --- a/docs/docs/configure/permissions.md +++ b/docs/docs/configure/permissions.md @@ -38,18 +38,20 @@ For tools that accept arguments (like `bash`), use pattern matching: { "permission": { "bash": { + "*": "ask", "dbt *": "allow", "git status": "allow", "git diff *": "allow", "rm *": "deny", - "DROP *": "deny", - "*": "ask" + "DROP *": "deny" } } } ``` -Patterns are matched in order -- first match wins. Use `*` as a wildcard. +Patterns are matched in order — **last matching rule wins**. Use `*` as a wildcard. Place your catch-all `"*"` rule first and more specific rules after it. + +For example, with `"*": "ask"` first and `"rm *": "deny"` after it, all `rm` commands are denied while everything else prompts. If you put `"*": "ask"` last, it would override the deny rule. ## Per-Agent Permissions @@ -104,3 +106,125 @@ Set permissions via environment variable: export ALTIMATE_CLI_PERMISSION='{"bash":"deny","write":"deny"}' altimate ``` + +## Recommended Configurations + +### Data Engineering (Default — Balanced) + +A good starting point for most data engineering workflows. Allows safe read operations, prompts for writes and commands: + +```json +{ + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": "ask", + "write": "ask", + "bash": { + "*": "ask", + "dbt *": "allow", + "git status": "allow", + "git diff *": "allow", + "git log *": "allow", + "ls *": "allow", + "cat *": "allow", + "rm *": "deny", + "DROP *": "deny", + "DELETE *": "deny", + "TRUNCATE *": "deny" + }, + "external_directory": "ask" + } +} +``` + +### Strict (Production-Adjacent Work) + +When working near production systems. Blocks destructive operations entirely and requires confirmation for everything else: + +```json +{ + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": "ask", + "write": "ask", + "bash": { + "*": "ask", + "dbt *": "ask", + "git status": "allow", + "rm *": "deny", + "DROP *": "deny", + "DELETE *": "deny", + "TRUNCATE *": "deny", + "ALTER *": "deny", + "git push *": "deny", + "git reset *": "deny" + }, + "external_directory": "deny" + } +} +``` + +### Per-Agent Lockdown + +Give each agent only the permissions it needs: + +```json +{ + "agent": { + "analyst": { + "permission": { + "write": "deny", + "edit": "deny", + "bash": { + "SELECT *": "allow", + "dbt docs *": "allow", + "*": "deny" + } + } + }, + "builder": { + "permission": { + "bash": { + "*": "ask", + "dbt *": "allow", + "git *": "ask", + "DROP *": "deny" + } + } + } + } +} +``` + +## How Permissions Work + +When the agent wants to use a tool, the permission system evaluates your rules in order: + +1. **Config rules** — from `altimate-code.json` +2. **Agent-level rules** — per-agent overrides +3. **Session approvals** — patterns you've approved with "Allow always" during the current session + +If a rule matches, it applies. If no rule matches, the default is `"ask"` — you'll be prompted. + +When prompted, you have three choices: + +| Choice | Effect | +|--------|--------| +| **Allow once** | Approves this single action | +| **Allow always** | Approves this pattern for the rest of the session | +| **Reject** | Blocks the action (optionally with feedback for the agent) | + +"Allow always" approvals persist for your current session only. They reset when you restart Altimate Code. + +## Tips + +- **Start with `"ask"` and relax as you build confidence.** You can always approve patterns with "Allow always" during a session. +- **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. +- **Use per-agent permissions** to enforce least-privilege. An analyst doesn't need write access. A builder doesn't need `DROP`. +- **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. diff --git a/docs/docs/security-faq.md b/docs/docs/security-faq.md index 078918875c..2abe309340 100644 --- a/docs/docs/security-faq.md +++ b/docs/docs/security-faq.md @@ -33,11 +33,11 @@ By default, destructive operations like `bash`, `write`, and `edit` require conf { "permission": { "bash": { + "*": "ask", "dbt *": "allow", "git status": "allow", "DROP *": "deny", - "rm *": "deny", - "*": "ask" + "rm *": "deny" } } } @@ -51,11 +51,11 @@ Yes. Use pattern-based permissions to deny destructive SQL: { "permission": { "bash": { + "*": "ask", "DROP *": "deny", "DELETE *": "deny", "TRUNCATE *": "deny", - "ALTER *": "deny", - "*": "ask" + "ALTER *": "deny" } } } @@ -198,6 +198,89 @@ For additional safety: - Run against a **staging environment** before production - Use the `analyst` agent with restricted permissions for ad-hoc queries +## What protections does Altimate Code have for file access? + +Altimate Code includes several layers of protection to keep the agent within your project: + +- **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. +- **Symlink-aware path resolution** — Symlinks inside the project that point outside are detected and blocked. This prevents an agent from reading or writing outside your project through symlinks. +- **Path traversal blocking** — Paths containing `../` sequences that would escape the project are rejected with an "Access denied" error. +- **Sensitive file protection** — Writing to credential files (`.env`, `.ssh/`, `.aws/`, private keys) triggers a confirmation prompt, even inside the project. See [below](#why-am-i-being-prompted-to-edit-env-files) for details. +- **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. +- **Non-git project safety** — For projects outside a git repository, the boundary is strictly the working directory (not the entire filesystem). + +These protections operate at the application level. For additional isolation, you can run Altimate Code inside a Docker container or VM. + +## Why am I being prompted to edit `.env` files? + +Altimate Code prompts before modifying files that commonly contain credentials or security-sensitive configuration, even when they're inside your project. This includes: + +| Pattern | Examples | +|---------|----------| +| **Environment files** | `.env`, `.env.local`, `.env.production`, `.env.staging` | +| **Credential files** | `credentials.json`, `service-account.json`, `.npmrc`, `.pypirc`, `.netrc`, `.pgpass` | +| **Secret key directories** | `.ssh/`, `.aws/`, `.gnupg/`, `.gcloud/`, `.kube/`, `.docker/` | +| **Private keys** | `*.pem`, `*.key`, `*.p12`, `*.pfx` | +| **Version control** | `.git/config`, `.git/hooks/*` | + +When you see this prompt: + +- **"Allow once"** — approves this single edit +- **"Allow always"** — approves edits to this specific file for the rest of the session (resets on restart) + +If you frequently edit `.env` files and find the prompts disruptive, click "Allow always" on the first prompt for each file — you won't be asked again for that file during your session. + +!!! tip + This protection does **not** block reading these files — only writing. The agent can still read your `.env` to understand configuration without prompting. + +## What commands are blocked or prompted by default? + +Altimate Code applies safe defaults so you don't have to configure anything for common protection: + +| Command | Default | Why | +|---------|---------|-----| +| `rm -rf *`, `rm -fr *` | **Prompted** | Recursive deletion can be destructive. You'll see what's being deleted. | +| `git push --force *` | **Prompted** | Force-push can overwrite shared branch history. | +| `git reset --hard *` | **Prompted** | Discards uncommitted changes permanently. | +| `git clean -f *` | **Prompted** | Removes untracked files permanently. | +| `DROP DATABASE *` | **Blocked** | Almost never intentional in an agent context. | +| `DROP SCHEMA *` | **Blocked** | Almost never intentional in an agent context. | +| `TRUNCATE *` | **Blocked** | Irreversible data deletion. | +| All other commands | **Prompted** | You approve each command before it runs. | + +**"Prompted"** means you'll see the command and can approve or reject it. **"Blocked"** means the agent cannot run it at all — you must override in config. + +To override defaults, add rules in `altimate-code.json`. See [Permissions](configure/permissions.md) for the full configuration reference. + +## Best practices for staying safe + +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. + +2. **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 — this is the single most effective protection. + +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. + +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. + +5. **Commit before large operations.** If the agent is about to make sweeping changes, commit your current state first. You can always `git stash` or revert. + +6. **Block truly dangerous database operations.** The defaults block `DROP DATABASE`, `DROP SCHEMA`, and `TRUNCATE`. You can extend this: + + ```json + { + "permission": { + "bash": { + "*": "ask", + "DROP *": "deny", + "DELETE FROM *": "deny", + "TRUNCATE *": "deny" + } + } + } + ``` + +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. + ## Where should I report security vulnerabilities? **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. diff --git a/packages/opencode/.github/meta/commit.txt b/packages/opencode/.github/meta/commit.txt index 00cc514855..f0fa51bb36 100644 --- a/packages/opencode/.github/meta/commit.txt +++ b/packages/opencode/.github/meta/commit.txt @@ -1,14 +1,21 @@ -fix: address new Sentry findings — regex m flag and off-by-one budget check +fix: UX evaluation — soften bash defaults, expand FAQ, remove .github from sensitive dirs -1. formatTrainingEntry regex: remove multiline `m` flag that could - match user content mid-string (memory/prompt.ts:82) +UX impact evaluation of each change: -2. Memory block budget check: change `<` to `<=` so blocks that fit - exactly into remaining budget are included (memory/prompt.ts:204) +1. **Bash defaults softened**: Changed destructive shell/git commands from + `deny` (blocked silently) to `ask` (prompted). `rm -rf ./build` and + `git push --force` after rebase are legitimate workflows — blocking them + without a prompt is poor UX. Database DDL (`DROP DATABASE`, `TRUNCATE`) + stays `deny` since it's almost never intentional in agent context. -3 prior Sentry findings already fixed in earlier commits: - - projectDir cache (Map keyed by Instance.directory) - - injectTrainingOnly header-only return (itemCount guard) - - orphaned section headers (first-entry pre-check) +2. **Removed `.github` from sensitive dirs**: Editing CI/CD workflows is a + core use case. Prompting on every workflow edit would cause severe + approval fatigue. + +3. **Expanded FAQ**: Added "Why am I being prompted to edit .env files?" + with table of protected patterns and guidance on "Allow always". + Added "What commands are blocked or prompted by default?" with clear + table showing which commands prompt vs block. Reordered best practices + to lead with "work on a branch" (most effective, least friction). Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2d9555ec17..c133887739 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -80,9 +80,57 @@ export namespace Agent { "*.env.*": "ask", "*.env.example": "allow", }, + // Safety defaults for bash commands. + // IMPORTANT: "*": "ask" must come FIRST because evaluation uses last-match-wins. + // + // "ask" = user sees prompt and can approve. Used for destructive file/git + // commands that are common in legitimate workflows (rm -rf ./build, + // git push --force after rebase, git clean in CI). + // "deny" = blocked entirely, no prompt. Used for database DDL that is + // almost never intentional in an agent context. + // + // Users can override any of these in altimate-code.json. + bash: { + "*": "ask", + "rm -rf *": "ask", + "rm -fr *": "ask", + "git push --force *": "ask", + "git push -f *": "ask", + "git reset --hard *": "ask", + "git clean -f *": "ask", + "DROP DATABASE *": "deny", + "DROP SCHEMA *": "deny", + "TRUNCATE *": "deny", + "drop database *": "deny", + "drop schema *": "deny", + "truncate *": "deny", + }, }) const user = PermissionNext.fromConfig(cfg.permission ?? {}) + // Safety deny rules that CANNOT be overridden by wildcard allows. + // Appended after user config so they always take precedence via last-match-wins. + // Users who need to override must use specific patterns like + // `"DROP DATABASE test_db": "allow"` — wildcard `bash: "allow"` won't work. + // Both UPPER and lowercase variants are included because Wildcard.match + // is case-sensitive on Linux/macOS. + const safetyDenials = PermissionNext.fromConfig({ + bash: { + "DROP DATABASE *": "deny", + "DROP SCHEMA *": "deny", + "TRUNCATE *": "deny", + "drop database *": "deny", + "drop schema *": "deny", + "truncate *": "deny", + "Drop Database *": "deny", + "Drop Schema *": "deny", + "Truncate *": "deny", + }, + }) + + // Combine user config with safety denials so every agent inherits them + const userWithSafety = PermissionNext.merge(user, safetyDenials) + const result: Record = { // altimate_change start - replace default build agent with builder and add custom modes builder: { @@ -96,7 +144,7 @@ export namespace Agent { question: "allow", plan_enter: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -128,7 +176,7 @@ export namespace Agent { question: "allow", webfetch: "allow", websearch: "allow", training_save: "allow", training_list: "allow", training_remove: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -160,7 +208,7 @@ export namespace Agent { question: "allow", webfetch: "allow", websearch: "allow", training_save: "allow", training_list: "allow", training_remove: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -192,7 +240,7 @@ export namespace Agent { question: "allow", training_save: "allow", training_list: "allow", training_remove: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -223,7 +271,7 @@ export namespace Agent { grep: "allow", glob: "allow", question: "allow", training_save: "allow", training_list: "allow", training_remove: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -255,7 +303,7 @@ export namespace Agent { question: "allow", webfetch: "allow", websearch: "allow", task: "allow", training_save: "allow", training_list: "allow", training_remove: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -276,7 +324,7 @@ export namespace Agent { schema_cache_status: "allow", warehouse_list: "allow", warehouse_discover: "allow", }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -300,7 +348,7 @@ export namespace Agent { [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", }, }), - user, + userWithSafety, ), mode: "primary", native: true, @@ -314,7 +362,7 @@ export namespace Agent { todoread: "deny", todowrite: "deny", }), - user, + userWithSafety, ), options: {}, mode: "subagent", @@ -339,7 +387,7 @@ export namespace Agent { ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, }), - user, + userWithSafety, ), description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, prompt: PROMPT_EXPLORE, @@ -358,7 +406,7 @@ export namespace Agent { PermissionNext.fromConfig({ "*": "deny", }), - user, + userWithSafety, ), options: {}, }, @@ -374,7 +422,7 @@ export namespace Agent { PermissionNext.fromConfig({ "*": "deny", }), - user, + userWithSafety, ), prompt: PROMPT_TITLE, }, @@ -389,7 +437,7 @@ export namespace Agent { PermissionNext.fromConfig({ "*": "deny", }), - user, + userWithSafety, ), prompt: PROMPT_SUMMARY, }, @@ -405,7 +453,7 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", - permission: PermissionNext.merge(defaults, user), + permission: PermissionNext.merge(defaults, userWithSafety), options: {}, native: false, } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e03fc8a9f3..a2e53b83f6 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -500,8 +500,6 @@ export namespace File { const project = Instance.project const full = path.join(Instance.directory, file) - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } @@ -580,8 +578,6 @@ export namespace File { } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. if (!Instance.containsPath(resolved)) { throw new Error(`Access denied: path escapes project directory`) } diff --git a/packages/opencode/src/file/protected.ts b/packages/opencode/src/file/protected.ts index d519746193..f33082dc60 100644 --- a/packages/opencode/src/file/protected.ts +++ b/packages/opencode/src/file/protected.ts @@ -37,6 +37,46 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes" const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"] +/** + * Directories and file patterns that should require explicit permission before + * write operations, even when they are located inside the project boundary. + * These contain credentials, version control state, or configuration that + * should not be modified without the user's awareness. + */ +const SENSITIVE_DIRS = [ + ".git", + ".ssh", + ".gnupg", + ".aws", + ".azure", + ".gcloud", + ".kube", + ".docker", +] + +const SENSITIVE_FILES = [ + ".env", + ".env.local", + ".env.production", + ".env.staging", + ".env.development", + ".npmrc", + ".pypirc", + ".netrc", + ".htpasswd", + ".pgpass", + "credentials.json", + "service-account.json", + "id_rsa", + "id_ed25519", +] + +/** File extensions that typically contain private keys or certificates. */ +const SENSITIVE_EXTENSIONS = [".pem", ".key", ".p12", ".pfx"] + +/** Whether the current platform uses case-insensitive filesystem by default. */ +const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32" + export namespace Protected { /** Directory basenames to skip when scanning the home directory. */ export function names(): ReadonlySet { @@ -56,4 +96,44 @@ export namespace Protected { if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n)) return [] } + + /** + * Check if a file path targets a sensitive directory or file that should + * require explicit user permission before modification, even inside the project. + * Returns the name of the matched sensitive pattern, or undefined if not sensitive. + */ + export function isSensitiveWrite(filepath: string): string | undefined { + // Split on both / and \ for cross-platform safety + const segments = filepath.split(/[/\\]/) + const filename = segments[segments.length - 1] ?? "" + + // Use case-insensitive comparison on macOS/Windows where + // .GIT/config and .git/config refer to the same path + const cmp = (a: string, b: string) => + CASE_INSENSITIVE ? a.toLowerCase() === b.toLowerCase() : a === b + + // Check if any path segment is a sensitive directory + for (const segment of segments) { + for (const dir of SENSITIVE_DIRS) { + if (cmp(segment, dir)) return dir + } + } + + // Check if the filename matches a sensitive file pattern + for (const pattern of SENSITIVE_FILES) { + if (cmp(filename, pattern)) return pattern + // Match .env.* variants (e.g., .env.local, .env.production.local) + if (pattern === ".env") { + const lower = CASE_INSENSITIVE ? filename.toLowerCase() : filename + if (lower.startsWith(".env.")) return filename + } + } + + // Check for private key / certificate extensions + const ext = filename.includes(".") ? "." + filename.split(".").pop()! : "" + const extLower = ext.toLowerCase() + if (SENSITIVE_EXTENSIONS.includes(extLower)) return filename + + return undefined + } } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index dac5e71ba1..9177b87ce3 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -93,14 +93,15 @@ export const Instance = { /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. + * Uses symlink-aware resolution to prevent symlink escape attacks. * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true + if (Filesystem.containsReal(Instance.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (Instance.worktree === "/") return false - return Filesystem.contains(Instance.worktree, filepath) + return Filesystem.containsReal(Instance.worktree, filepath) }, state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 06293b6eba..efe2771e34 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -7,7 +7,7 @@ import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectory, assertSensitiveWrite } from "./external-directory" import { trimDiff } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" @@ -60,6 +60,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { for (const hunk of hunks) { const filePath = path.resolve(Instance.directory, hunk.path) await assertExternalDirectory(ctx, filePath) + await assertSensitiveWrite(ctx, filePath) switch (hunk.type) { case "add": { @@ -118,6 +119,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined await assertExternalDirectory(ctx, movePath) + await assertSensitiveWrite(ctx, movePath) fileChanges.push({ filePath, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index c7b12378ed..005e0941cc 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -16,7 +16,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectory, assertSensitiveWrite } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -52,6 +52,7 @@ export const EditTool = Tool.define("edit", { const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filePath) + await assertSensitiveWrite(ctx, filePath) let diff = "" let contentOld = "" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 5d8885b2ad..a9d5c1ba32 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,6 +1,7 @@ import path from "path" import type { Tool } from "./tool" import { Instance } from "../project/instance" +import { Protected } from "../file/protected" type Kind = "file" | "directory" @@ -30,3 +31,31 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string }, }) } + +/** + * Checks if a write target is a sensitive file or directory (e.g., .git/, .ssh/, + * .env, credentials). If so, prompts the user for explicit permission even if the + * path is inside the project boundary. + * + * Uses a dedicated "sensitive_write" permission (not "edit") so that agents with + * `edit: "allow"` don't silently bypass this check. The "sensitive_write" permission + * defaults to "ask" when not explicitly configured. + */ +export async function assertSensitiveWrite(ctx: Tool.Context, target?: string) { + if (!target) return + + const relativePath = path.relative(Instance.directory, target) + const matched = Protected.isSensitiveWrite(relativePath) + if (!matched) return + + await ctx.ask({ + permission: "sensitive_write", + patterns: [relativePath], + always: [relativePath], + metadata: { + filepath: target, + sensitive: matched, + reason: `This file is in a sensitive location (${matched}). Modifications could affect credentials, version control, or security configuration.`, + }, + }) +} diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 8c1e53ccaf..a91164f3e3 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -11,7 +11,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectory, assertSensitiveWrite } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -25,6 +25,7 @@ export const WriteTool = Tool.define("write", { async execute(params, ctx) { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filepath) + await assertSensitiveWrite(ctx, filepath) const exists = await Filesystem.exists(filepath) const contentOld = exists ? await Filesystem.readText(filepath) : "" diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9c..0f96003383 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve } from "path" +import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -146,7 +146,79 @@ export namespace Filesystem { } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const rel = relative(parent, child) + // Block cross-drive paths on Windows where relative() returns an absolute path + if (isAbsolute(rel)) return false + return !rel.startsWith("..") + } + + /** + * Symlink-aware containment check. Resolves both paths to their real + * filesystem location before comparing, preventing symlink escape attacks. + * For non-existent paths (write operations), walks up to the nearest + * existing ancestor and resolves from there. + * Falls back to lexical `contains()` if resolution fails entirely. + * + * Note: Like all application-level path checks, this is subject to TOCTOU + * races — a symlink could be created between check and use. Only OS-level + * sandboxing (Seatbelt, bubblewrap) can fully prevent this. + */ + export function containsReal(parent: string, child: string): boolean { + let realParent: string + try { + realParent = realpathSync(parent) + } catch { + // Parent doesn't exist — fall back to lexical check + return contains(parent, child) + } + + // Try resolving the child directly (exists on disk) + try { + const realChild = realpathSync(child) + const rel = relative(realParent, realChild) + return !isAbsolute(rel) && !rel.startsWith("..") + } catch { + // Child doesn't exist — walk up to find nearest existing ancestor + } + + // SECURITY: If the raw child path contains '..' segments, reject it. + // realpathSync normalizes '..' lexically (before symlink resolution), + // but the OS kernel resolves symlinks THEN applies '..'. For example: + // realpathSync("project/symlink/..") → project/ (lexical) + // writeFile("project/symlink/../f") → writes outside project (kernel) + // Since we can't trust realpathSync's resolution of paths with '..', + // any path containing '..' that couldn't be fully resolved above is denied. + const segments = child.split(/[/\\]/) + if (segments.includes("..")) return false + + // Walk up the directory tree to find the nearest existing ancestor, + // then append the remaining segments. This handles write operations + // where the target directory hasn't been created yet. + // + // CRITICAL: realpathSync normalizes '..' lexically (before symlink resolution), + // but the OS kernel resolves symlinks THEN applies '..'. For example: + // realpathSync("project/symlink/..") → project/ (lexical) + // writeFile("project/symlink/../f") → writes outside project (kernel) + // Therefore, if any trailing segment is '..', we MUST deny the access since + // we cannot predict where the OS will actually write. + let current = child + const trailing: string[] = [] + while (true) { + try { + const realAncestor = realpathSync(current) + const realChild = trailing.length > 0 ? join(realAncestor, ...trailing) : realAncestor + const rel = relative(realParent, realChild) + return !isAbsolute(rel) && !rel.startsWith("..") + } catch { + const parent_ = dirname(current) + if (parent_ === current) { + // Reached filesystem root without finding an existing dir — fall back + return contains(parent, child) + } + trailing.unshift(basename(current)) + current = parent_ + } + } } export async function findUp(target: string, start: string, stop?: string) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index e2fda711a0..b3bdd8e091 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -43,7 +43,8 @@ test("build agent has correct default properties", async () => { expect(build?.mode).toBe("primary") expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") - expect(evalPerm(build, "bash")).toBe("allow") + // bash defaults to "ask" for safety — destructive commands are prompted + expect(evalPerm(build, "bash")).toBe("ask") }, }) }) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f1543..90ce4fbc23 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { Protected } from "../../src/file/protected" import { tmpdir } from "../fixture/fixture" describe("Filesystem.contains", () => { @@ -31,6 +32,108 @@ describe("Filesystem.contains", () => { }) }) +describe("Filesystem.containsReal", () => { + test("allows paths within project (no symlinks)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file.txt"), "content") + }, + }) + expect(Filesystem.containsReal(tmp.path, path.join(tmp.path, "file.txt"))).toBe(true) + }) + + test("blocks symlink pointing outside project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a symlink inside project that points to /tmp (outside project) + await fs.symlink("/tmp", path.join(dir, "escape-link")) + }, + }) + // The symlink target resolves to /tmp, which is outside the project + expect(Filesystem.containsReal(tmp.path, path.join(tmp.path, "escape-link"))).toBe(false) + }) + + test("blocks directory symlink escape", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret data") + }, + }) + await using project = await tmpdir({ + init: async (dir) => { + // Symlink inside project pointing to a directory outside + await fs.symlink(outside.path, path.join(dir, "linked-dir")) + }, + }) + // Path through symlink should be rejected + expect(Filesystem.containsReal(project.path, path.join(project.path, "linked-dir", "secret.txt"))).toBe(false) + }) + + test("allows symlink within project pointing to another project path", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "real", "file.txt"), "content") + await fs.symlink(path.join(dir, "real"), path.join(dir, "link-to-real")) + }, + }) + // Symlink target is still within the project — should be allowed + expect(Filesystem.containsReal(tmp.path, path.join(tmp.path, "link-to-real", "file.txt"))).toBe(true) + }) + + test("allows write to non-existent file in valid directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "src"), { recursive: true }) + }, + }) + // File doesn't exist yet but parent dir is valid and inside project + expect(Filesystem.containsReal(tmp.path, path.join(tmp.path, "src", "new-file.ts"))).toBe(true) + }) + + test("falls back to lexical check when parent does not exist", () => { + // When the parent dir itself doesn't exist, containsReal falls back to lexical contains() + expect(Filesystem.containsReal("/nonexistent-project-dir", "/nonexistent-project-dir/sub/file.txt")).toBe(true) + // But escape attempts still fail even in fallback mode + expect(Filesystem.containsReal("/nonexistent-project-dir", "/etc/passwd")).toBe(false) + }) +}) + +describe("Protected.isSensitiveWrite", () => { + test("detects .git directory", () => { + expect(Protected.isSensitiveWrite(".git/config")).toBe(".git") + expect(Protected.isSensitiveWrite(".git/hooks/pre-commit")).toBe(".git") + expect(Protected.isSensitiveWrite("subdir/.git/config")).toBe(".git") + }) + + test("detects .ssh directory", () => { + expect(Protected.isSensitiveWrite(".ssh/id_rsa")).toBe(".ssh") + expect(Protected.isSensitiveWrite(".ssh/authorized_keys")).toBe(".ssh") + }) + + test("detects .aws directory", () => { + expect(Protected.isSensitiveWrite(".aws/credentials")).toBe(".aws") + }) + + test("detects .env files", () => { + expect(Protected.isSensitiveWrite(".env")).toBe(".env") + expect(Protected.isSensitiveWrite(".env.local")).toBe(".env.local") + expect(Protected.isSensitiveWrite(".env.production")).toBe(".env.production") + expect(Protected.isSensitiveWrite("config/.env")).toBe(".env") + }) + + test("detects credential files", () => { + expect(Protected.isSensitiveWrite("credentials.json")).toBe("credentials.json") + expect(Protected.isSensitiveWrite("service-account.json")).toBe("service-account.json") + }) + + test("allows normal files", () => { + expect(Protected.isSensitiveWrite("src/index.ts")).toBeUndefined() + expect(Protected.isSensitiveWrite("README.md")).toBeUndefined() + expect(Protected.isSensitiveWrite("package.json")).toBeUndefined() + expect(Protected.isSensitiveWrite("models/schema.sql")).toBeUndefined() + }) +}) + /* * Integration tests for File.read() and File.list() path traversal protection. * diff --git a/packages/opencode/test/file/security-e2e.test.ts b/packages/opencode/test/file/security-e2e.test.ts new file mode 100644 index 0000000000..6eec65703c --- /dev/null +++ b/packages/opencode/test/file/security-e2e.test.ts @@ -0,0 +1,659 @@ +/** + * End-to-end security tests for path containment, symlink protection, + * protected directories, and sensitive file detection. + * + * These tests use real filesystem operations (not mocks) to verify + * the security boundaries work against actual attack scenarios. + */ +import { test, expect, describe, beforeAll } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { existsSync, symlinkSync, mkdirSync, writeFileSync } from "fs" +import { Filesystem } from "../../src/util/filesystem" +import { File } from "../../src/file" +import { Instance } from "../../src/project/instance" +import { Protected } from "../../src/file/protected" +import { assertSensitiveWrite } from "../../src/tool/external-directory" +import { PermissionNext } from "../../src/permission/next" +import type { Tool } from "../../src/tool/tool" +import { SessionID, MessageID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" + +// Helper: create a mock Tool.Context that records permission requests +function mockContext() { + const requests: Array> = [] + const ctx: Tool.Context = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async (req) => { + requests.push(req) + }, + } + return { ctx, requests } +} + +// ───────────────────────────────────────────────────────────────────── +// SYMLINK ESCAPE ATTACKS +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: symlink escape attacks", () => { + test("file symlink pointing to /etc/hosts is blocked by containsReal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Attacker plants a symlink inside the project + if (existsSync("/etc/hosts")) { + await fs.symlink("/etc/hosts", path.join(dir, "innocent.txt")) + } + }, + }) + + if (!existsSync(path.join(tmp.path, "innocent.txt"))) return // skip if /etc/hosts doesn't exist + + // containsReal should detect that the symlink resolves outside the project + expect(Filesystem.containsReal(tmp.path, path.join(tmp.path, "innocent.txt"))).toBe(false) + }) + + test("directory symlink escape is blocked", async () => { + // Create an "outside" directory with a secret file + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.key"), "AWS_SECRET=hunter2") + }, + }) + + // Create project with a symlink pointing to the outside directory + await using project = await tmpdir({ + init: async (dir) => { + await fs.symlink(outside.path, path.join(dir, "config")) + }, + }) + + // Accessing config/secret.key through the symlink should be blocked + const secretPath = path.join(project.path, "config", "secret.key") + expect(Filesystem.containsReal(project.path, secretPath)).toBe(false) + }) + + test("chained symlinks are resolved and blocked", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "data.txt"), "sensitive data") + }, + }) + + await using project = await tmpdir({ + init: async (dir) => { + // link1 -> link2 -> outside + await fs.symlink(outside.path, path.join(dir, "link2")) + await fs.symlink(path.join(dir, "link2"), path.join(dir, "link1")) + }, + }) + + const target = path.join(project.path, "link1", "data.txt") + expect(Filesystem.containsReal(project.path, target)).toBe(false) + }) + + test("symlink within project to another project path is allowed", async () => { + await using project = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "src", "real.ts"), "export const x = 1") + await fs.symlink(path.join(dir, "src"), path.join(dir, "lib")) + }, + }) + + // lib -> src (both inside project) should be fine + expect(Filesystem.containsReal(project.path, path.join(project.path, "lib", "real.ts"))).toBe(true) + }) + + test("File.read blocks symlink escape via Instance.containsPath", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "password123") + }, + }) + + await using project = await tmpdir({ + init: async (dir) => { + await fs.symlink(path.join(outside.path, "secret.txt"), path.join(dir, "harmless.txt")) + }, + }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + // File.read uses Instance.containsPath which now uses containsReal + await expect(File.read("harmless.txt")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("symlink/../file.txt escape for non-existent write target is blocked", async () => { + // CRITICAL: This tests the Gemini-found vulnerability where path.resolve() + // strips '..' lexically before symlinks are resolved. The agent writes to + // /project/symlink/../secret.txt. Without the fix, pathResolve normalizes + // this to /project/secret.txt (looks safe). With the fix, realpathSync on + // /project/symlink/.. correctly follows the symlink first. + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "existing.txt"), "data") + }, + }) + + await using project = await tmpdir({ + init: async (dir) => { + // symlink inside project pointing outside + await fs.symlink(outside.path, path.join(dir, "link")) + }, + }) + + // /project/link/../new-file.txt — OS resolves link to outside, then .. + // goes to outside's parent. This must be DENIED. + // NOTE: path.join normalizes away '..', so we construct the path manually + const escapePath = project.path + "/link/../new-file.txt" + expect(Filesystem.containsReal(project.path, escapePath)).toBe(false) + }) + + test("relative symlink that resolves outside is blocked", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "credentials"), "secret") + }, + }) + + await using project = await tmpdir({ + init: async (dir) => { + // Relative symlink: ./escape -> ../..//credentials + const relTarget = path.relative(dir, path.join(outside.path, "credentials")) + await fs.symlink(relTarget, path.join(dir, "escape")) + }, + }) + + expect(Filesystem.containsReal(project.path, path.join(project.path, "escape"))).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// PATH TRAVERSAL ATTACKS (e2e with real filesystem) +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: path traversal via File.read/File.list", () => { + test("../../../etc/passwd is blocked", async () => { + await using project = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "ok.txt"), "allowed") + }, + }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied") + // But reading a valid file works + const result = await File.read("ok.txt") + expect(result.content).toBe("allowed") + }, + }) + }) + + test("encoded traversal src/nested/../../../../../../etc/passwd is blocked", async () => { + await using project = await tmpdir() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow("Access denied") + }, + }) + }) + + test("File.list blocks directory traversal to /etc", async () => { + await using project = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "file.txt"), "ok") + }, + }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await expect(File.list("../../../etc")).rejects.toThrow("Access denied") + // Valid listing works + const result = await File.list("subdir") + expect(result.length).toBeGreaterThan(0) + }, + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// CROSS-DRIVE / ABSOLUTE PATH ESCAPE +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: absolute path escape", () => { + test("absolute path outside project is rejected by Instance.containsPath", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: () => { + expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(Instance.containsPath("/tmp/random")).toBe(false) + expect(Instance.containsPath("/usr/bin/env")).toBe(false) + // But project path is fine + expect(Instance.containsPath(path.join(project.path, "file.ts"))).toBe(true) + }, + }) + }) + + test("prefix collision is handled correctly", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: () => { + // /tmp/project-evil should NOT be inside /tmp/project + expect(Instance.containsPath(project.path + "-evil")).toBe(false) + expect(Instance.containsPath(project.path + "file")).toBe(false) + }, + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// NON-GIT PROJECT WORKTREE SAFETY +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: non-git project worktree safety", () => { + test("non-git project does not allow all paths via worktree='/'", async () => { + await using project = await tmpdir() // no git: true + + await Instance.provide({ + directory: project.path, + fn: () => { + expect(Instance.containsPath(path.join(project.path, "file.txt"))).toBe(true) + // These must NOT be allowed even though worktree="/" + expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(Instance.containsPath("/root/.ssh/id_rsa")).toBe(false) + expect(Instance.containsPath("/home/user/.aws/credentials")).toBe(false) + }, + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// PROTECTED / SENSITIVE FILE DETECTION +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: Protected.isSensitiveWrite", () => { + describe("detects sensitive directories", () => { + const cases = [ + [".git/config", ".git"], + [".git/hooks/pre-commit", ".git"], + [".git/objects/pack/pack-abc.idx", ".git"], + [".ssh/id_rsa", ".ssh"], + [".ssh/id_ed25519", ".ssh"], + [".ssh/known_hosts", ".ssh"], + [".ssh/authorized_keys", ".ssh"], + [".gnupg/private-keys-v1.d/key.gpg", ".gnupg"], + [".aws/credentials", ".aws"], + [".aws/config", ".aws"], + [".azure/config", ".azure"], + [".gcloud/application_default_credentials.json", ".gcloud"], + [".kube/config", ".kube"], + [".docker/config.json", ".docker"], + ] as const + + for (const [filepath, expected] of cases) { + test(`${filepath} → ${expected}`, () => { + expect(Protected.isSensitiveWrite(filepath)).toBe(expected) + }) + } + }) + + describe("detects sensitive files", () => { + const cases = [ + ".env", + ".env.local", + ".env.production", + ".env.staging", + ".env.development", + ".npmrc", + ".pypirc", + ".netrc", + "credentials.json", + "service-account.json", + "id_rsa", + "id_ed25519", + ] + + for (const filename of cases) { + test(`${filename} is detected`, () => { + expect(Protected.isSensitiveWrite(filename)).toBeDefined() + }) + } + }) + + describe("detects .env variants in subdirectories", () => { + test("config/.env is detected", () => { + expect(Protected.isSensitiveWrite("config/.env")).toBe(".env") + }) + + test("deploy/.env.production is detected", () => { + expect(Protected.isSensitiveWrite("deploy/.env.production")).toBe(".env.production") + }) + + test("nested/deep/.env.local is detected", () => { + expect(Protected.isSensitiveWrite("nested/deep/.env.local")).toBe(".env.local") + }) + }) + + describe("allows normal project files", () => { + const safe = [ + "src/index.ts", + "README.md", + "package.json", + "tsconfig.json", + "models/schema.sql", + "dbt_project.yml", + "Dockerfile", + "Makefile", + ".gitignore", + ".eslintrc.json", + "src/components/Button.tsx", + "tests/test_main.py", + "requirements.txt", + "pyproject.toml", + ] + + for (const filepath of safe) { + test(`${filepath} is allowed`, () => { + expect(Protected.isSensitiveWrite(filepath)).toBeUndefined() + }) + } + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// assertSensitiveWrite E2E +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: assertSensitiveWrite triggers permission prompt", () => { + test("prompts for .git/config write", async () => { + await using project = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".git"), { recursive: true }) + await Bun.write(path.join(dir, ".git", "config"), "[core]") + }, + }) + + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, ".git", "config")) + }, + }) + + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("sensitive_write") + expect(requests[0].metadata.sensitive).toBe(".git") + }) + + test("prompts for .env write", async () => { + await using project = await tmpdir() + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, ".env")) + }, + }) + + expect(requests.length).toBe(1) + expect(requests[0].metadata.sensitive).toBe(".env") + }) + + test("prompts for .ssh/id_rsa write", async () => { + await using project = await tmpdir() + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, ".ssh", "id_rsa")) + }, + }) + + expect(requests.length).toBe(1) + expect(requests[0].metadata.sensitive).toBe(".ssh") + }) + + test("does NOT prompt for normal file writes", async () => { + await using project = await tmpdir() + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, "src", "index.ts")) + await assertSensitiveWrite(ctx, path.join(project.path, "README.md")) + await assertSensitiveWrite(ctx, path.join(project.path, "package.json")) + }, + }) + + expect(requests.length).toBe(0) + }) + + test("prompts for credentials.json in nested directory", async () => { + await using project = await tmpdir() + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, "config", "credentials.json")) + }, + }) + + expect(requests.length).toBe(1) + expect(requests[0].metadata.sensitive).toBe("credentials.json") + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// COMBINED ATTACK SCENARIOS +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: combined attack scenarios", () => { + test("symlink to .ssh directory is double-blocked (containsReal + sensitive check)", async () => { + await using project = await tmpdir({ + init: async (dir) => { + // Even if .ssh is somehow reachable, sensitive check catches it + await fs.mkdir(path.join(dir, ".ssh")) + await Bun.write(path.join(dir, ".ssh", "id_rsa"), "fake key") + }, + }) + + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + // isSensitiveWrite catches it regardless of path containment + const matched = Protected.isSensitiveWrite(".ssh/id_rsa") + expect(matched).toBe(".ssh") + + // assertSensitiveWrite would prompt + await assertSensitiveWrite(ctx, path.join(project.path, ".ssh", "id_rsa")) + expect(requests.length).toBe(1) + }, + }) + }) + + test("write to .env.production via nested path triggers prompt", async () => { + await using project = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "deploy"), { recursive: true }) + }, + }) + + const { ctx, requests } = mockContext() + + await Instance.provide({ + directory: project.path, + fn: async () => { + await assertSensitiveWrite(ctx, path.join(project.path, "deploy", ".env.production")) + }, + }) + + expect(requests.length).toBe(1) + expect(requests[0].metadata.sensitive).toBe(".env.production") + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// WINDOWS CROSS-DRIVE PATH CHECK +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: Windows cross-drive path check (isAbsolute guard)", () => { + test("Filesystem.contains blocks when relative() returns absolute path", () => { + // On Windows, path.relative("C:\\project", "D:\\secrets") returns "D:\\secrets" (absolute). + // Simulate this: if the relative result is absolute, contains() must return false. + // On Unix, path.relative() never returns an absolute path for same-root paths, + // but we can verify the isAbsolute guard works by testing the function directly. + expect(Filesystem.contains("/project", "/project")).toBe(true) + expect(Filesystem.contains("/project", "/other")).toBe(false) + // The isAbsolute guard specifically catches cases where relative returns an absolute path + // This happens on Windows cross-drive. On Unix we verify the ../ check still works. + expect(Filesystem.contains("/a/b/c", "/a/b/c/d/e")).toBe(true) + expect(Filesystem.contains("/a/b/c", "/a/b/x")).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// BASH DENY DEFAULTS EVALUATION +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: bash deny defaults", () => { + test("destructive commands are denied by default rules", () => { + // Mirrors the actual defaults in agent.ts: + // - Destructive shell/git commands → "ask" (prompt, not block — common in legitimate workflows) + // - Database DDL → "deny" (almost never intentional in agent context) + const defaults = PermissionNext.fromConfig({ + bash: { + "*": "ask", + "rm -rf *": "ask", + "rm -fr *": "ask", + "git push --force *": "ask", + "git push -f *": "ask", + "git reset --hard *": "ask", + "git clean -f *": "ask", + "DROP DATABASE *": "deny", + "DROP SCHEMA *": "deny", + "TRUNCATE *": "deny", + "drop database *": "deny", + "drop schema *": "deny", + "truncate *": "deny", + }, + }) + + // Database DDL is blocked entirely (deny) — both upper and lowercase + expect(PermissionNext.evaluate("bash", "DROP DATABASE production", defaults).action).toBe("deny") + expect(PermissionNext.evaluate("bash", "DROP SCHEMA public", defaults).action).toBe("deny") + expect(PermissionNext.evaluate("bash", "TRUNCATE users", defaults).action).toBe("deny") + expect(PermissionNext.evaluate("bash", "drop database production", defaults).action).toBe("deny") + expect(PermissionNext.evaluate("bash", "drop schema public", defaults).action).toBe("deny") + expect(PermissionNext.evaluate("bash", "truncate users", defaults).action).toBe("deny") + + // Destructive file/git commands are prompted (ask), not blocked + // This is intentional — rm -rf ./build, git push --force after rebase, etc. are legitimate + expect(PermissionNext.evaluate("bash", "rm -rf ./build", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "git push --force origin main", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "git reset --hard HEAD~5", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "git clean -f", defaults).action).toBe("ask") + + // Regular commands also prompt (ask) + expect(PermissionNext.evaluate("bash", "ls -la", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "git status", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "dbt run", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "npm install", defaults).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "git push origin main", defaults).action).toBe("ask") + }) + + test("user config can override defaults via merge (last-match-wins)", () => { + const defaults = PermissionNext.fromConfig({ + bash: { + "*": "ask", + "DROP DATABASE *": "deny", + }, + }) + const userOverride = PermissionNext.fromConfig({ + bash: { + "DROP DATABASE test_db": "allow", + }, + }) + + const merged = PermissionNext.merge(defaults, userOverride) + + // Specific user override allows dropping a test database (last-match-wins) + expect(PermissionNext.evaluate("bash", "DROP DATABASE test_db", merged).action).toBe("allow") + // Other DROP DATABASE commands still denied (deny from defaults, no user override matches) + expect(PermissionNext.evaluate("bash", "DROP DATABASE production", merged).action).toBe("deny") + }) +}) + +// ───────────────────────────────────────────────────────────────────── +// SENSITIVE FILE DETECTION WITH WINDOWS-STYLE PATHS +// ───────────────────────────────────────────────────────────────────── + +describe("E2E: sensitive file detection with backslash paths", () => { + test("detects .git with backslash separator", () => { + expect(Protected.isSensitiveWrite(".git\\config")).toBe(".git") + expect(Protected.isSensitiveWrite(".git\\hooks\\pre-commit")).toBe(".git") + }) + + test("detects .ssh with backslash separator", () => { + expect(Protected.isSensitiveWrite(".ssh\\id_rsa")).toBe(".ssh") + }) + + test("detects .env in backslash path", () => { + expect(Protected.isSensitiveWrite("config\\.env")).toBe(".env") + expect(Protected.isSensitiveWrite("deploy\\.env.production")).toBe(".env.production") + }) + + test("mixed separators work", () => { + expect(Protected.isSensitiveWrite("path/to\\.git/config")).toBe(".git") + expect(Protected.isSensitiveWrite("path\\.ssh/id_rsa")).toBe(".ssh") + }) + + test("case-insensitive matching on macOS/Windows", () => { + // On macOS and Windows, .GIT and .git are the same directory + if (process.platform === "darwin" || process.platform === "win32") { + expect(Protected.isSensitiveWrite(".GIT/config")).toBe(".git") + expect(Protected.isSensitiveWrite(".Git/hooks/pre-commit")).toBe(".git") + expect(Protected.isSensitiveWrite(".SSH/id_rsa")).toBe(".ssh") + expect(Protected.isSensitiveWrite(".AWS/credentials")).toBe(".aws") + expect(Protected.isSensitiveWrite(".ENV")).toBeDefined() + expect(Protected.isSensitiveWrite(".Env.Production")).toBeDefined() + } + }) + + test("detects private key / certificate extensions", () => { + expect(Protected.isSensitiveWrite("server.pem")).toBeDefined() + expect(Protected.isSensitiveWrite("private.key")).toBeDefined() + expect(Protected.isSensitiveWrite("cert.p12")).toBeDefined() + expect(Protected.isSensitiveWrite("keystore.pfx")).toBeDefined() + expect(Protected.isSensitiveWrite("certs/tls.key")).toBeDefined() + }) + + test("detects additional credential files", () => { + expect(Protected.isSensitiveWrite(".htpasswd")).toBe(".htpasswd") + expect(Protected.isSensitiveWrite(".pgpass")).toBe(".pgpass") + }) +})