From 3da6ed810554de28ab22b3cec90ca574c2eb2ebb Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 20 Oct 2026 00:07:11 +0300 Subject: [PATCH] V15: Deny .env files to workspace tools by default Co-Authored-By: Claude Opus 5.5 --- docs/workspace-tools.md | 16 ++++++++++----- llms-full.txt | 16 ++++++++++----- src/tools/workspace/fsTools.test.ts | 29 ++++++++++++++++++++++++++++ src/tools/workspace/fsTools.ts | 11 +++++++---- src/tools/workspace/paths.ts | 30 ++++++++++++++++++++--------- 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index 8071473b..8e2823ca 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -96,7 +96,7 @@ const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistr | `maxFilesScanned` | 20,000 | Most files a `glob` or `grep` walk visits. | | `grepTimeoutMs` | 10,000 | Milliseconds `grep` may spend matching its regex across all files; time spent reading files does not count. A slow pattern stops with an error naming how many files were scanned. | | `ignore` | `['.git', 'node_modules']` | Directory names `glob` and `grep` skip. | -| `deny` | `['.git']` | Entry names no tool reads, lists, searches or writes, at any depth. A path through one is a tool error. `[]` turns it off. See [Security model](#security-model). | +| `deny` | `['.git', '.env', '.env.*']` | Entry names no tool reads, lists, searches or writes, at any depth. A trailing `*` is a prefix. A path through one is a tool error. `[]` turns it off. See [Security model](#security-model). | | `checkpoints` | none | A `WorkspaceCheckpoints`: back up every file `write_file` and `edit_file` change, so the changes can be rewound (see below). | `createShellTool(shell, options?)` returns one tool, `shell`, that takes @@ -270,15 +270,21 @@ with `ignore: []`. The check runs after normalization, so `./.git`, `a/../.git`, `.git\config` and `.git/` are refused too. Names compare case-insensitively (`.GIT`), trailing dots and spaces are ignored (Windows opens `.git.` as `.git`), and on Windows 8.3 short names such as `GIT~1` are -refused. `.gitignore` and `.github/` are not affected. Add names with the -`deny` option, for example `deny: ['.git', '.env', 'secrets']` (entry names, -not paths), or pass `deny: []` to turn the check off. +refused. `.gitignore` and `.github/` are not affected. + +The default list also covers `.env` and `.env.*` (`.env.local`, +`.env.production`), so a prompt-injected model cannot read secrets from them. +`.env.example`, `.env.sample` and `.env.template` stay readable. An entry +ending in `*` is a prefix match that spares those three template suffixes. +A `deny` you pass **replaces** the defaults, it does not extend them: list +`.git` and `.env` again, for example `deny: ['.git', '.env', '.env.*', +'secrets']` (entry names, not paths), or pass `deny: []` to turn the check off. ```ts import { createFsTools, NodeWorkspace } from '@lousho/build-ai-agent'; const workspace = new NodeWorkspace({ root: '.' }); -const tools = createFsTools(workspace, { readOnly: true, deny: ['.git', '.env'] }); +const tools = createFsTools(workspace, { readOnly: true, deny: ['.git', '.env', '.env.*'] }); ``` Links into `.git` are refused too. With `NodeWorkspace` (or any provider that diff --git a/llms-full.txt b/llms-full.txt index 19374330..7e134a6d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -20416,7 +20416,7 @@ const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistr | `maxFilesScanned` | 20,000 | Most files a `glob` or `grep` walk visits. | | `grepTimeoutMs` | 10,000 | Milliseconds `grep` may spend matching its regex across all files; time spent reading files does not count. A slow pattern stops with an error naming how many files were scanned. | | `ignore` | `['.git', 'node_modules']` | Directory names `glob` and `grep` skip. | -| `deny` | `['.git']` | Entry names no tool reads, lists, searches or writes, at any depth. A path through one is a tool error. `[]` turns it off. See [Security model](#security-model). | +| `deny` | `['.git', '.env', '.env.*']` | Entry names no tool reads, lists, searches or writes, at any depth. A trailing `*` is a prefix. A path through one is a tool error. `[]` turns it off. See [Security model](#security-model). | | `checkpoints` | none | A `WorkspaceCheckpoints`: back up every file `write_file` and `edit_file` change, so the changes can be rewound (see below). | `createShellTool(shell, options?)` returns one tool, `shell`, that takes @@ -20590,15 +20590,21 @@ with `ignore: []`. The check runs after normalization, so `./.git`, `a/../.git`, `.git\config` and `.git/` are refused too. Names compare case-insensitively (`.GIT`), trailing dots and spaces are ignored (Windows opens `.git.` as `.git`), and on Windows 8.3 short names such as `GIT~1` are -refused. `.gitignore` and `.github/` are not affected. Add names with the -`deny` option, for example `deny: ['.git', '.env', 'secrets']` (entry names, -not paths), or pass `deny: []` to turn the check off. +refused. `.gitignore` and `.github/` are not affected. + +The default list also covers `.env` and `.env.*` (`.env.local`, +`.env.production`), so a prompt-injected model cannot read secrets from them. +`.env.example`, `.env.sample` and `.env.template` stay readable. An entry +ending in `*` is a prefix match that spares those three template suffixes. +A `deny` you pass **replaces** the defaults, it does not extend them: list +`.git` and `.env` again, for example `deny: ['.git', '.env', '.env.*', +'secrets']` (entry names, not paths), or pass `deny: []` to turn the check off. ```ts import { createFsTools, NodeWorkspace } from '@lousho/build-ai-agent'; const workspace = new NodeWorkspace({ root: '.' }); -const tools = createFsTools(workspace, { readOnly: true, deny: ['.git', '.env'] }); +const tools = createFsTools(workspace, { readOnly: true, deny: ['.git', '.env', '.env.*'] }); ``` Links into `.git` are refused too. With `NodeWorkspace` (or any provider that diff --git a/src/tools/workspace/fsTools.test.ts b/src/tools/workspace/fsTools.test.ts index b8e28dbe..9e125629 100644 --- a/src/tools/workspace/fsTools.test.ts +++ b/src/tools/workspace/fsTools.test.ts @@ -424,6 +424,35 @@ describe('createFsTools deny list (consumer harness H1-2)', () => { expect(() => createFsTools(new MemoryWorkspace(), { deny: ['.git/config'] })).toThrow(/entry names such as ".git", not paths/); }); + it.each(['.env', 'sub/.env', '.env.local', '.env.production', 'pkg/.env.staging', '.ENV', 'sub/.Env.Local', '.env.'])( + 'read_file refuses %j by default', + async (path) => { + const ws = new MemoryWorkspace({ files: { '.env': 'K=1', 'sub/.env': 'K=1', '.env.local': 'K=1', '.env.production': 'K=1', 'pkg/.env.staging': 'K=1' } }); + const { read_file } = toolsByName(ws, { readOnly: true }); + await expect(call(read_file, { path })).rejects.toThrow(/is denied: the file tools never open/); + } + ); + + it('leaves .env.example, .env.sample and .env.template readable, and hides .env from glob and list_dir', async () => { + const ws = new MemoryWorkspace({ files: { '.env': 'SECRET=1', '.env.local': 'SECRET=1', '.env.example': 'KEY=', '.env.sample': 'KEY=', 'sub/.env.template': 'KEY=', '.environment': 'x' } }); + const { read_file, list_dir } = toolsByName(ws, { readOnly: true }); + for (const path of ['.env.example', '.env.sample', 'sub/.env.template', '.environment']) { + expect(await call(read_file, { path })).toBeDefined(); + } + const listing = String(await call(list_dir, { path: '.' })); + expect(listing).toContain('.env.example'); + expect(listing).not.toMatch(/\.env(\.local)?(?!\.)/); + }); + + it('a custom deny replaces the defaults, so .env is readable unless listed', async () => { + const ws = new MemoryWorkspace({ files: { '.env': 'K=1', '.env.local': 'K=2' } }); + const open = toolsByName(ws, { deny: ['.git'] }); + expect(await call(open.read_file, { path: '.env' })).toContain('K=1'); + const wild = toolsByName(ws, { deny: ['.env.*'] }); + await expect(call(wild.read_file, { path: '.env.local' })).rejects.toThrow(/is denied/); + expect(await call(wild.read_file, { path: '.env' })).toContain('K=1'); + }); + it('allows names that only contain a denied name', async () => { const ws = new MemoryWorkspace({ files: { '.gitignore': 'dist\n', '.github/ci.yml': 'on: push\n', 'my.git/x': 'y\n' } }); const { read_file } = toolsByName(ws); diff --git a/src/tools/workspace/fsTools.ts b/src/tools/workspace/fsTools.ts index cacdaba7..f37cc87c 100644 --- a/src/tools/workspace/fsTools.ts +++ b/src/tools/workspace/fsTools.ts @@ -98,9 +98,12 @@ export interface FsToolsOptions { ignore?: readonly string[]; /** * Entry names no tool opens, lists, searches or writes, at any depth. - * Defaults to `['.git']`: `.git/config` can hold a CI checkout token - * (`http..extraheader = AUTHORIZATION: basic ...`), and a worktree's - * `.git` file names a host path. A path with a denied segment (`.git/config`, + * Defaults to `['.git', '.env', '.env.*']`: `.git/config` can hold a CI + * checkout token (`http..extraheader = AUTHORIZATION: basic ...`), a + * worktree's `.git` file names a host path, and `.env` files hold secrets. + * An entry ending in `*` is a prefix: `.env.*` covers `.env.local` and + * `.env.production` but never `.env.example`, `.env.sample` or `.env.template`. + * A `deny` you pass replaces the defaults, so list `.git` and `.env` again. A path with a denied segment (`.git/config`, * `./.GIT`, `a/../.git/`, `sub\.git\HEAD`) is a `WorkspaceError`; `glob`, * `grep` and `list_dir` leave such entries out. With a provider that has * `realPath` (`NodeWorkspace`), a path that resolves through a symlink or @@ -131,7 +134,7 @@ function resolveLimits(options: FsToolsOptions): FsLimits { } function resolveDeny(deny: readonly string[] | undefined): readonly string[] { - if (deny === undefined) return ['.git']; + if (deny === undefined) return ['.git', '.env', '.env.*']; for (const name of deny) { if (typeof name !== 'string' || /[/\\]/.test(name)) { throw new ConfigurationError( diff --git a/src/tools/workspace/paths.ts b/src/tools/workspace/paths.ts index 9af8720a..fe3452e9 100644 --- a/src/tools/workspace/paths.ts +++ b/src/tools/workspace/paths.ts @@ -124,21 +124,33 @@ function shortNamePattern(name: string): RegExp | undefined { return stem === '' ? undefined : new RegExp(`^${stem}[a-z0-9]{0,4}~\\d+(\\..*)?$`, 'i'); } +/** Files meant to be committed and read (`.env.example`): a trailing-`*` deny entry never matches them. */ +const TEMPLATE_SUFFIX = /\.(example|sample|template)$/; + /** - * Builds a {@link DeniedSegment} test for entry `names` such as `.git`. + * Builds a {@link DeniedSegment} test for entry `names` such as `.git`. An + * entry ending in `*` is a prefix (`.env.*` covers `.env.local`) and, unlike an + * exact name, spares template files named `.example`, `.sample` or `.template`. * Segments compare case-insensitively (Windows and macOS file systems ignore * case) and with trailing dots and spaces dropped (Windows opens `.git.` as * `.git`). On Windows an 8.3 short-name alias (`GIT~1`) is denied too. */ export function createDenyTest(names: readonly string[], platform: string = process.platform): DeniedSegment { const effective = (segment: string) => segment.replace(/[ .]+$/, '').toLowerCase(); - const denied = new Set(names.map(effective).filter((name) => name !== '')); - if (denied.size === 0) return () => undefined; + const all = names.map(effective).filter((name) => name !== ''); + const prefixes = all.filter((name) => name.endsWith('*') && name.length > 1).map((name) => name.slice(0, -1)); + const denied = new Set(all.filter((name) => !name.endsWith('*'))); + if (denied.size === 0 && prefixes.length === 0) return () => undefined; const aliases = platform === 'win32' ? [...denied].map(shortNamePattern).filter((p): p is RegExp => p !== undefined) : []; - return (normalizedPath) => - normalizedPath === '.' - ? undefined - : normalizedPath.split('/').find((segment) => denied.has(effective(segment)) || aliases.some((p) => p.test(segment))); + const matches = (segment: string) => { + const name = effective(segment); + return ( + denied.has(name) || + (!TEMPLATE_SUFFIX.test(name) && prefixes.some((prefix) => name.startsWith(prefix))) || + aliases.some((p) => p.test(segment)) + ); + }; + return (normalizedPath) => (normalizedPath === '.' ? undefined : normalizedPath.split('/').find(matches)); } /** Normalizes `path` like {@link normalizeWorkspacePath}, then refuses it when one of its segments is denied. */ @@ -148,7 +160,7 @@ export function normalizeAllowedPath(path: string, isDenied: DeniedSegment): str if (segment !== undefined) { throw new WorkspaceError( `Access to ${JSON.stringify(path)} is denied: the file tools never open ${JSON.stringify(segment)}, ` + - 'which can hold credentials (for example CI checkout tokens in .git/config). Use files elsewhere in the workspace.' + 'which can hold credentials (for example CI checkout tokens in .git/config, or secrets in .env). Use files elsewhere in the workspace.' ); } return normalized; @@ -164,7 +176,7 @@ export function assertResolvedPathAllowed(path: string, realPath: string, isDeni if (segment !== undefined) { throw new WorkspaceError( `Access to ${JSON.stringify(path)} is denied: it resolves through a link to ${JSON.stringify(realPath)}, and the file tools never open ` + - `${JSON.stringify(segment)}, which can hold credentials (for example CI checkout tokens in .git/config). Use files elsewhere in the workspace.` + `${JSON.stringify(segment)}, which can hold credentials (for example CI checkout tokens in .git/config, or secrets in .env). Use files elsewhere in the workspace.` ); } }