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:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user