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
28 changes: 26 additions & 2 deletions src/lib/steps/harness/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { type HookCallback, query } from '@anthropic-ai/claude-agent-sdk'

import { toolInputTouchesSecret } from './secret-paths.js'
import {
redactSecretGrepLines,
toolInputTouchesSecret,
} from './secret-paths.js'
import type { Harness, HarnessRunStepArgs, StepRunResult } from './types.js'

// The official Seam MCP (same server the seam-plugin wires up).
Expand Down Expand Up @@ -40,6 +43,25 @@ const denySecretFileAccess: HookCallback = async (input) => {
return {}
}

// The PreToolUse deny only fires when a tool *names* a secret path; a repo-wide
// grep (path ".") still scans `.env` and returns its lines. Strip those from the
// grep output before it reaches the model. Only the string form of the result is
// handled — a shape the redactor can't parse is left untouched.
const redactSecretsFromGrepOutput: HookCallback = async (input) => {
if (input.hook_event_name !== 'PostToolUse') return {}
if (input.tool_name !== 'Grep' || typeof input.tool_response !== 'string') {
return {}
}
const redacted = redactSecretGrepLines(input.tool_response)
if (redacted === input.tool_response) return {}
return {
hookSpecificOutput: {
hookEventName: 'PostToolUse',
updatedToolOutput: redacted,
},
}
}

