Skip to content

Commit 1690b9d

Browse files
authored
fix(workspace): protect env file reads (#9)
* fix env public prefix precedence * discover env sources from root env files * enforce env-aware workspace reads * document env-aware workspace reads
1 parent dd98e4c commit 1690b9d

7 files changed

Lines changed: 381 additions & 26 deletions

File tree

README.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ That distinction is the point of this project. Agent Container gives coding agen
2929
<p align="center">
3030
<a href="#quick-start">Quick Start</a> &middot;
3131
<a href="#why">Why</a> &middot;
32-
<a href="#threat-model"</a> &middot;
32+
<a href="#threat-model">Threat Model</a> &middot;
3333
<a href="#how-it-works">How It Works</a> &middot;
3434
<a href="#bindings">Bindings</a> &middot;
3535
<a href="#api">API</a> &middot;
@@ -145,6 +145,8 @@ Implemented today:
145145
- `WORKSPACE` supports `readText`, `writeText`, `list`, `stat`, `glob`, `grep`, and `remove`.
146146
- Workspace mode can be `live` or `shadow`; `shadow` copies the repository to a disposable temp directory.
147147
- Workspace mounts can expose additional paths as read-only or read-write logical mount points.
148+
- Workspace reads are env-aware: root `.env*` sources are exposed as filtered dotenv views, and env-like files outside configured env sources are denied.
149+
- `workspace.denyRead` can deny additional non-env paths from `WORKSPACE.readText` and `WORKSPACE.grep`.
148150
- `EXEC.run` starts allowlisted host commands with workspace-scoped cwd resolution, timeout handling, and selected env projection.
149151
- `EXEC.shell` exists, but only works when `allowShell` is enabled.
150152
- `ENV` exposes public variables and `SECRETS` exposes secret-classified variables.
@@ -199,6 +201,38 @@ const container = await createAgentContainer({
199201

200202
The workspace controller resolves logical paths against the matching mount and rejects path traversal outside that mount's physical root.
201203

204+
Env file reads:
205+
206+
```ts
207+
const container = await createAgentContainer({
208+
workspace: { root: process.cwd() },
209+
env: {
210+
include: ["PUBLIC_*"],
211+
processEnv: "none",
212+
},
213+
});
214+
215+
const envFile = await WORKSPACE.readText(".env");
216+
// PUBLIC_READ_KEY="hello-world"
217+
```
218+
219+
If `env.sources` is omitted, root-level `.env` and `.env.*` files are treated as env sources. Reading one of those files through `WORKSPACE` returns a synthetic dotenv file filtered by the same `env.include` and `env.exclude` rules used by `ENV`.
220+
221+
If `env.sources` is provided, only those file sources are readable as filtered env files. Other env-like files, such as `.env.local` when only `.env` is configured, are denied with `Path is not readable: <path>`.
222+
223+
Additional read denies:
224+
225+
```ts
226+
const container = await createAgentContainer({
227+
workspace: {
228+
root: process.cwd(),
229+
denyRead: ["**/*.secret"],
230+
},
231+
});
232+
```
233+
234+
`denyRead` applies to non-env file content reads and search. It does not select env sources; use `env.sources` for that. `list`, `stat`, and `glob` may still reveal filenames.
235+
202236
### EXEC
203237

204238
`EXEC` is brokered subprocess execution.
@@ -251,7 +285,7 @@ const token = await SECRETS.get("API_SECRET_TOKEN");
251285
const secretKeys = await SECRETS.keys();
252286
```
253287

254-
Env policy can load from `.env`, `.env.local`, inline values, and selected process env values:
288+
Env policy can load from root `.env*` files, explicit file sources, inline values, and selected process env values:
255289

256290
```ts
257291
const container = await createAgentContainer({
@@ -265,6 +299,19 @@ const container = await createAgentContainer({
265299
});
266300
```
267301

302+
When `sources` is omitted, Agent Container discovers root-level `.env` and `.env.*` files. When `sources` is provided, only those sources are used.
303+
304+
```ts
305+
const container = await createAgentContainer({
306+
workspace: { root: process.cwd() },
307+
env: {
308+
sources: [{ type: "file", path: ".env" }],
309+
include: ["PUBLIC_*"],
310+
processEnv: "none",
311+
},
312+
});
313+
```
314+
268315
### Network
269316

270317
There is no first-class `NET` binding yet. Current network policy controls `workerd`'s global outbound fetch behavior:
@@ -320,11 +367,13 @@ interface AgentContainerOptions {
320367
sourcePath: string;
321368
mode: "ro" | "rw";
322369
}[];
370+
denyRead?: readonly string[];
323371
};
324372
env?: {
325373
sources?: readonly EnvSource[];
326374
include?: readonly string[];
327375
exclude?: readonly string[];
376+
publicPatterns?: readonly string[];
328377
secretPatterns?: readonly string[];
329378
processEnv?: "none" | "allow-matching" | "all";
330379
};

packages/agent-container/src/container.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,10 @@ export class LocalAgentContainer implements AgentContainer {
153153
export async function createAgentContainer(
154154
options: AgentContainerOptions,
155155
): Promise<AgentContainer> {
156-
const workspace = await LocalWorkspaceController.create(options.workspace, (event) =>
157-
emitWithSink(options, event),
156+
const workspace = await LocalWorkspaceController.create(
157+
options.workspace,
158+
(event) => emitWithSink(options, event),
159+
{ env: options.env },
158160
);
159161
const env = await resolveEnv(workspace.root, options.env);
160162
const exec = new LocalExecController({

packages/agent-container/src/env.ts

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFile } from "node:fs/promises";
1+
import { readdir, readFile } from "node:fs/promises";
22
import { matchesGlob, resolve } from "node:path";
33

44
import type {
@@ -11,30 +11,50 @@ import type {
1111
ResolvedEnvSnapshot,
1212
} from "@agent-container/types";
1313

14-
const DEFAULT_ENV_SOURCES = [
15-
{ type: "file", path: ".env", optional: true },
16-
{ type: "file", path: ".env.local", optional: true },
17-
] as const;
18-
1914
const DEFAULT_SECRET_PATTERNS = ["*_KEY", "*_TOKEN", "*_SECRET", "*_PASSWORD"] as const;
2015

16+
const DEFAULT_PUBLIC_PATTERNS = ["PUBLIC_*"] as const;
17+
2118
function matchesPatterns(value: string, patterns: readonly string[]): boolean {
2219
return patterns.some((pattern) => matchesGlob(value, pattern));
2320
}
2421

25-
function normalizeSources(policy: EnvPolicy): readonly EnvSource[] {
22+
export interface ResolvedEnvPolicy {
23+
sources: readonly EnvSource[];
24+
include: readonly string[];
25+
exclude: readonly string[];
26+
publicPatterns: readonly string[];
27+
secretPatterns: readonly string[];
28+
processEnv: ProcessEnvMode;
29+
}
30+
31+
async function discoverRootEnvSources(repoRoot: string): Promise<readonly EnvSource[]> {
32+
let entries: readonly string[];
33+
try {
34+
entries = await readdir(repoRoot);
35+
} catch {
36+
return [];
37+
}
38+
39+
return entries
40+
.filter((entry) => entry === ".env" || entry.startsWith(".env."))
41+
.sort()
42+
.map((path) => ({ type: "file", path, optional: true }) satisfies EnvSource);
43+
}
44+
45+
async function normalizeSources(repoRoot: string, policy: EnvPolicy): Promise<readonly EnvSource[]> {
2646
if (policy.sources !== undefined && policy.sources.length > 0) {
2747
return policy.sources;
2848
}
2949

30-
const sources: EnvSource[] = [...DEFAULT_ENV_SOURCES];
50+
const sources: EnvSource[] = [...(await discoverRootEnvSources(repoRoot))];
3151
if ((policy.processEnv ?? "none") !== "none") {
3252
sources.push({ type: "process" });
3353
}
3454
return sources;
3555
}
3656

37-
function shouldIncludeName(
57+
export function shouldIncludeEnvName(
3858
name: string,
3959
include: readonly string[],
4060
exclude: readonly string[],
@@ -47,7 +67,21 @@ function shouldIncludeName(
4767
return !matchesPatterns(name, exclude);
4868
}
4969

50-
function parseEnvFile(content: string): Record<string, string> {
70+
function classifyEnvName(
71+
name: string,
72+
options: {
73+
publicPatterns: readonly string[];
74+
secretPatterns: readonly string[];
75+
},
76+
): EnvClassification {
77+
if (matchesPatterns(name, options.publicPatterns)) {
78+
return "public";
79+
}
80+
81+
return matchesPatterns(name, options.secretPatterns) ? "secret" : "public";
82+
}
83+
84+
export function parseEnvFile(content: string): Record<string, string> {
5185
const values: Record<string, string> = {};
5286

5387
for (const line of content.split(/\r?\n/u)) {
@@ -83,6 +117,31 @@ function parseEnvFile(content: string): Record<string, string> {
83117
return values;
84118
}
85119

120+
export function serializeEnvFile(values: Record<string, string>): string {
121+
return Object.entries(values)
122+
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
123+
.join("\n");
124+
}
125+
126+
export function filterEnvValues(
127+
values: Record<string, string>,
128+
options: {
129+
include: readonly string[];
130+
exclude: readonly string[];
131+
},
132+
): Record<string, string> {
133+
const filtered: Record<string, string> = {};
134+
for (const [name, value] of Object.entries(values)) {
135+
if (!shouldIncludeEnvName(name, options.include, options.exclude)) {
136+
continue;
137+
}
138+
139+
filtered[name] = value;
140+
}
141+
142+
return filtered;
143+
}
144+
86145
async function loadFileSource(
87146
repoRoot: string,
88147
source: Extract<EnvSource, { type: "file" }>,
@@ -116,7 +175,7 @@ function loadProcessSource(
116175
}
117176

118177
if (processEnvMode === "allow-matching") {
119-
if (!shouldIncludeName(name, include, exclude)) {
178+
if (!shouldIncludeEnvName(name, include, exclude)) {
120179
continue;
121180
}
122181
} else if (matchesPatterns(name, exclude)) {
@@ -185,26 +244,43 @@ class ResolvedEnvMap implements ResolvedEnv {
185244
}
186245
}
187246

247+
export async function resolveEnvPolicy(
248+
repoRoot: string,
249+
policy: EnvPolicy,
250+
): Promise<ResolvedEnvPolicy> {
251+
const include = policy.include ?? [];
252+
const exclude = policy.exclude ?? [];
253+
const publicPatterns = policy.publicPatterns ?? DEFAULT_PUBLIC_PATTERNS;
254+
const secretPatterns = policy.secretPatterns ?? DEFAULT_SECRET_PATTERNS;
255+
const processEnvMode = policy.processEnv ?? "none";
256+
257+
return {
258+
sources: await normalizeSources(repoRoot, policy),
259+
include,
260+
exclude,
261+
publicPatterns,
262+
secretPatterns,
263+
processEnv: processEnvMode,
264+
};
265+
}
266+
188267
export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise<ResolvedEnv> {
189268
if (policy === undefined) {
190269
return new ResolvedEnvMap({});
191270
}
192271

193-
const include = policy.include ?? [];
194-
const exclude = policy.exclude ?? [];
195-
const secretPatterns = policy.secretPatterns ?? DEFAULT_SECRET_PATTERNS;
196-
const processEnvMode = policy.processEnv ?? "none";
272+
const envPolicy = await resolveEnvPolicy(repoRoot, policy);
197273

198274
const mergedEntries = new Map<string, { value: string; source: string }>();
199-
for (const source of normalizeSources(policy)) {
275+
for (const source of envPolicy.sources) {
200276
let values: Record<string, string>;
201277
let sourceName: string;
202278

203279
if (source.type === "file") {
204280
values = await loadFileSource(repoRoot, source);
205281
sourceName = `file:${source.path}`;
206282
} else if (source.type === "process") {
207-
values = loadProcessSource(processEnvMode, include, exclude);
283+
values = loadProcessSource(envPolicy.processEnv, envPolicy.include, envPolicy.exclude);
208284
sourceName = "process";
209285
} else {
210286
values = source.values;
@@ -218,14 +294,17 @@ export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise<
218294

219295
const finalEntries: Record<string, ResolvedEnvEntry> = {};
220296
for (const [name, entry] of mergedEntries) {
221-
if (!shouldIncludeName(name, include, exclude)) {
297+
if (!shouldIncludeEnvName(name, envPolicy.include, envPolicy.exclude)) {
222298
continue;
223299
}
224300

225301
finalEntries[name] = {
226302
value: entry.value,
227303
source: entry.source,
228-
classification: matchesPatterns(name, secretPatterns) ? "secret" : "public",
304+
classification: classifyEnvName(name, {
305+
publicPatterns: envPolicy.publicPatterns,
306+
secretPatterns: envPolicy.secretPatterns,
307+
}),
229308
};
230309
}
231310

0 commit comments

Comments
 (0)