Files
envsitter-guard/test/envsitter-guard.v2.test.ts
T
David Ibia 0595794385 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).
2026-10-20 15:10:32 +01:00

205 lines
8.5 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 { 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");
});