// The control harness: drives the integration with the Claude Agent SDK.
export const anthropicHarness: Harness = {
name: 'anthropic',
Expand Down Expand Up @@ -76,9 +98,11 @@ 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.
// Block reads/writes of the developer's .env / secret files, and strip
// any secret lines a broad grep still surfaces from them.
hooks: {
PreToolUse: [{ hooks: [denySecretFileAccess] }],
PostToolUse: [{ hooks: [redactSecretsFromGrepOutput] }],
},
mcpServers: {
// Wired exactly like the seam-plugin: mcp-remote bridges to the hosted
Expand Down
46 changes: 44 additions & 2 deletions src/lib/steps/harness/pi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ToolDefinition } from '@earendil-works/pi-coding-agent'
import { createJiti } from 'jiti'

import { toolInputTouchesSecret } from './secret-paths.js'
import type { Harness, HarnessRunStepArgs, StepRunResult } from './types.js'

// The official Seam MCP — same server the anthropic harness and the seam-plugin
Expand Down Expand Up @@ -127,14 +128,14 @@ export const piHarness: Harness = {
// Each factory returns a differently-parameterised ToolDefinition; widen to
// the general type createAgentSession accepts (the per-tool schema variance
// is irrelevant to the session, which treats them uniformly).
const customTools = [
const customTools = guardSecretFileTools([
createReadToolDefinition(cwd),
createEditToolDefinition(cwd),
createWriteToolDefinition(cwd),
createLsToolDefinition(cwd),
createFindToolDefinition(cwd),
createGrepToolDefinition(cwd),
] as unknown as ToolDefinition[]
] as unknown as ToolDefinition[])

const { session } = await createAgentSession({
model,
Expand Down Expand Up @@ -201,6 +202,47 @@ export const piHarness: Harness = {
},
}

// pi's built-in file tools ship no secret-file guard, so wrap each one: a call
// whose input targets a secret file (.env, …) is refused before it runs — parity
// with the anthropic harness's PreToolUse deny. Only `execute` is overridden;
// every other field (schema, renderers) is preserved by the spread.
type PiToolExecute = (
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate: unknown,
ctx: unknown,
) => Promise<unknown>

function guardSecretFileTools(tools: ToolDefinition[]): ToolDefinition[] {
return tools.map((tool) => {
const runOriginal = (
tool as unknown as { execute: PiToolExecute }
).execute.bind(tool)
const execute: PiToolExecute = async (
toolCallId,
params,
signal,
onUpdate,
ctx,
) => {
if (toolInputTouchesSecret(params)) {
return {
content: [
{
type: 'text',
text: 'Reading .env / secret files is blocked. Load SEAM_API_KEY from the runtime environment instead.',
},
],
details: {},
}
}
return runOriginal(toolCallId, params, signal, onUpdate, ctx)
}
return { ...tool, execute } as unknown as ToolDefinition
})
}

function readRole(message: unknown): string | undefined {
const role = (message as { role?: unknown }).role
return typeof role === 'string' ? role : undefined
Expand Down
43 changes: 39 additions & 4 deletions src/lib/steps/harness/secret-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from 'vitest'

import { isSecretFilePath, toolInputTouchesSecret } from './secret-paths.js'
import {
isSecretFilePath,
redactSecretGrepLines,
toolInputTouchesSecret,
} from './secret-paths.js'

test('isSecretFilePath: blocks .env and its variants', () => {
expect(isSecretFilePath('.env')).toBe(true)
Expand All @@ -25,13 +29,44 @@ test('isSecretFilePath: allows ordinary files', () => {
})

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' })).toBe(true) // SDK Read/Edit/Write
expect(toolInputTouchesSecret({ path: './.env.local' })).toBe(true) // pi Read / SDK Grep
expect(toolInputTouchesSecret({ glob: '.env' })).toBe(true) // Grep/Find glob
expect(toolInputTouchesSecret({ dir: 'config/.env.staging' })).toBe(true) // pi Ls
expect(toolInputTouchesSecret({ file_path: '.env.example' })).toBe(false)
expect(toolInputTouchesSecret({ file_path: 'src/app.ts' })).toBe(false)
})

test('redactSecretGrepLines: drops lines sourced from a secret file', () => {
const output = [
'src/config.ts:12:const url = process.env.DATABASE_URL',
'.env:1:DATABASE_URL=postgres://user:pw@host/db',
'config/.env.production:3:STRIPE_KEY=sk_live_abc',
'src/app.ts:5:import { Seam } from "seam"',
].join('\n')
const redacted = redactSecretGrepLines(output)
expect(redacted).toBe(
[
'src/config.ts:12:const url = process.env.DATABASE_URL',
'src/app.ts:5:import { Seam } from "seam"',
].join('\n'),
)
expect(redacted).not.toContain('sk_live_abc')
expect(redacted).not.toContain('postgres://')
})

test('redactSecretGrepLines: keeps .env.example lines and is a no-op when clean', () => {
const clean = ['src/a.ts:1:x', '.env.example:1:SEAM_API_KEY='].join('\n')
expect(redactSecretGrepLines(clean)).toBe(clean)
})

test('redactSecretGrepLines: handles files-with-matches (bare path) output', () => {
const output = ['src/config.ts', '.env', 'src/app.ts'].join('\n')
expect(redactSecretGrepLines(output)).toBe(
['src/config.ts', 'src/app.ts'].join('\n'),
)
})

test('toolInputTouchesSecret: safe on non-object / empty input', () => {
expect(toolInputTouchesSecret(null)).toBe(false)
expect(toolInputTouchesSecret(undefined)).toBe(false)
Expand Down
24 changes: 21 additions & 3 deletions src/lib/steps/harness/secret-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
// 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`.
// Tool-input fields that carry a filesystem path across the built-in tools of
// both harnesses. Claude Agent SDK: Read/Edit/Write use `file_path`, Grep/Glob
// use `path` + `glob`, notebooks use `notebook_path`. pi: Read/Grep/Find use
// `path`, Grep/Find also take `glob`, Ls uses `dir`/`directory`.
const PATH_BEARING_KEYS = [
'file_path',
'path',
'notebook_path',
'glob',
'dir',
'directory',
] as const

// True for `.env` and `.env.*` at any directory depth, except `.env.example`.
Expand All @@ -33,3 +36,18 @@ export function toolInputTouchesSecret(toolInput: unknown): boolean {
}
return false
}

// Drop lines a grep surfaced from a secret file. The PreToolUse deny only fires
// when a tool *names* a secret path, so a repo-wide grep (path ".") still scans
// `.env` and returns its matching lines; this strips them from the output before
// it reaches the model. Each grep line is prefixed with its source file
// (`path:line:text` in content mode, or a bare `path` in files-with-matches
// mode), so the leading token is the file to test.
export function redactSecretGrepLines(output: string): string {
const lines = output.split('\n')
const kept = lines.filter((line) => {
const leadingPath = line.split(':', 1)[0] ?? line
return !isSecretFilePath(leadingPath)
})
return kept.length === lines.length ? output : kept.join('\n')
}
Loading