diff --git a/README.md b/README.md index 2f6e091..69e45fa 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ That distinction is the point of this project. Agent Container gives coding agen

Quick Start · Why · - · + Threat Model · How It Works · Bindings · API · @@ -145,6 +145,8 @@ Implemented today: - `WORKSPACE` supports `readText`, `writeText`, `list`, `stat`, `glob`, `grep`, and `remove`. - Workspace mode can be `live` or `shadow`; `shadow` copies the repository to a disposable temp directory. - Workspace mounts can expose additional paths as read-only or read-write logical mount points. +- Workspace reads are env-aware: root `.env*` sources are exposed as filtered dotenv views, and env-like files outside configured env sources are denied. +- `workspace.denyRead` can deny additional non-env paths from `WORKSPACE.readText` and `WORKSPACE.grep`. - `EXEC.run` starts allowlisted host commands with workspace-scoped cwd resolution, timeout handling, and selected env projection. - `EXEC.shell` exists, but only works when `allowShell` is enabled. - `ENV` exposes public variables and `SECRETS` exposes secret-classified variables. @@ -199,6 +201,38 @@ const container = await createAgentContainer({ The workspace controller resolves logical paths against the matching mount and rejects path traversal outside that mount's physical root. +Env file reads: + +```ts +const container = await createAgentContainer({ + workspace: { root: process.cwd() }, + env: { + include: ["PUBLIC_*"], + processEnv: "none", + }, +}); + +const envFile = await WORKSPACE.readText(".env"); +// PUBLIC_READ_KEY="hello-world" +``` + +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`. + +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: `. + +Additional read denies: + +```ts +const container = await createAgentContainer({ + workspace: { + root: process.cwd(), + denyRead: ["**/*.secret"], + }, +}); +``` + +`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. + ### EXEC `EXEC` is brokered subprocess execution. @@ -251,7 +285,7 @@ const token = await SECRETS.get("API_SECRET_TOKEN"); const secretKeys = await SECRETS.keys(); ``` -Env policy can load from `.env`, `.env.local`, inline values, and selected process env values: +Env policy can load from root `.env*` files, explicit file sources, inline values, and selected process env values: ```ts const container = await createAgentContainer({ @@ -265,6 +299,19 @@ const container = await createAgentContainer({ }); ``` +When `sources` is omitted, Agent Container discovers root-level `.env` and `.env.*` files. When `sources` is provided, only those sources are used. + +```ts +const container = await createAgentContainer({ + workspace: { root: process.cwd() }, + env: { + sources: [{ type: "file", path: ".env" }], + include: ["PUBLIC_*"], + processEnv: "none", + }, +}); +``` + ### Network There is no first-class `NET` binding yet. Current network policy controls `workerd`'s global outbound fetch behavior: @@ -320,11 +367,13 @@ interface AgentContainerOptions { sourcePath: string; mode: "ro" | "rw"; }[]; + denyRead?: readonly string[]; }; env?: { sources?: readonly EnvSource[]; include?: readonly string[]; exclude?: readonly string[]; + publicPatterns?: readonly string[]; secretPatterns?: readonly string[]; processEnv?: "none" | "allow-matching" | "all"; }; diff --git a/packages/agent-container/src/container.ts b/packages/agent-container/src/container.ts index 36153d4..3c6c0b9 100644 --- a/packages/agent-container/src/container.ts +++ b/packages/agent-container/src/container.ts @@ -153,8 +153,10 @@ export class LocalAgentContainer implements AgentContainer { export async function createAgentContainer( options: AgentContainerOptions, ): Promise { - const workspace = await LocalWorkspaceController.create(options.workspace, (event) => - emitWithSink(options, event), + const workspace = await LocalWorkspaceController.create( + options.workspace, + (event) => emitWithSink(options, event), + { env: options.env }, ); const env = await resolveEnv(workspace.root, options.env); const exec = new LocalExecController({ diff --git a/packages/agent-container/src/env.ts b/packages/agent-container/src/env.ts index d794841..36e4378 100644 --- a/packages/agent-container/src/env.ts +++ b/packages/agent-container/src/env.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { matchesGlob, resolve } from "node:path"; import type { @@ -11,30 +11,50 @@ import type { ResolvedEnvSnapshot, } from "@agent-container/types"; -const DEFAULT_ENV_SOURCES = [ - { type: "file", path: ".env", optional: true }, - { type: "file", path: ".env.local", optional: true }, -] as const; - const DEFAULT_SECRET_PATTERNS = ["*_KEY", "*_TOKEN", "*_SECRET", "*_PASSWORD"] as const; +const DEFAULT_PUBLIC_PATTERNS = ["PUBLIC_*"] as const; + function matchesPatterns(value: string, patterns: readonly string[]): boolean { return patterns.some((pattern) => matchesGlob(value, pattern)); } -function normalizeSources(policy: EnvPolicy): readonly EnvSource[] { +export interface ResolvedEnvPolicy { + sources: readonly EnvSource[]; + include: readonly string[]; + exclude: readonly string[]; + publicPatterns: readonly string[]; + secretPatterns: readonly string[]; + processEnv: ProcessEnvMode; +} + +async function discoverRootEnvSources(repoRoot: string): Promise { + let entries: readonly string[]; + try { + entries = await readdir(repoRoot); + } catch { + return []; + } + + return entries + .filter((entry) => entry === ".env" || entry.startsWith(".env.")) + .sort() + .map((path) => ({ type: "file", path, optional: true }) satisfies EnvSource); +} + +async function normalizeSources(repoRoot: string, policy: EnvPolicy): Promise { if (policy.sources !== undefined && policy.sources.length > 0) { return policy.sources; } - const sources: EnvSource[] = [...DEFAULT_ENV_SOURCES]; + const sources: EnvSource[] = [...(await discoverRootEnvSources(repoRoot))]; if ((policy.processEnv ?? "none") !== "none") { sources.push({ type: "process" }); } return sources; } -function shouldIncludeName( +export function shouldIncludeEnvName( name: string, include: readonly string[], exclude: readonly string[], @@ -47,7 +67,21 @@ function shouldIncludeName( return !matchesPatterns(name, exclude); } -function parseEnvFile(content: string): Record { +function classifyEnvName( + name: string, + options: { + publicPatterns: readonly string[]; + secretPatterns: readonly string[]; + }, +): EnvClassification { + if (matchesPatterns(name, options.publicPatterns)) { + return "public"; + } + + return matchesPatterns(name, options.secretPatterns) ? "secret" : "public"; +} + +export function parseEnvFile(content: string): Record { const values: Record = {}; for (const line of content.split(/\r?\n/u)) { @@ -83,6 +117,31 @@ function parseEnvFile(content: string): Record { return values; } +export function serializeEnvFile(values: Record): string { + return Object.entries(values) + .map(([name, value]) => `${name}=${JSON.stringify(value)}`) + .join("\n"); +} + +export function filterEnvValues( + values: Record, + options: { + include: readonly string[]; + exclude: readonly string[]; + }, +): Record { + const filtered: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (!shouldIncludeEnvName(name, options.include, options.exclude)) { + continue; + } + + filtered[name] = value; + } + + return filtered; +} + async function loadFileSource( repoRoot: string, source: Extract, @@ -116,7 +175,7 @@ function loadProcessSource( } if (processEnvMode === "allow-matching") { - if (!shouldIncludeName(name, include, exclude)) { + if (!shouldIncludeEnvName(name, include, exclude)) { continue; } } else if (matchesPatterns(name, exclude)) { @@ -185,18 +244,35 @@ class ResolvedEnvMap implements ResolvedEnv { } } +export async function resolveEnvPolicy( + repoRoot: string, + policy: EnvPolicy, +): Promise { + const include = policy.include ?? []; + const exclude = policy.exclude ?? []; + const publicPatterns = policy.publicPatterns ?? DEFAULT_PUBLIC_PATTERNS; + const secretPatterns = policy.secretPatterns ?? DEFAULT_SECRET_PATTERNS; + const processEnvMode = policy.processEnv ?? "none"; + + return { + sources: await normalizeSources(repoRoot, policy), + include, + exclude, + publicPatterns, + secretPatterns, + processEnv: processEnvMode, + }; +} + export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise { if (policy === undefined) { return new ResolvedEnvMap({}); } - const include = policy.include ?? []; - const exclude = policy.exclude ?? []; - const secretPatterns = policy.secretPatterns ?? DEFAULT_SECRET_PATTERNS; - const processEnvMode = policy.processEnv ?? "none"; + const envPolicy = await resolveEnvPolicy(repoRoot, policy); const mergedEntries = new Map(); - for (const source of normalizeSources(policy)) { + for (const source of envPolicy.sources) { let values: Record; let sourceName: string; @@ -204,7 +280,7 @@ export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise< values = await loadFileSource(repoRoot, source); sourceName = `file:${source.path}`; } else if (source.type === "process") { - values = loadProcessSource(processEnvMode, include, exclude); + values = loadProcessSource(envPolicy.processEnv, envPolicy.include, envPolicy.exclude); sourceName = "process"; } else { values = source.values; @@ -218,14 +294,17 @@ export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise< const finalEntries: Record = {}; for (const [name, entry] of mergedEntries) { - if (!shouldIncludeName(name, include, exclude)) { + if (!shouldIncludeEnvName(name, envPolicy.include, envPolicy.exclude)) { continue; } finalEntries[name] = { value: entry.value, source: entry.source, - classification: matchesPatterns(name, secretPatterns) ? "secret" : "public", + classification: classifyEnvName(name, { + publicPatterns: envPolicy.publicPatterns, + secretPatterns: envPolicy.secretPatterns, + }), }; } diff --git a/packages/agent-container/src/workspace.ts b/packages/agent-container/src/workspace.ts index c09b48a..4fc8f3e 100644 --- a/packages/agent-container/src/workspace.ts +++ b/packages/agent-container/src/workspace.ts @@ -12,9 +12,11 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve, sep } from "node:path"; +import { basename, dirname, join, matchesGlob, relative, resolve, sep } from "node:path"; import type { + EnvPolicy, + EnvSource, MountAccessMode, ObservabilityEvent, WorkspaceController, @@ -24,6 +26,14 @@ import type { WorkspaceSearchResult, } from "@agent-container/types"; +import { + filterEnvValues, + parseEnvFile, + type ResolvedEnvPolicy, + resolveEnvPolicy, + serializeEnvFile, +} from "./env.js"; + interface MountBinding { mountPath: string; sourcePath: string; @@ -41,6 +51,17 @@ function isWorkspaceWriteDeniedError(error: unknown): error is WorkspaceWriteDen return error instanceof Error && error.name === "WorkspaceWriteDeniedError"; } +class WorkspaceReadDeniedError extends Error { + public constructor(message: string) { + super(message); + this.name = "WorkspaceReadDeniedError"; + } +} + +function isWorkspaceReadDeniedError(error: unknown): error is WorkspaceReadDeniedError { + return error instanceof Error && error.name === "WorkspaceReadDeniedError"; +} + type EmitEvent = (event: Omit) => Promise; function normalizeMountPath(value: string): string { @@ -140,6 +161,19 @@ function entryKindFromStat( return "other"; } +function sourceLogicalPath( + root: string, + source: Extract, +): string | undefined { + const sourcePath = resolve(root, source.path); + const relativePath = relative(root, sourcePath); + if (relativePath === "" || relativePath.startsWith("..") || relativePath.startsWith(`..${sep}`)) { + return undefined; + } + + return normalizeLogicalPath(relativePath.replace(/\\/gu, "/")); +} + export class LocalWorkspaceController implements WorkspaceController { public readonly root: string; @@ -151,23 +185,40 @@ export class LocalWorkspaceController implements WorkspaceController { readonly #shadowRoot: string | undefined; + readonly #envPolicy: ResolvedEnvPolicy | undefined; + + readonly #envSourcePaths: ReadonlySet; + + readonly #denyRead: readonly string[]; + private constructor(options: { root: string; mode: "live" | "shadow"; mounts: readonly MountBinding[]; shadowRoot?: string; emit?: EmitEvent; + envPolicy?: ResolvedEnvPolicy; + denyRead: readonly string[]; }) { this.root = options.root; this.mode = options.mode; this.#mounts = options.mounts; this.#shadowRoot = options.shadowRoot; this.#emit = options.emit; + this.#envPolicy = options.envPolicy; + this.#denyRead = options.denyRead; + this.#envSourcePaths = new Set( + options.envPolicy?.sources + .filter((source): source is Extract => source.type === "file") + .map((source) => sourceLogicalPath(options.root, source)) + .filter((path): path is string => path !== undefined) ?? [], + ); } public static async create( options: WorkspaceOptions, emit?: EmitEvent, + policy?: { env?: EnvPolicy }, ): Promise { const workspaceRoot = resolve(options.root); const mode = options.mode ?? "live"; @@ -198,6 +249,8 @@ export class LocalWorkspaceController implements WorkspaceController { } mounts.sort((left, right) => right.mountPath.length - left.mountPath.length); + const envPolicy = + policy?.env === undefined ? undefined : await resolveEnvPolicy(activeRoot, policy.env); return new LocalWorkspaceController({ root: activeRoot, @@ -205,12 +258,28 @@ export class LocalWorkspaceController implements WorkspaceController { mounts, shadowRoot, emit, + envPolicy, + denyRead: options.denyRead ?? [], }); } public async read(path: string): Promise { try { const target = await this.#resolveTarget(path); + const envContent = await this.#tryReadEnvSource(target); + if (envContent !== undefined) { + const content = Buffer.from(envContent, "utf8"); + await this.#emitEvent({ + scope: "workspace", + action: "read", + outcome: "success", + target: target.logicalPath, + detail: `${content.byteLength} bytes`, + }); + return content; + } + + await this.#assertReadable(target.logicalPath); const content = await readFile(target.physicalPath); await this.#emitEvent({ scope: "workspace", @@ -221,7 +290,9 @@ export class LocalWorkspaceController implements WorkspaceController { }); return content; } catch (error) { - await this.#emitFailure("read", path, error); + if (!isWorkspaceReadDeniedError(error)) { + await this.#emitFailure("read", path, error); + } throw error; } } @@ -359,7 +430,16 @@ export class LocalWorkspaceController implements WorkspaceController { continue; } - const content = await this.readText(path); + let content: string; + try { + content = await this.readText(path); + } catch (error) { + if (isWorkspaceReadDeniedError(error)) { + continue; + } + + throw error; + } const lines = content.split(/\r?\n/u); for (const [index, line] of lines.entries()) { const haystack = caseSensitive ? line : line.toLowerCase(); @@ -484,6 +564,32 @@ export class LocalWorkspaceController implements WorkspaceController { }; } + async #tryReadEnvSource(target: ResolvedTarget): Promise { + if (!this.#envSourcePaths.has(target.logicalPath) || this.#envPolicy === undefined) { + return undefined; + } + + const content = await readFile(target.physicalPath, "utf8"); + const filtered = filterEnvValues(parseEnvFile(content), this.#envPolicy); + return serializeEnvFile(filtered); + } + + async #assertReadable(logicalPath: string): Promise { + const isEnvLike = logicalPath + .split("/") + .some((segment) => segment === ".env" || segment.startsWith(".env.")); + const isDenied = this.#denyRead.some((pattern) => matchesGlob(logicalPath, pattern)); + if (isEnvLike || isDenied) { + await this.#emitEvent({ + scope: "workspace", + action: "read-denied", + outcome: "denied", + target: logicalPath, + }); + throw new WorkspaceReadDeniedError(`Path is not readable: ${logicalPath}`); + } + } + async #emitEvent(event: Omit): Promise { if (this.#emit === undefined) { return; diff --git a/packages/agent-container/tests/env.integration.test.ts b/packages/agent-container/tests/env.integration.test.ts index 7df8d54..a27f083 100644 --- a/packages/agent-container/tests/env.integration.test.ts +++ b/packages/agent-container/tests/env.integration.test.ts @@ -41,6 +41,7 @@ describe("env integration", () => { type: "inline", values: { PUBLIC_MODE: "inline", + PUBLIC_READ_KEY: "public-key", INLINE_FLAG: "enabled", API_SECRET_TOKEN: "inline-secret", }, @@ -55,9 +56,11 @@ describe("env integration", () => { "PUBLIC_FILE_ONLY", "PUBLIC_LOCAL_ONLY", "PUBLIC_MODE", + "PUBLIC_READ_KEY", ]); expect(snapshot.secretKeys).toEqual(["API_SECRET_TOKEN"]); expect(env.get("PUBLIC_MODE")).toBe("inline"); + expect(env.get("PUBLIC_READ_KEY")).toBe("public-key"); expect(env.get("PROCESS_VISIBLE")).toBe("from-process"); expect(env.get("PROCESS_BLOCKED")).toBeUndefined(); expect(env.getClassification("API_SECRET_TOKEN")).toBe("secret"); @@ -67,6 +70,7 @@ describe("env integration", () => { PUBLIC_FILE_ONLY: "base", PUBLIC_LOCAL_ONLY: "local", PUBLIC_MODE: "inline", + PUBLIC_READ_KEY: "public-key", }); expect(env.toObject({ includeSecrets: true })).toMatchObject({ API_SECRET_TOKEN: "inline-secret", @@ -92,4 +96,27 @@ describe("env integration", () => { } } }); + + it("discovers root env files by default and filters excluded private keys", async () => { + const workspace = await createTempWorkspace("agent-container-env-default-sources-"); + resources.add(workspace); + await writeWorkspaceFiles(workspace.root, { + ".env": "PUBLIC_READ_KEY=hello-world\nPRIVATE_API_KEY=foobarbaz\n", + ".env.prod": "PUBLIC_PROD_MODE=enabled\nPRIVATE_PROD_TOKEN=hidden\n", + "nested/.env": "PUBLIC_NESTED_MODE=ignored\n", + }); + + const env = await resolveEnv(workspace.root, { + include: ["PUBLIC_*"], + processEnv: "none", + }); + + expect(env.snapshot().publicKeys).toEqual(["PUBLIC_PROD_MODE", "PUBLIC_READ_KEY"]); + expect(env.snapshot().secretKeys).toEqual([]); + expect(env.get("PUBLIC_READ_KEY")).toBe("hello-world"); + expect(env.get("PUBLIC_PROD_MODE")).toBe("enabled"); + expect(env.get("PRIVATE_API_KEY")).toBeUndefined(); + expect(env.get("PRIVATE_PROD_TOKEN")).toBeUndefined(); + expect(env.get("PUBLIC_NESTED_MODE")).toBeUndefined(); + }); }); diff --git a/packages/agent-container/tests/workspace.integration.test.ts b/packages/agent-container/tests/workspace.integration.test.ts index ed2c677..f87322d 100644 --- a/packages/agent-container/tests/workspace.integration.test.ts +++ b/packages/agent-container/tests/workspace.integration.test.ts @@ -156,4 +156,94 @@ describe("workspace integration", () => { }, ]); }); + + it("returns filtered content for default root env sources", async () => { + const workspaceRoot = await createTempWorkspace("agent-container-workspace-env-default-"); + await writeWorkspaceFiles(workspaceRoot.root, { + ".env": "PUBLIC_READ_KEY=hello-world\nPRIVATE_API_KEY=foobarbaz\n", + ".env.local": "PUBLIC_LOCAL_MODE=demo\nPRIVATE_LOCAL_TOKEN=hidden\n", + "package.json": "{\"name\":\"demo\"}\n", + "src/index.ts": "export const value = 1;\n", + "src/.env.prod": "PRIVATE_NESTED_TOKEN=hidden\n", + }); + + const events: ObservabilityEvent[] = []; + const workspace = await LocalWorkspaceController.create( + { + root: workspaceRoot.root, + mode: "live", + }, + async (event) => { + events.push({ timestamp: new Date().toISOString(), ...event }); + }, + { + env: { + include: ["PUBLIC_*"], + processEnv: "none", + }, + }, + ); + resources.add({ workspaces: [workspaceRoot], controller: workspace }); + + expect(await workspace.readText("package.json")).toBe("{\"name\":\"demo\"}\n"); + expect(await workspace.readText("src/index.ts")).toBe("export const value = 1;\n"); + expect(await workspace.readText(".env")).toBe("PUBLIC_READ_KEY=\"hello-world\""); + expect(await workspace.readText(".env.local")).toBe("PUBLIC_LOCAL_MODE=\"demo\""); + expect(await workspace.grep("PRIVATE_API_KEY", { include: [".env", ".env.local"] })).toEqual([]); + await expect(workspace.readText("src/.env.prod")).rejects.toThrow( + "Path is not readable: src/.env.prod", + ); + expect( + events.some((event) => event.scope === "workspace" && event.action === "read-denied"), + ).toBe(true); + }); + + it("uses explicit env file sources as the only readable env-like files", async () => { + const workspaceRoot = await createTempWorkspace("agent-container-workspace-env-explicit-"); + await writeWorkspaceFiles(workspaceRoot.root, { + ".env": "PUBLIC_READ_KEY=hello-world\nPRIVATE_API_KEY=foobarbaz\n", + ".env.local": "PUBLIC_LOCAL_MODE=demo\n", + }); + + const workspace = await LocalWorkspaceController.create( + { + root: workspaceRoot.root, + mode: "live", + }, + undefined, + { + env: { + sources: [{ type: "file", path: ".env" }], + include: ["PUBLIC_*"], + processEnv: "none", + }, + }, + ); + resources.add({ workspaces: [workspaceRoot], controller: workspace }); + + expect(await workspace.readText(".env")).toBe("PUBLIC_READ_KEY=\"hello-world\""); + await expect(workspace.readText(".env.local")).rejects.toThrow( + "Path is not readable: .env.local", + ); + }); + + it("applies custom denyRead patterns to non-env files", async () => { + const workspaceRoot = await createTempWorkspace("agent-container-workspace-deny-read-"); + await writeWorkspaceFiles(workspaceRoot.root, { + "package.json": "{\"name\":\"demo\"}\n", + "config/app.secret": "hidden\n", + }); + + const workspace = await LocalWorkspaceController.create({ + root: workspaceRoot.root, + mode: "live", + denyRead: ["**/*.secret"], + }); + resources.add({ workspaces: [workspaceRoot], controller: workspace }); + + expect(await workspace.readText("package.json")).toBe("{\"name\":\"demo\"}\n"); + await expect(workspace.readText("config/app.secret")).rejects.toThrow( + "Path is not readable: config/app.secret", + ); + }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a1307ac..23720ec 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -29,6 +29,7 @@ export interface EnvPolicy { sources?: readonly EnvSource[]; include?: readonly string[]; exclude?: readonly string[]; + publicPatterns?: readonly string[]; secretPatterns?: readonly string[]; processEnv?: ProcessEnvMode; } @@ -62,6 +63,7 @@ export interface WorkspaceOptions { root: string; mode?: WorkspaceMode; mounts?: readonly WorkspaceMount[]; + denyRead?: readonly string[]; } export type WorkspaceEntryKind = "file" | "directory" | "symlink" | "other";