Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/lib/steps/harness/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
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).
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',
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/lib/steps/harness/secret-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
35 changes: 35 additions & 0 deletions src/lib/steps/harness/secret-paths.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
for (const key of PATH_BEARING_KEYS) {
const value = record[key]
if (typeof value === 'string' && isSecretFilePath(value)) return true
}
return false
}
Loading