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
16 changes: 11 additions & 5 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/tools/workspace/fsTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions src/tools/workspace/fsTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<url>.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.<url>.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
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 21 additions & 9 deletions src/tools/workspace/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand All @@ -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.`
);
}
}
Loading