0595794385
- 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).
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
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 { 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-"));
|
|
return dir;
|
|
}
|
|
|
|
function createClientSpy(): {
|
|
client: {
|
|
tui: {
|
|
showToast: (input: { body: { title: string; variant: string; message: string } }) => Promise<void>;
|
|
};
|
|
};
|
|
calls: { showToast: number };
|
|
} {
|
|
const calls = { showToast: 0 };
|
|
|
|
return {
|
|
calls,
|
|
client: {
|
|
tui: {
|
|
async showToast() {
|
|
calls.showToast += 1;
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function getHooks(params: { directory: string; worktree: string }) {
|
|
const { client, calls } = createClientSpy();
|
|
|
|
const pluginInput: PluginInput = {
|
|
client: client as unknown as PluginInput["client"],
|
|
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.server(pluginInput)) as {
|
|
"tool.execute.before": ToolExecuteBeforeHook;
|
|
"tool.execute.after": ToolExecuteAfterHook;
|
|
};
|
|
|
|
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 { before } = await getBeforeHook();
|
|
|
|
await assert.rejects(
|
|
() => before(call("read"), { args: { filePath: ".env" } }),
|
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
|
);
|
|
});
|
|
|
|
test("allows reading example and template env files", async () => {
|
|
const { before } = await getBeforeHook();
|
|
|
|
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 { before } = await getBeforeHook();
|
|
|
|
await assert.rejects(
|
|
() => before(call("edit"), { args: { filePath: ".env" } }),
|
|
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
|
);
|
|
});
|
|
|
|
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(
|
|
() => 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 { before } = await getBeforeHook();
|
|
|
|
await assert.rejects(
|
|
() => 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 { before, calls } = await getBeforeHook();
|
|
|
|
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");
|
|
});
|