Skip to content

Commit f2c49e4

Browse files
committed
enforce env-aware workspace reads
1 parent c755722 commit f2c49e4

4 files changed

Lines changed: 204 additions & 5 deletions

File tree

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/workspace.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
writeFile,
1313
} from "node:fs/promises";
1414
import { tmpdir } from "node:os";
15-
import { basename, dirname, join, resolve, sep } from "node:path";
15+
import { basename, dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
1616

1717
import type {
18+
EnvPolicy,
19+
EnvSource,
1820
MountAccessMode,
1921
ObservabilityEvent,
2022
WorkspaceController,
@@ -24,6 +26,14 @@ import type {
2426
WorkspaceSearchResult,
2527
} from "@agent-container/types";
2628

29+
import {
30+
filterEnvValues,
31+
parseEnvFile,
32+
type ResolvedEnvPolicy,
33+
resolveEnvPolicy,
34+
serializeEnvFile,
35+
} from "./env.js";
36+
2737
interface MountBinding {
2838
mountPath: string;
2939
sourcePath: string;
@@ -41,6 +51,17 @@ function isWorkspaceWriteDeniedError(error: unknown): error is WorkspaceWriteDen
4151
return error instanceof Error && error.name === "WorkspaceWriteDeniedError";
4252
}
4353

54+
class WorkspaceReadDeniedError extends Error {
55+
public constructor(message: string) {
56+
super(message);
57+
this.name = "WorkspaceReadDeniedError";
58+
}
59+
}
60+
61+
function isWorkspaceReadDeniedError(error: unknown): error is WorkspaceReadDeniedError {
62+
return error instanceof Error && error.name === "WorkspaceReadDeniedError";
63+
}
64+
4465
type EmitEvent = (event: Omit<ObservabilityEvent, "timestamp">) => Promise<void>;
4566

4667
function normalizeMountPath(value: string): string {
@@ -140,6 +161,19 @@ function entryKindFromStat(
140161
return "other";
141162
}
142163

164+
function sourceLogicalPath(
165+
root: string,
166+
source: Extract<EnvSource, { type: "file" }>,
167+
): string | undefined {
168+
const sourcePath = resolve(root, source.path);
169+
const relativePath = relative(root, sourcePath);
170+
if (relativePath === "" || relativePath.startsWith("..") || relativePath.startsWith(`..${sep}`)) {
171+
return undefined;
172+
}
173+
174+
return normalizeLogicalPath(relativePath.replace(/\\/gu, "/"));
175+
}
176+
143177
export class LocalWorkspaceController implements WorkspaceController {
144178
public readonly root: string;
145179

@@ -151,23 +185,40 @@ export class LocalWorkspaceController implements WorkspaceController {
151185

152186
readonly #shadowRoot: string | undefined;
153187

188+
readonly #envPolicy: ResolvedEnvPolicy | undefined;
189+
190+
readonly #envSourcePaths: ReadonlySet<string>;
191+
192+
readonly #denyRead: readonly string[];
193+
154194
private constructor(options: {
155195
root: string;
156196
mode: "live" | "shadow";
157197
mounts: readonly MountBinding[];
158198
shadowRoot?: string;
159199
emit?: EmitEvent;
200+
envPolicy?: ResolvedEnvPolicy;
201+
denyRead: readonly string[];
160202
}) {
161203
this.root = options.root;
162204
this.mode = options.mode;
163205
this.#mounts = options.mounts;
164206
this.#shadowRoot = options.shadowRoot;
165207
this.#emit = options.emit;
208+
this.#envPolicy = options.envPolicy;
209+
this.#denyRead = options.denyRead;
210+
this.#envSourcePaths = new Set(
211+
options.envPolicy?.sources
212+
.filter((source): source is Extract<EnvSource, { type: "file" }> => source.type === "file")
213+
.map((source) => sourceLogicalPath(options.root, source))
214+
.filter((path): path is string => path !== undefined) ?? [],
215+
);
166216
}
167217

168218
public static async create(
169219
options: WorkspaceOptions,
170220
emit?: EmitEvent,
221+
policy?: { env?: EnvPolicy },
171222
): Promise<LocalWorkspaceController> {
172223
const workspaceRoot = resolve(options.root);
173224
const mode = options.mode ?? "live";
@@ -198,19 +249,37 @@ export class LocalWorkspaceController implements WorkspaceController {
198249
}
199250

200251
mounts.sort((left, right) => right.mountPath.length - left.mountPath.length);
252+
const envPolicy =
253+
policy?.env === undefined ? undefined : await resolveEnvPolicy(activeRoot, policy.env);
201254

202255
return new LocalWorkspaceController({
203256
root: activeRoot,
204257
mode,
205258
mounts,
206259
shadowRoot,
207260
emit,
261+
envPolicy,
262+
denyRead: options.denyRead ?? [],
208263
});
209264
}
210265

211266
public async read(path: string): Promise<Uint8Array> {
212267
try {
213268
const target = await this.#resolveTarget(path);
269+
const envContent = await this.#tryReadEnvSource(target);
270+
if (envContent !== undefined) {
271+
const content = Buffer.from(envContent, "utf8");
272+
await this.#emitEvent({
273+
scope: "workspace",
274+
action: "read",
275+
outcome: "success",
276+
target: target.logicalPath,
277+
detail: `${content.byteLength} bytes`,
278+
});
279+
return content;
280+
}
281+
282+
await this.#assertReadable(target.logicalPath);
214283
const content = await readFile(target.physicalPath);
215284
await this.#emitEvent({
216285
scope: "workspace",
@@ -221,7 +290,9 @@ export class LocalWorkspaceController implements WorkspaceController {
221290
});
222291
return content;
223292
} catch (error) {
224-
await this.#emitFailure("read", path, error);
293+
if (!isWorkspaceReadDeniedError(error)) {
294+
await this.#emitFailure("read", path, error);
295+
}
225296
throw error;
226297
}
227298
}
@@ -359,7 +430,16 @@ export class LocalWorkspaceController implements WorkspaceController {
359430
continue;
360431
}
361432

362-
const content = await this.readText(path);
433+
let content: string;
434+
try {
435+
content = await this.readText(path);
436+
} catch (error) {
437+
if (isWorkspaceReadDeniedError(error)) {
438+
continue;
439+
}
440+
441+
throw error;
442+
}
363443
const lines = content.split(/\r?\n/u);
364444
for (const [index, line] of lines.entries()) {
365445
const haystack = caseSensitive ? line : line.toLowerCase();
@@ -484,6 +564,32 @@ export class LocalWorkspaceController implements WorkspaceController {
484564
};
485565
}
486566

567+
async #tryReadEnvSource(target: ResolvedTarget): Promise<string | undefined> {
568+
if (!this.#envSourcePaths.has(target.logicalPath) || this.#envPolicy === undefined) {
569+
return undefined;
570+
}
571+
572+
const content = await readFile(target.physicalPath, "utf8");
573+
const filtered = filterEnvValues(parseEnvFile(content), this.#envPolicy);
574+
return serializeEnvFile(filtered);
575+
}
576+
577+
async #assertReadable(logicalPath: string): Promise<void> {
578+
const isEnvLike = logicalPath
579+
.split("/")
580+
.some((segment) => segment === ".env" || segment.startsWith(".env."));
581+
const isDenied = this.#denyRead.some((pattern) => matchesGlob(logicalPath, pattern));
582+
if (isEnvLike || isDenied) {
583+
await this.#emitEvent({
584+
scope: "workspace",
585+
action: "read-denied",
586+
outcome: "denied",
587+
target: logicalPath,
588+
});
589+
throw new WorkspaceReadDeniedError(`Path is not readable: ${logicalPath}`);
590+
}
591+
}
592+
487593
async #emitEvent(event: Omit<ObservabilityEvent, "timestamp">): Promise<void> {
488594
if (this.#emit === undefined) {
489595
return;

packages/agent-container/tests/workspace.integration.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,94 @@ describe("workspace integration", () => {
156156
},
157157
]);
158158
});
159+
160+
it("returns filtered content for default root env sources", async () => {
161+
const workspaceRoot = await createTempWorkspace("agent-container-workspace-env-default-");
162+
await writeWorkspaceFiles(workspaceRoot.root, {
163+
".env": "PUBLIC_READ_KEY=hello-world\nPRIVATE_API_KEY=foobarbaz\n",
164+
".env.local": "PUBLIC_LOCAL_MODE=demo\nPRIVATE_LOCAL_TOKEN=hidden\n",
165+
"package.json": "{\"name\":\"demo\"}\n",
166+
"src/index.ts": "export const value = 1;\n",
167+
"src/.env.prod": "PRIVATE_NESTED_TOKEN=hidden\n",
168+
});
169+
170+
const events: ObservabilityEvent[] = [];
171+
const workspace = await LocalWorkspaceController.create(
172+
{
173+
root: workspaceRoot.root,
174+
mode: "live",
175+
},
176+
async (event) => {
177+
events.push({ timestamp: new Date().toISOString(), ...event });
178+
},
179+
{
180+
env: {
181+
include: ["PUBLIC_*"],
182+
processEnv: "none",
183+
},
184+
},
185+
);
186+
resources.add({ workspaces: [workspaceRoot], controller: workspace });
187+
188+
expect(await workspace.readText("package.json")).toBe("{\"name\":\"demo\"}\n");
189+
expect(await workspace.readText("src/index.ts")).toBe("export const value = 1;\n");
190+
expect(await workspace.readText(".env")).toBe("PUBLIC_READ_KEY=\"hello-world\"");
191+
expect(await workspace.readText(".env.local")).toBe("PUBLIC_LOCAL_MODE=\"demo\"");
192+
expect(await workspace.grep("PRIVATE_API_KEY", { include: [".env", ".env.local"] })).toEqual([]);
193+
await expect(workspace.readText("src/.env.prod")).rejects.toThrow(
194+
"Path is not readable: src/.env.prod",
195+
);
196+
expect(
197+
events.some((event) => event.scope === "workspace" && event.action === "read-denied"),
198+
).toBe(true);
199+
});
200+
201+
it("uses explicit env file sources as the only readable env-like files", async () => {
202+
const workspaceRoot = await createTempWorkspace("agent-container-workspace-env-explicit-");
203+
await writeWorkspaceFiles(workspaceRoot.root, {
204+
".env": "PUBLIC_READ_KEY=hello-world\nPRIVATE_API_KEY=foobarbaz\n",
205+
".env.local": "PUBLIC_LOCAL_MODE=demo\n",
206+
});
207+
208+
const workspace = await LocalWorkspaceController.create(
209+
{
210+
root: workspaceRoot.root,
211+
mode: "live",
212+
},
213+
undefined,
214+
{
215+
env: {
216+
sources: [{ type: "file", path: ".env" }],
217+
include: ["PUBLIC_*"],
218+
processEnv: "none",
219+
},
220+
},
221+
);
222+
resources.add({ workspaces: [workspaceRoot], controller: workspace });
223+
224+
expect(await workspace.readText(".env")).toBe("PUBLIC_READ_KEY=\"hello-world\"");
225+
await expect(workspace.readText(".env.local")).rejects.toThrow(
226+
"Path is not readable: .env.local",
227+
);
228+
});
229+
230+
it("applies custom denyRead patterns to non-env files", async () => {
231+
const workspaceRoot = await createTempWorkspace("agent-container-workspace-deny-read-");
232+
await writeWorkspaceFiles(workspaceRoot.root, {
233+
"package.json": "{\"name\":\"demo\"}\n",
234+
"config/app.secret": "hidden\n",
235+
});
236+
237+
const workspace = await LocalWorkspaceController.create({
238+
root: workspaceRoot.root,
239+
mode: "live",
240+
denyRead: ["**/*.secret"],
241+
});
242+
resources.add({ workspaces: [workspaceRoot], controller: workspace });
243+
244+
expect(await workspace.readText("package.json")).toBe("{\"name\":\"demo\"}\n");
245+
await expect(workspace.readText("config/app.secret")).rejects.toThrow(
246+
"Path is not readable: config/app.secret",
247+
);
248+
});
159249
});

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface WorkspaceOptions {
6363
root: string;
6464
mode?: WorkspaceMode;
6565
mounts?: readonly WorkspaceMount[];
66+
denyRead?: readonly string[];
6667
}
6768

6869
export type WorkspaceEntryKind = "file" | "directory" | "symlink" | "other";

0 commit comments

Comments
 (0)