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
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ That distinction is the point of this project. Agent Container gives coding agen
<p align="center">
<a href="#quick-start">Quick Start</a> &middot;
<a href="#why">Why</a> &middot;
<a href="#threat-model"</a> &middot;
<a href="#threat-model">Threat Model</a> &middot;
<a href="#how-it-works">How It Works</a> &middot;
<a href="#bindings">Bindings</a> &middot;
<a href="#api">API</a> &middot;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: <path>`.

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.
Expand Down Expand Up @@ -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({
Expand All @@ -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:
Expand Down Expand Up @@ -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";
};
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-container/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,10 @@ export class LocalAgentContainer implements AgentContainer {
export async function createAgentContainer(
options: AgentContainerOptions,
): Promise<AgentContainer> {
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({
Expand Down
117 changes: 98 additions & 19 deletions packages/agent-container/src/env.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<readonly EnvSource[]> {
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<readonly EnvSource[]> {
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[],
Expand All @@ -47,7 +67,21 @@ function shouldIncludeName(
return !matchesPatterns(name, exclude);
}

function parseEnvFile(content: string): Record<string, string> {
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<string, string> {
const values: Record<string, string> = {};

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

export function serializeEnvFile(values: Record<string, string>): string {
return Object.entries(values)
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
.join("\n");
}

export function filterEnvValues(
values: Record<string, string>,
options: {
include: readonly string[];
exclude: readonly string[];
},
): Record<string, string> {
const filtered: Record<string, string> = {};
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<EnvSource, { type: "file" }>,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -185,26 +244,43 @@ class ResolvedEnvMap implements ResolvedEnv {
}
}

export async function resolveEnvPolicy(
repoRoot: string,
policy: EnvPolicy,
): Promise<ResolvedEnvPolicy> {
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<ResolvedEnv> {
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<string, { value: string; source: string }>();
for (const source of normalizeSources(policy)) {
for (const source of envPolicy.sources) {
let values: Record<string, string>;
let sourceName: string;

if (source.type === "file") {
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;
Expand All @@ -218,14 +294,17 @@ export async function resolveEnv(repoRoot: string, policy?: EnvPolicy): Promise<

const finalEntries: Record<string, ResolvedEnvEntry> = {};
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,
}),
};
}

Expand Down
Loading
Loading