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";