Skip to content

Commit e805991

Browse files
authored
feat(harness): extend .env guard to pi + redact broad-grep leaks (#59)
Follow-up to ENG-2925 (#57), completing ENG-2926. - pi harness: wrap the built-in Read/Edit/Write/Ls/Find/Grep tools so a call whose input targets a secret file (.env, …) is refused before it runs — parity with the anthropic PreToolUse deny. Reuses toolInputTouchesSecret; pi's path fields (path/glob/dir/directory) are now covered too. - anthropic harness: add a PostToolUse hook that strips .env-sourced lines from Grep output via updatedToolOutput. The PreToolUse deny only fires when a tool *names* a secret path, so a repo-wide grep (path ".") could still surface .env lines; this closes that residual. - secret-paths.ts: add redactSecretGrepLines + the pi path-field keys, with tests.
1 parent 44aaca0 commit e805991

4 files changed

Lines changed: 130 additions & 11 deletions

File tree

‎src/lib/steps/harness/anthropic.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { type HookCallback, query } from '@anthropic-ai/claude-agent-sdk'
22

3-
import { toolInputTouchesSecret } from './secret-paths.js'
3+
import {
4+
redactSecretGrepLines,
5+
toolInputTouchesSecret,
6+
} from './secret-paths.js'
47
import type { Harness, HarnessRunStepArgs, StepRunResult } from './types.js'
58

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

46+
// The PreToolUse deny only fires when a tool *names* a secret path; a repo-wide
47+
// grep (path ".") still scans `.env` and returns its lines. Strip those from the
48+
// grep output before it reaches the model. Only the string form of the result is
49+
// handled — a shape the redactor can't parse is left untouched.
50+
const redactSecretsFromGrepOutput: HookCallback = async (input) => {
51+
if (input.hook_event_name !== 'PostToolUse') return {}
52+
if (input.tool_name !== 'Grep' || typeof input.tool_response !== 'string') {
53+
return {}
54+
}
55+
const redacted = redactSecretGrepLines(input.tool_response)
56+
if (redacted === input.tool_response) return {}
57+
return {
58+
hookSpecificOutput: {
59+
hookEventName: 'PostToolUse',
60+
updatedToolOutput: redacted,
61+
},
62+
}
63+
}
64+
4365
// The control harness: drives the integration with the Claude Agent SDK.
4466
export const anthropicHarness: Harness = {
4567
name: 'anthropic',
@@ -76,9 +98,11 @@ export const anthropicHarness: Harness = {
7698
// reviews the result as a git diff afterward. Read/search tools and the
7799
// docs MCP are read-only, so nothing destructive runs unattended.
78100
permissionMode: 'acceptEdits',
79-
// Block reads/writes of the developer's .env / secret files.
101+
// Block reads/writes of the developer's .env / secret files, and strip
102+
// any secret lines a broad grep still surfaces from them.
80103
hooks: {
81104
PreToolUse: [{ hooks: [denySecretFileAccess] }],
105+
PostToolUse: [{ hooks: [redactSecretsFromGrepOutput] }],
82106
},
83107
mcpServers: {
84108
// Wired exactly like the seam-plugin: mcp-remote bridges to the hosted

‎src/lib/steps/harness/pi.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ToolDefinition } from '@earendil-works/pi-coding-agent'
22
import { createJiti } from 'jiti'
33

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

67
// The official Seam MCP — same server the anthropic harness and the seam-plugin
@@ -127,14 +128,14 @@ export const piHarness: Harness = {
127128
// Each factory returns a differently-parameterised ToolDefinition; widen to
128129
// the general type createAgentSession accepts (the per-tool schema variance
129130
// is irrelevant to the session, which treats them uniformly).
130-
const customTools = [
131+
const customTools = guardSecretFileTools([
131132
createReadToolDefinition(cwd),
132133
createEditToolDefinition(cwd),
133134
createWriteToolDefinition(cwd),
134135
createLsToolDefinition(cwd),
135136
createFindToolDefinition(cwd),
136137
createGrepToolDefinition(cwd),
137-
] as unknown as ToolDefinition[]
138+
] as unknown as ToolDefinition[])
138139

139140
const { session } = await createAgentSession({
140141
model,
@@ -201,6 +202,47 @@ export const piHarness: Harness = {
201202
},
202203
}
203204

205+
// pi's built-in file tools ship no secret-file guard, so wrap each one: a call
206+
// whose input targets a secret file (.env, …) is refused before it runs — parity
207+
// with the anthropic harness's PreToolUse deny. Only `execute` is overridden;
208+
// every other field (schema, renderers) is preserved by the spread.
209+
type PiToolExecute = (
210+
toolCallId: string,
211+
params: unknown,
212+
signal: AbortSignal | undefined,
213+
onUpdate: unknown,
214+
ctx: unknown,
215+
) => Promise<unknown>
216+
217+
function guardSecretFileTools(tools: ToolDefinition[]): ToolDefinition[] {
218+
return tools.map((tool) => {
219+
const runOriginal = (
220+
tool as unknown as { execute: PiToolExecute }
221+
).execute.bind(tool)
222+
const execute: PiToolExecute = async (
223+
toolCallId,
224+
params,
225+
signal,
226+
onUpdate,
227+
ctx,
228+
) => {
229+
if (toolInputTouchesSecret(params)) {
230+
return {
231+
content: [
232+
{
233+
type: 'text',
234+
text: 'Reading .env / secret files is blocked. Load SEAM_API_KEY from the runtime environment instead.',
235+
},
236+
],
237+
details: {},
238+
}
239+
}
240+
return runOriginal(toolCallId, params, signal, onUpdate, ctx)
241+
}
242+
return { ...tool, execute } as unknown as ToolDefinition
243+
})
244+
}
245+
204246
function readRole(message: unknown): string | undefined {
205247
const role = (message as { role?: unknown }).role
206248
return typeof role === 'string' ? role : undefined

‎src/lib/steps/harness/secret-paths.test.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { expect, test } from 'vitest'
22

3-
import { isSecretFilePath, toolInputTouchesSecret } from './secret-paths.js'
3+
import {
4+
isSecretFilePath,
5+
redactSecretGrepLines,
6+
toolInputTouchesSecret,
7+
} from './secret-paths.js'
48

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

2731
test('toolInputTouchesSecret: detects the path across tool input fields', () => {
28-
expect(toolInputTouchesSecret({ file_path: '.env' })).toBe(true) // Read/Edit/Write
29-
expect(toolInputTouchesSecret({ path: './.env.local' })).toBe(true) // Grep/Glob/Ls
30-
expect(toolInputTouchesSecret({ glob: '.env' })).toBe(true) // Grep glob
32+
expect(toolInputTouchesSecret({ file_path: '.env' })).toBe(true) // SDK Read/Edit/Write
33+
expect(toolInputTouchesSecret({ path: './.env.local' })).toBe(true) // pi Read / SDK Grep
34+
expect(toolInputTouchesSecret({ glob: '.env' })).toBe(true) // Grep/Find glob
35+
expect(toolInputTouchesSecret({ dir: 'config/.env.staging' })).toBe(true) // pi Ls
3136
expect(toolInputTouchesSecret({ file_path: '.env.example' })).toBe(false)
3237
expect(toolInputTouchesSecret({ file_path: 'src/app.ts' })).toBe(false)
3338
})
3439

40+
test('redactSecretGrepLines: drops lines sourced from a secret file', () => {
41+
const output = [
42+
'src/config.ts:12:const url = process.env.DATABASE_URL',
43+
'.env:1:DATABASE_URL=postgres://user:pw@host/db',
44+
'config/.env.production:3:STRIPE_KEY=sk_live_abc',
45+
'src/app.ts:5:import { Seam } from "seam"',
46+
].join('\n')
47+
const redacted = redactSecretGrepLines(output)
48+
expect(redacted).toBe(
49+
[
50+
'src/config.ts:12:const url = process.env.DATABASE_URL',
51+
'src/app.ts:5:import { Seam } from "seam"',
52+
].join('\n'),
53+
)
54+
expect(redacted).not.toContain('sk_live_abc')
55+
expect(redacted).not.toContain('postgres://')
56+
})
57+
58+
test('redactSecretGrepLines: keeps .env.example lines and is a no-op when clean', () => {
59+
const clean = ['src/a.ts:1:x', '.env.example:1:SEAM_API_KEY='].join('\n')
60+
expect(redactSecretGrepLines(clean)).toBe(clean)
61+
})
62+
63+
test('redactSecretGrepLines: handles files-with-matches (bare path) output', () => {
64+
const output = ['src/config.ts', '.env', 'src/app.ts'].join('\n')
65+
expect(redactSecretGrepLines(output)).toBe(
66+
['src/config.ts', 'src/app.ts'].join('\n'),
67+
)
68+
})
69+
3570
test('toolInputTouchesSecret: safe on non-object / empty input', () => {
3671
expect(toolInputTouchesSecret(null)).toBe(false)
3772
expect(toolInputTouchesSecret(undefined)).toBe(false)

‎src/lib/steps/harness/secret-paths.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
// model context. `.env.example` is the value-less template the wizard writes, so
55
// it stays readable.
66

7-
// Tool-input fields that carry a filesystem path across the built-in tools:
8-
// Read/Edit/Write use `file_path`, Grep/Glob/Ls use `path`, Grep also takes a
9-
// `glob`, and notebook tools use `notebook_path`.
7+
// Tool-input fields that carry a filesystem path across the built-in tools of
8+
// both harnesses. Claude Agent SDK: Read/Edit/Write use `file_path`, Grep/Glob
9+
// use `path` + `glob`, notebooks use `notebook_path`. pi: Read/Grep/Find use
10+
// `path`, Grep/Find also take `glob`, Ls uses `dir`/`directory`.
1011
const PATH_BEARING_KEYS = [
1112
'file_path',
1213
'path',
1314
'notebook_path',
1415
'glob',
16+
'dir',
17+
'directory',
1518
] as const
1619

1720
// True for `.env` and `.env.*` at any directory depth, except `.env.example`.
@@ -33,3 +36,18 @@ export function toolInputTouchesSecret(toolInput: unknown): boolean {
3336
}
3437
return false
3538
}
39+
40+
// Drop lines a grep surfaced from a secret file. The PreToolUse deny only fires
41+
// when a tool *names* a secret path, so a repo-wide grep (path ".") still scans
42+
// `.env` and returns its matching lines; this strips them from the output before
43+
// it reaches the model. Each grep line is prefixed with its source file
44+
// (`path:line:text` in content mode, or a bare `path` in files-with-matches
45+
// mode), so the leading token is the file to test.
46+
export function redactSecretGrepLines(output: string): string {
47+
const lines = output.split('\n')
48+
const kept = lines.filter((line) => {
49+
const leadingPath = line.split(':', 1)[0] ?? line
50+
return !isSecretFilePath(leadingPath)
51+
})
52+
return kept.length === lines.length ? output : kept.join('\n')
53+
}

0 commit comments

Comments
 (0)