diff --git a/src/lib/steps/harness/anthropic.ts b/src/lib/steps/harness/anthropic.ts index 54c5f85..bbed7ba 100644 --- a/src/lib/steps/harness/anthropic.ts +++ b/src/lib/steps/harness/anthropic.ts @@ -1,5 +1,6 @@ -import { query } from '@anthropic-ai/claude-agent-sdk' +import { type HookCallback, query } from '@anthropic-ai/claude-agent-sdk' +import { toolInputTouchesSecret } from './secret-paths.js' import type { Harness, HarnessRunStepArgs, StepRunResult } from './types.js' // The official Seam MCP (same server the seam-plugin wires up). @@ -7,17 +8,38 @@ const SEAM_MCP_URL = 'https://mcp.seam.co/mcp' // Read/search/write + the docs MCP. Deliberately no Bash, no subagents, no task // tools: the agent writes integration code, it does not run the developer's -// shell. `mcp__seam-docs__*` grants every seam-docs tool. +// shell. No WebFetch either — the agent gets its references from the seam-docs +// MCP, so arbitrary web egress is unnecessary and only widens the exfiltration +// surface. `mcp__seam-docs__*` grants every seam-docs tool. const ALLOWED_TOOLS = [ 'Read', 'Glob', 'Grep', 'Edit', 'Write', - 'WebFetch', 'mcp__seam-docs__*', ] +// Hard block on the developer's secret files: a deny here overrides the broad +// `Read` allow above and fires for every tool, so Read/Grep/Edit/Write can't +// touch `.env` (the system-prompt instruction alone is not enforcement). +const denySecretFileAccess: HookCallback = async (input) => { + if ( + input.hook_event_name === 'PreToolUse' && + toolInputTouchesSecret(input.tool_input) + ) { + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + 'Reading .env / secret files is blocked. Load SEAM_API_KEY from the runtime environment instead.', + }, + } + } + return {} +} + // The control harness: drives the integration with the Claude Agent SDK. export const anthropicHarness: Harness = { name: 'anthropic', @@ -54,6 +76,10 @@ export const anthropicHarness: Harness = { // reviews the result as a git diff afterward. Read/search tools and the // docs MCP are read-only, so nothing destructive runs unattended. permissionMode: 'acceptEdits', + // Block reads/writes of the developer's .env / secret files. + hooks: { + PreToolUse: [{ hooks: [denySecretFileAccess] }], + }, mcpServers: { // Wired exactly like the seam-plugin: mcp-remote bridges to the hosted // Seam MCP and runs the OAuth browser flow on first use, caching the diff --git a/src/lib/steps/harness/secret-paths.test.ts b/src/lib/steps/harness/secret-paths.test.ts new file mode 100644 index 0000000..6af12bd --- /dev/null +++ b/src/lib/steps/harness/secret-paths.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'vitest' + +import { isSecretFilePath, toolInputTouchesSecret } from './secret-paths.js' + +test('isSecretFilePath: blocks .env and its variants', () => { + expect(isSecretFilePath('.env')).toBe(true) + expect(isSecretFilePath('.env.local')).toBe(true) + expect(isSecretFilePath('.env.production')).toBe(true) + expect(isSecretFilePath('./.env')).toBe(true) + expect(isSecretFilePath('/abs/project/.env')).toBe(true) + expect(isSecretFilePath('config/.env.staging')).toBe(true) +}) + +test('isSecretFilePath: allows the value-less .env.example template', () => { + expect(isSecretFilePath('.env.example')).toBe(false) + expect(isSecretFilePath('./.env.example')).toBe(false) + expect(isSecretFilePath('nested/dir/.env.example')).toBe(false) +}) + +test('isSecretFilePath: allows ordinary files', () => { + expect(isSecretFilePath('src/index.ts')).toBe(false) + expect(isSecretFilePath('README.md')).toBe(false) + // Not a dotenv file — "environment.ts" merely starts with "env" after a slash. + expect(isSecretFilePath('src/environment.ts')).toBe(false) +}) + +test('toolInputTouchesSecret: detects the path across tool input fields', () => { + expect(toolInputTouchesSecret({ file_path: '.env' })).toBe(true) // Read/Edit/Write + expect(toolInputTouchesSecret({ path: './.env.local' })).toBe(true) // Grep/Glob/Ls + expect(toolInputTouchesSecret({ glob: '.env' })).toBe(true) // Grep glob + expect(toolInputTouchesSecret({ file_path: '.env.example' })).toBe(false) + expect(toolInputTouchesSecret({ file_path: 'src/app.ts' })).toBe(false) +}) + +test('toolInputTouchesSecret: safe on non-object / empty input', () => { + expect(toolInputTouchesSecret(null)).toBe(false) + expect(toolInputTouchesSecret(undefined)).toBe(false) + expect(toolInputTouchesSecret('a string')).toBe(false) + expect(toolInputTouchesSecret({})).toBe(false) +}) diff --git a/src/lib/steps/harness/secret-paths.ts b/src/lib/steps/harness/secret-paths.ts new file mode 100644 index 0000000..f973ff2 --- /dev/null +++ b/src/lib/steps/harness/secret-paths.ts @@ -0,0 +1,35 @@ +// Guards the integration agent away from the developer's secret files. `.env` +// (and `.env.local`, `.env.production`, …) routinely hold secrets beyond the +// Seam key — database URLs, Stripe keys, etc. — that must never be read into the +// model context. `.env.example` is the value-less template the wizard writes, so +// it stays readable. + +// Tool-input fields that carry a filesystem path across the built-in tools: +// Read/Edit/Write use `file_path`, Grep/Glob/Ls use `path`, Grep also takes a +// `glob`, and notebook tools use `notebook_path`. +const PATH_BEARING_KEYS = [ + 'file_path', + 'path', + 'notebook_path', + 'glob', +] as const + +// True for `.env` and `.env.*` at any directory depth, except `.env.example`. +export function isSecretFilePath(candidate: string): boolean { + const basename = candidate.split(/[/\\]/).pop() ?? candidate + if (basename === '.env.example') return false + return basename === '.env' || basename.startsWith('.env.') +} + +// True when a tool's input targets a secret file via any of its path-bearing +// fields. Used by the PreToolUse deny hook, which fires for every tool (so a +// deny overrides the broad `Read` allow and covers Grep/Edit/Write too). +export function toolInputTouchesSecret(toolInput: unknown): boolean { + if (typeof toolInput !== 'object' || toolInput == null) return false + const record = toolInput as Record + for (const key of PATH_BEARING_KEYS) { + const value = record[key] + if (typeof value === 'string' && isSecretFilePath(value)) return true + } + return false +}