feat: support OpenCode 2 and scrub secret values from tool output
- Default export is { id, setup, server }: OpenCode 2 calls setup(ctx), OpenCode 1 (1.18.29+) calls server()
- Tools defined once in lib/tools.ts and registered for both versions (V2 with codemode: false)
- Blocking covers path args of any tool, patch/apply_patch envelopes, shell commands naming env files, grep output, and V2 permission checks
- Secret-looking values from the project's env files are replaced in every non-envsitter tool's output
- Protected files come from envsitter's path policy; example/template files stay readable
- npm run test:e2e drives the real OpenCode 1 and 2 CLIs with a scripted model
- @opencode-ai/plugin ^1.19.3, @opencode/plugin ^2.1.0 (peer >=2.0.32), envsitter ^0.1.0, TypeScript 6
BREAKING CHANGE: the named export EnvSitterGuard is now the plugin definition object; call EnvSitterGuard.server(input) instead of EnvSitterGuard(input).
This commit is contained in:
@@ -9,6 +9,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
||||
import EnvSitterGuard from "../index.js";
|
||||
|
||||
type ToolExecuteBeforeHook = NonNullable<Hooks["tool.execute.before"]>;
|
||||
type ToolExecuteAfterHook = NonNullable<Hooks["tool.execute.after"]>;
|
||||
|
||||
async function createTmpDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-"));
|
||||
@@ -37,7 +38,7 @@ function createClientSpy(): {
|
||||
};
|
||||
}
|
||||
|
||||
async function getBeforeHook(params: { directory: string; worktree: string }) {
|
||||
async function getHooks(params: { directory: string; worktree: string }) {
|
||||
const { client, calls } = createClientSpy();
|
||||
|
||||
const pluginInput: PluginInput = {
|
||||
@@ -45,72 +46,142 @@ async function getBeforeHook(params: { directory: string; worktree: string }) {
|
||||
project: {} as unknown as PluginInput["project"],
|
||||
directory: params.directory,
|
||||
worktree: params.worktree,
|
||||
experimental_workspace: { register() {} },
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: (() => {
|
||||
throw new Error("not used in tests");
|
||||
}) as unknown as PluginInput["$"],
|
||||
};
|
||||
|
||||
const hooks = (await EnvSitterGuard(pluginInput)) as {
|
||||
const hooks = (await EnvSitterGuard.server(pluginInput)) as {
|
||||
"tool.execute.before": ToolExecuteBeforeHook;
|
||||
"tool.execute.after": ToolExecuteAfterHook;
|
||||
};
|
||||
|
||||
return { hook: hooks["tool.execute.before"], calls };
|
||||
return { before: hooks["tool.execute.before"], after: hooks["tool.execute.after"], calls };
|
||||
}
|
||||
|
||||
async function getBeforeHook() {
|
||||
const worktree = await createTmpDir();
|
||||
return getHooks({ directory: worktree, worktree });
|
||||
}
|
||||
|
||||
function call(tool: string) {
|
||||
return { tool, sessionID: "s", callID: "c" };
|
||||
}
|
||||
|
||||
test("blocks reading .env", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(
|
||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
||||
() => before(call("read"), { args: { filePath: ".env" } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||
);
|
||||
});
|
||||
|
||||
test("allows reading .env.example", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
||||
test("allows reading example and template env files", async () => {
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env.example" } });
|
||||
for (const filePath of [".env.example", ".env.sample", ".env.template", "api.env.example"]) {
|
||||
await before(call("read"), { args: { filePath } });
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks reading named env files such as api.env and config.env.local", async () => {
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
for (const filePath of ["api.env", "config/config.env.local", "/abs/project/.env.production"]) {
|
||||
await assert.rejects(
|
||||
() => before(call("read"), { args: { filePath } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks editing .env", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(
|
||||
() => hook({ tool: "edit", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
||||
() => before(call("edit"), { args: { filePath: ".env" } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks .envsitter/pepper", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
||||
test("blocks apply_patch when the patch touches .env", async () => {
|
||||
const { before } = await getBeforeHook();
|
||||
const patchText = ["*** Begin Patch", "*** Update File: src/app.ts", "@@", "*** Add File: .env.local", "+TOKEN=x", "*** End Patch"].join("\n");
|
||||
|
||||
await assert.rejects(
|
||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".envsitter/pepper" } }),
|
||||
() => before(call("apply_patch"), { args: { patchText } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("`.env.local`"),
|
||||
);
|
||||
|
||||
await before(call("apply_patch"), { args: { patchText: "*** Begin Patch\n*** Update File: src/app.ts\n*** End Patch" } });
|
||||
});
|
||||
|
||||
test("blocks shell commands that name .env files but allows the envsitter CLI", async () => {
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(
|
||||
() => before(call("bash"), { args: { command: "cat .env | head -5" } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Shell commands that reference"),
|
||||
);
|
||||
|
||||
await before(call("bash"), { args: { command: "npx envsitter keys --file .env --json" } });
|
||||
await before(call("bash"), { args: { command: 'git commit -m "document .env usage"' } });
|
||||
});
|
||||
|
||||
test("blocks .envsitter/pepper", async () => {
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(
|
||||
() => before(call("read"), { args: { filePath: ".envsitter/pepper" } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("blocked"),
|
||||
);
|
||||
});
|
||||
|
||||
test("strips @ prefix in filePath", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
||||
const { before } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(
|
||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: "@.env" } }),
|
||||
() => before(call("read"), { args: { filePath: "@.env" } }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||
);
|
||||
});
|
||||
|
||||
test("blocking is silent (no toasts)", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const { hook, calls } = await getBeforeHook({ directory: worktree, worktree });
|
||||
const { before, calls } = await getBeforeHook();
|
||||
|
||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
||||
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||
|
||||
assert.equal(calls.showToast, 0, "should not show toasts, only throw errors");
|
||||
});
|
||||
|
||||
test("hides grep matches from protected env files", async () => {
|
||||
const { after } = await getBeforeHook();
|
||||
const output = {
|
||||
title: "TOKEN",
|
||||
metadata: {},
|
||||
output: ["Found 3 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 4: process.env.TOKEN", " Line 9: TOKEN"].join("\n"),
|
||||
};
|
||||
|
||||
await after({ ...call("grep"), args: { pattern: "TOKEN" } }, output);
|
||||
|
||||
assert.ok(!output.output.includes("super-secret"));
|
||||
assert.ok(output.output.startsWith("Found 2 matches"));
|
||||
assert.ok(output.output.includes("/repo/src/app.ts:"));
|
||||
assert.ok(output.output.includes("1 match in protected .env files hidden"));
|
||||
});
|
||||
|
||||
test("scrubs secret values from any tool output, such as shell commands that never name .env", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
await fs.writeFile(path.join(worktree, ".env"), "API_TOKEN=tok_live_9f8a7b6c5d4e\nNODE_ENV=development\n");
|
||||
const { after } = await getHooks({ directory: worktree, worktree });
|
||||
|
||||
const output = { title: "cat logs/app.log", metadata: {}, output: "env=development\ncalling api with tok_live_9f8a7b6c5d4e\n" };
|
||||
await after({ ...call("bash"), args: { command: "cat logs/app.log" } }, output);
|
||||
|
||||
assert.equal(output.output, "env=development\ncalling api with [envsitter: API_TOKEN redacted]\n");
|
||||
});
|
||||
|
||||
@@ -125,13 +125,14 @@ async function getTools(params: { directory: string; worktree: string }): Promis
|
||||
project: {} as unknown as PluginInput["project"],
|
||||
directory: params.directory,
|
||||
worktree: params.worktree,
|
||||
experimental_workspace: { register() {} },
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: (() => {
|
||||
throw new Error("not used in tests");
|
||||
}) as unknown as PluginInput["$"],
|
||||
};
|
||||
|
||||
const hooks = (await EnvSitterGuard(pluginInput)) as unknown as {
|
||||
const hooks = (await EnvSitterGuard.server(pluginInput)) as unknown as {
|
||||
tool: ToolApi;
|
||||
};
|
||||
|
||||
@@ -305,6 +306,24 @@ test("tool execution rejects paths outside worktree", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("envsitter tools accept named env files such as api.env", async () => {
|
||||
await withPepper(async () => {
|
||||
const worktree = await createTmpDir();
|
||||
await fs.writeFile(path.join(worktree, "api.env"), "API_TOKEN=secret-token\n");
|
||||
|
||||
const tools = await getTools({ directory: worktree, worktree });
|
||||
const out = await tools.envsitter_keys.execute({ filePath: "api.env" });
|
||||
|
||||
assert.ok(!out.includes("secret-token"));
|
||||
assert.deepEqual((JSON.parse(out) as { keys: string[] }).keys, ["API_TOKEN"]);
|
||||
|
||||
await assert.rejects(
|
||||
() => tools.envsitter_keys.execute({ filePath: "src/index.ts" }),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("Only dotenv files are allowed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("tool execution blocks .envsitter/pepper", async () => {
|
||||
const worktree = await createTmpDir();
|
||||
const tools = await getTools({ directory: worktree, worktree });
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Plugin } from "@opencode/plugin";
|
||||
|
||||
import EnvSitterGuard from "../index.js";
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
input: Record<string, unknown>;
|
||||
options?: { codemode?: boolean };
|
||||
execute: (input: unknown, context: unknown) => Promise<{ content?: unknown }>;
|
||||
};
|
||||
|
||||
type HookCallback = (event: Record<string, unknown>) => unknown;
|
||||
|
||||
const registration = { dispose: async () => {} };
|
||||
|
||||
async function setupV2(directory: string, root = directory) {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const toolHooks = new Map<string, HookCallback>();
|
||||
const permissionHooks = new Map<string, HookCallback>();
|
||||
|
||||
const ctx = {
|
||||
location: { directory, project: { id: "project", directory: root, canonical: root } },
|
||||
options: {},
|
||||
tool: {
|
||||
async transform(callback: (editor: { add(tool: RegisteredTool): void }) => void) {
|
||||
callback({ add: (tool) => tools.set(tool.name, tool) });
|
||||
return registration;
|
||||
},
|
||||
async hook(name: string, callback: HookCallback) {
|
||||
toolHooks.set(name, callback);
|
||||
return registration;
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
async hook(name: string, callback: HookCallback) {
|
||||
permissionHooks.set(name, callback);
|
||||
return registration;
|
||||
},
|
||||
},
|
||||
} as unknown as Plugin.Context;
|
||||
|
||||
await EnvSitterGuard.setup(ctx);
|
||||
|
||||
const hook = (map: Map<string, HookCallback>, name: string): HookCallback => {
|
||||
const callback = map.get(name);
|
||||
assert.ok(callback, `missing hook ${name}`);
|
||||
return callback;
|
||||
};
|
||||
|
||||
return {
|
||||
tools,
|
||||
before: hook(toolHooks, "execute.before"),
|
||||
after: hook(toolHooks, "execute.after"),
|
||||
evaluate: hook(permissionHooks, "evaluate"),
|
||||
};
|
||||
}
|
||||
|
||||
async function createProject(files: Record<string, string>): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-v2-"));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
await fs.writeFile(path.join(dir, name), contents);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function toolEvent(tool: string, input: Record<string, unknown>) {
|
||||
return { tool, sessionID: "ses_1", agent: "build", messageID: "msg_1", id: "call_1", input };
|
||||
}
|
||||
|
||||
test("V2 default export carries a stable id plus both entrypoints", () => {
|
||||
assert.equal(EnvSitterGuard.id, "envsitter-guard");
|
||||
assert.equal(typeof EnvSitterGuard.setup, "function");
|
||||
assert.equal(typeof EnvSitterGuard.server, "function");
|
||||
});
|
||||
|
||||
test("V2 registers every envsitter tool directly (not behind Code Mode) with JSON Schema inputs", async () => {
|
||||
const { tools } = await setupV2(await createProject({}));
|
||||
|
||||
assert.equal(tools.size, 15);
|
||||
for (const tool of tools.values()) {
|
||||
assert.ok(tool.name.startsWith("envsitter_"), tool.name);
|
||||
assert.equal(tool.options?.codemode, false, tool.name);
|
||||
assert.equal(tool.input.type, "object", tool.name);
|
||||
assert.equal("$schema" in tool.input, false, tool.name);
|
||||
}
|
||||
|
||||
const fingerprint = tools.get("envsitter_fingerprint");
|
||||
assert.deepEqual(fingerprint?.input.required, ["key"]);
|
||||
});
|
||||
|
||||
test("V2 tools return structured content without values and validate input", async () => {
|
||||
const dir = await createProject({ ".env": "API_TOKEN=very-secret\nPORT=3000\n" });
|
||||
const { tools } = await setupV2(dir);
|
||||
|
||||
const keys = tools.get("envsitter_keys");
|
||||
assert.ok(keys);
|
||||
const result = await keys.execute({ filePath: ".env" }, {});
|
||||
assert.equal(typeof result.content, "string");
|
||||
assert.ok(!String(result.content).includes("very-secret"));
|
||||
assert.deepEqual((JSON.parse(String(result.content)) as { keys: string[] }).keys, ["API_TOKEN", "PORT"]);
|
||||
|
||||
const fingerprint = tools.get("envsitter_fingerprint");
|
||||
assert.ok(fingerprint);
|
||||
await assert.rejects(() => fingerprint.execute({ filePath: ".env" }, {}));
|
||||
});
|
||||
|
||||
test("V2 tools resolve paths from the plugin location and stay inside the project", async () => {
|
||||
const root = await createProject({ "apps/api/.env": "A=1\n" });
|
||||
const { tools } = await setupV2(path.join(root, "apps", "api"), root);
|
||||
|
||||
const keys = tools.get("envsitter_keys");
|
||||
assert.ok(keys);
|
||||
const result = await keys.execute({}, {});
|
||||
assert.equal((JSON.parse(String(result.content)) as { file: string }).file, path.join("apps", "api", ".env"));
|
||||
|
||||
await assert.rejects(
|
||||
() => keys.execute({ filePath: "../../../outside/.env" }, {}),
|
||||
(err: unknown) => err instanceof Error && err.message.includes("inside the current project"),
|
||||
);
|
||||
});
|
||||
|
||||
test("V2 execute.before blocks built-in tools on protected files, including nested Code Mode calls", async () => {
|
||||
const { before } = await setupV2(await createProject({}));
|
||||
|
||||
assert.throws(() => before(toolEvent("read", { path: ".env" })), /Reading `\.env\*` is blocked/);
|
||||
assert.throws(() => before(toolEvent("write", { path: "api.env", content: "X=1" })), /Editing `\.env\*`/);
|
||||
assert.throws(
|
||||
() => before(toolEvent("patch", { patchText: "*** Begin Patch\n*** Delete File: .env.production\n*** End Patch" })),
|
||||
/Editing `\.env\*`/,
|
||||
);
|
||||
assert.throws(() => before(toolEvent("shell", { command: "source .env && env" })), /Shell commands that reference/);
|
||||
assert.throws(() => before(toolEvent("grep", { pattern: "TOKEN", path: ".env.local" })), /Reading `\.env\*` is blocked/);
|
||||
assert.throws(() => before(toolEvent("filesystem_read_file", { path: "/abs/.env" })), /through `filesystem_read_file`/);
|
||||
|
||||
before(toolEvent("read", { path: ".env.example" }));
|
||||
before(toolEvent("read", { path: "src/index.ts" }));
|
||||
before(toolEvent("shell", { command: "npm test" }));
|
||||
before(toolEvent("envsitter_keys", { filePath: ".env" }));
|
||||
});
|
||||
|
||||
test("V2 execute.after hides grep matches from protected files in content and structured output", async () => {
|
||||
const { after } = await setupV2(await createProject({}));
|
||||
|
||||
const event = {
|
||||
...toolEvent("grep", { pattern: "TOKEN" }),
|
||||
status: "completed",
|
||||
result: {
|
||||
content: ["Found 2 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 3: TOKEN"].join("\n"),
|
||||
output: [
|
||||
{ entry: { path: ".env" }, line: 1, text: "TOKEN=super-secret" },
|
||||
{ entry: { path: "src/app.ts" }, line: 3, text: "TOKEN" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await after(event);
|
||||
|
||||
const result = event.result as { content: string; output: Array<{ entry: { path: string } }> };
|
||||
assert.ok(!result.content.includes("super-secret"));
|
||||
assert.ok(result.content.startsWith("Found 1 matches"));
|
||||
assert.deepEqual(
|
||||
result.output.map((match) => match.entry.path),
|
||||
["src/app.ts"],
|
||||
);
|
||||
});
|
||||
|
||||
test("V2 execute.after scrubs secret values from shell results that never name .env", async () => {
|
||||
const { after } = await setupV2(await createProject({ ".env": "API_TOKEN=tok_live_9f8a7b6c5d4e\n" }));
|
||||
|
||||
const event = {
|
||||
...toolEvent("shell", { command: "grep -r api ." }),
|
||||
status: "completed",
|
||||
result: { content: [{ type: "text", text: "./logs/app.log: api key tok_live_9f8a7b6c5d4e" }] },
|
||||
};
|
||||
await after(event);
|
||||
|
||||
const result = event.result as { content: Array<{ text: string }> };
|
||||
assert.equal(result.content[0]?.text, "./logs/app.log: api key [envsitter: API_TOKEN redacted]");
|
||||
});
|
||||
|
||||
test("V2 permission evaluate denies protected read, edit, and shell resources only", async () => {
|
||||
const { evaluate } = await setupV2(await createProject({}));
|
||||
|
||||
const deniedRead = { sessionID: "ses_1", action: "read", resources: ["/repo/.env"], effect: "allow" as string, message: undefined as string | undefined };
|
||||
await evaluate(deniedRead);
|
||||
assert.equal(deniedRead.effect, "deny");
|
||||
assert.match(deniedRead.message ?? "", /Reading `\.env\*` is blocked/);
|
||||
|
||||
const deniedShell = { sessionID: "ses_1", action: "shell", resources: ["cat .env.local"], effect: "ask" as string };
|
||||
await evaluate(deniedShell);
|
||||
assert.equal(deniedShell.effect, "deny");
|
||||
|
||||
const allowed = { sessionID: "ses_1", action: "edit", resources: ["/repo/src/app.ts", "/repo/.env.example"], effect: "allow" as string };
|
||||
await evaluate(allowed);
|
||||
assert.equal(allowed.effect, "allow");
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { extractPatchPaths, findBlockedAccess, findProtectedPathsInCommand, redactGrepOutput } from "../lib/policy.js";
|
||||
|
||||
test("shell scan flags commands that name protected files", () => {
|
||||
const blocked = [
|
||||
"cat .env",
|
||||
"source .env",
|
||||
". .env.local",
|
||||
"echo TOKEN=x >> .env",
|
||||
"cat<.env",
|
||||
"node --env-file=.env app.js",
|
||||
'echo "$(cat .env)"',
|
||||
"echo `cat api.env`",
|
||||
"cat .env*",
|
||||
"cat .envsitter/pepper",
|
||||
"ls && cat config/app.env",
|
||||
"envsitter keys --file .env; cat .env",
|
||||
];
|
||||
|
||||
for (const command of blocked) {
|
||||
assert.ok(findProtectedPathsInCommand(command).length > 0, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("shell scan allows envsitter CLI calls, example files, and prose mentions", () => {
|
||||
const allowed = [
|
||||
"npx envsitter keys --file .env",
|
||||
"npx -y envsitter@latest match --file .env --key A --op exists",
|
||||
"envsitter set --file .env.local --key A --value-stdin --write",
|
||||
"pnpm dlx envsitter validate --file .env",
|
||||
'git commit -m "document .env usage"',
|
||||
"cat .env.example",
|
||||
"cp .env.example .env.sample",
|
||||
"ls -la",
|
||||
"npm test",
|
||||
];
|
||||
|
||||
for (const command of allowed) {
|
||||
assert.deepEqual(findProtectedPathsInCommand(command), [], command);
|
||||
}
|
||||
});
|
||||
|
||||
test("patch path extraction covers patch envelopes and unified diffs", () => {
|
||||
const envelope = ["*** Begin Patch", "*** Update File: src/a.ts", "*** Move to: src/b.ts", "*** Add File: .env", "*** Delete File: old.env", "*** End Patch"].join("\n");
|
||||
assert.deepEqual(extractPatchPaths(envelope), ["src/a.ts", "src/b.ts", ".env", "old.env"]);
|
||||
|
||||
const unified = ["--- a/.env.local", "+++ b/.env.local", "@@ -1 +1 @@", "-A=1", "+A=2"].join("\n");
|
||||
assert.deepEqual(extractPatchPaths(unified), [".env.local", ".env.local"]);
|
||||
assert.deepEqual(extractPatchPaths("--- /dev/null\n+++ b/src/new.ts"), ["src/new.ts"]);
|
||||
});
|
||||
|
||||
test("findBlockedAccess reads path arguments from V1 and V2 tool shapes", () => {
|
||||
assert.equal(findBlockedAccess("read", { filePath: ".env" })?.kind, "read");
|
||||
assert.equal(findBlockedAccess("read", { path: ".env" })?.kind, "read");
|
||||
assert.equal(findBlockedAccess("multi_read", { paths: ["a.ts", ".env.production"] })?.kind, "access");
|
||||
assert.equal(findBlockedAccess("envsitter_keys", { filePath: ".env" }), undefined);
|
||||
assert.equal(findBlockedAccess("read", "not-an-object"), undefined);
|
||||
});
|
||||
|
||||
test("grep redaction is a no-op when nothing is protected", () => {
|
||||
const output = "Found 1 matches\n/repo/src/a.ts:\n Line 1: TOKEN";
|
||||
assert.deepEqual(redactGrepOutput(output), { output, hidden: 0 });
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createScrubLoader, findSensitiveEnvFiles, scrubToolResult } from "../lib/scrub.js";
|
||||
|
||||
const TOKEN = "tok_live_9f8a7b6c5d4e";
|
||||
const DB_PASSWORD = "s3cretPassw0rd";
|
||||
|
||||
async function createProject(files: Record<string, string>): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-scrub-"));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
await fs.writeFile(path.join(dir, name), contents);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("finds sensitive env files in the project, skipping examples and dependency folders", async () => {
|
||||
const root = await createProject({
|
||||
".env": `API_TOKEN=${TOKEN}\n`,
|
||||
".env.example": "API_TOKEN=replace-me\n",
|
||||
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||
"node_modules/pkg/.env": "X=1\n",
|
||||
"a/b/c/d/.env": "TOO_DEEP=1\n",
|
||||
});
|
||||
|
||||
const files = (await findSensitiveEnvFiles(root)).map((file) => path.relative(root, file));
|
||||
assert.deepEqual(files, [".env", path.join("apps", "web", ".env.local")]);
|
||||
});
|
||||
|
||||
test("scrub replaces secret values from every env file and leaves other text alone", async () => {
|
||||
const root = await createProject({
|
||||
".env": `API_TOKEN=${TOKEN}\nNODE_ENV=development\n`,
|
||||
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||
});
|
||||
|
||||
const scrub = await createScrubLoader(root)();
|
||||
const output = scrub(`Bearer ${TOKEN}\npassword=${DB_PASSWORD}\nNODE_ENV=development`);
|
||||
assert.equal(output, "Bearer [envsitter: API_TOKEN redacted]\npassword=[envsitter: DATABASE_URL redacted]\nNODE_ENV=development");
|
||||
});
|
||||
|
||||
test("scrub picks up env file changes", async () => {
|
||||
const root = await createProject({ ".env": `API_TOKEN=${TOKEN}\n` });
|
||||
const loadScrub = createScrubLoader(root);
|
||||
assert.ok(!(await loadScrub())(TOKEN).includes(TOKEN));
|
||||
|
||||
const rotated = "tok_live_0a1b2c3d4e5f6a7b8c";
|
||||
await fs.writeFile(path.join(root, ".env"), `API_TOKEN=${rotated}\n`);
|
||||
const scrub = await loadScrub();
|
||||
assert.equal(scrub(rotated), "[envsitter: API_TOKEN redacted]");
|
||||
assert.equal(scrub(TOKEN), TOKEN);
|
||||
});
|
||||
|
||||
test("scrubToolResult scrubs text content and structured output, and keeps untouched results identical", () => {
|
||||
const scrub = (text: string) => text.replaceAll(TOKEN, "[x]");
|
||||
|
||||
const result = {
|
||||
content: [{ type: "text", text: `token ${TOKEN}` }, { type: "image", data: TOKEN }],
|
||||
output: { stdout: `ok ${TOKEN}`, exitCode: 0, lines: [TOKEN, "fine"] },
|
||||
metadata: { title: "t" },
|
||||
};
|
||||
const scrubbed = scrubToolResult(result, scrub);
|
||||
assert.deepEqual(scrubbed.content, [{ type: "text", text: "token [x]" }, { type: "image", data: TOKEN }]);
|
||||
assert.deepEqual(scrubbed.output, { stdout: "ok [x]", exitCode: 0, lines: ["[x]", "fine"] });
|
||||
assert.equal(scrubbed.metadata, result.metadata);
|
||||
|
||||
const clean = { content: "nothing secret", output: { exitCode: 0 } };
|
||||
assert.equal(scrubToolResult(clean, scrub), clean);
|
||||
});
|
||||
Reference in New Issue
Block a user