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:
David Ibia
2026-10-20 15:10:32 +01:00
parent 2a53fe2d9c
commit 0595794385
17 changed files with 6110 additions and 971 deletions
+137
View File
@@ -0,0 +1,137 @@
import fs from "node:fs/promises";
import path from "node:path";
import { EnvSitter, isSensitiveEnvFile, type EnvSitterRedactor } from "envsitter";
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "out", "coverage", "target", "vendor", ".next", ".nuxt", ".turbo", ".cache", ".venv", "venv"]);
const MAX_DEPTH = 3;
const MAX_ENV_FILES = 50;
export type Scrub = (text: string) => string;
type CachedRedactor = {
readonly mtimeMs: number;
readonly size: number;
readonly redactor: EnvSitterRedactor;
};
function errorCode(error: unknown): string | undefined {
return error instanceof Error && "code" in error ? String((error as { code?: unknown }).code) : undefined;
}
export async function findSensitiveEnvFiles(root: string): Promise<string[]> {
const found: string[] = [];
let level = [root];
for (let depth = 0; depth <= MAX_DEPTH && level.length > 0; depth++) {
const next: string[] = [];
for (const dir of level) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
const code = errorCode(error);
if (code === "ENOENT" || code === "EACCES" || code === "ENOTDIR") continue;
throw error;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) next.push(fullPath);
} else if ((entry.isFile() || entry.isSymbolicLink()) && isSensitiveEnvFile(fullPath)) {
found.push(fullPath);
if (found.length >= MAX_ENV_FILES) return found.sort();
}
}
}
level = next;
}
return found.sort();
}
async function redactorFor(file: string, cache: Map<string, CachedRedactor>): Promise<EnvSitterRedactor | undefined> {
let info;
try {
info = await fs.stat(file);
} catch (error) {
if (errorCode(error) !== "ENOENT") throw error;
cache.delete(file);
return undefined;
}
if (!info.isFile()) return undefined;
const cached = cache.get(file);
if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.redactor;
const redactor = await EnvSitter.fromDotenvFile(file).redactor();
cache.set(file, { mtimeMs: info.mtimeMs, size: info.size, redactor });
return redactor;
}
// Redactors are cached per env file and rebuilt when the file's mtime or size changes.
export function createScrubLoader(root: string): () => Promise<Scrub> {
const cache = new Map<string, CachedRedactor>();
return async () => {
const redactors: EnvSitterRedactor[] = [];
for (const file of await findSensitiveEnvFiles(root)) {
const redactor = await redactorFor(file, cache);
if (redactor && redactor.keys.length > 0) redactors.push(redactor);
}
return (text) => redactors.reduce((current, redactor) => redactor.redact(current).text, text);
};
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function scrubContent(content: unknown, scrub: Scrub): unknown {
if (typeof content === "string") return scrub(content);
if (!Array.isArray(content)) return content;
let changed = false;
const parts = content.map((part: unknown) => {
const record = asRecord(part);
if (record?.type !== "text" || typeof record.text !== "string") return part;
const text = scrub(record.text);
if (text === record.text) return part;
changed = true;
return { ...record, text };
});
return changed ? parts : content;
}
function scrubStrings(value: unknown, scrub: Scrub): unknown {
if (typeof value === "string") return scrub(value);
if (Array.isArray(value)) {
let changed = false;
const items = value.map((item: unknown) => {
const scrubbed = scrubStrings(item, scrub);
if (scrubbed !== item) changed = true;
return scrubbed;
});
return changed ? items : value;
}
const record = asRecord(value);
if (!record || (Object.getPrototypeOf(record) !== Object.prototype && Object.getPrototypeOf(record) !== null)) return value;
let changed = false;
const entries = Object.entries(record).map(([key, item]) => {
const scrubbed = scrubStrings(item, scrub);
if (scrubbed !== item) changed = true;
return [key, scrubbed] as const;
});
return changed ? Object.fromEntries(entries) : value;
}
export function scrubToolResult<Result extends { readonly content?: unknown; readonly output?: unknown }>(result: Result, scrub: Scrub): Result {
const content = scrubContent(result.content, scrub);
const output = scrubStrings(result.output, scrub);
if (content === result.content && output === result.output) return result;
return { ...result, content, output };
}