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
+173
View File
@@ -0,0 +1,173 @@
export const HELP_TOPICS = ["overview", "reading", "matching", "mutations", "file_ops", "all"] as const;
export type HelpTopic = (typeof HELP_TOPICS)[number];
const overview = `
## EnvSitter Tools Overview
EnvSitter provides safe .env file operations that NEVER expose secret values.
All tools return keys, booleans, line numbers, and operation plans only.
### Why Use EnvSitter?
- Direct access to sensitive env files (\`.env\`, \`.env.*\`, \`*.env\`) is BLOCKED to prevent secret leaks:
reads, edits/writes/patches, shell commands that name them, and grep matches inside them
- Secret-looking values from those files are replaced with \`[envsitter: KEY redacted]\` in other tools' output
- Example/template files (\`.env.example\`, \`.env.sample\`, \`.env.template\`, \`.env.dist\`, \`.env.default(s)\`) stay readable
- These tools let you inspect, validate, and modify env files safely
- File modifications are dry-run by default; use \`write: true\` to apply
### Tool Categories
- **Reading**: envsitter_keys, envsitter_fingerprint, envsitter_scan
- **Matching**: envsitter_match, envsitter_match_by_key
- **Mutations**: envsitter_add, envsitter_set, envsitter_unset, envsitter_delete
- **File Ops**: envsitter_validate, envsitter_copy, envsitter_format, envsitter_annotate
`;
const reading = `
## Reading Tools (never return values)
### envsitter_keys
List all keys in a .env file.
\`\`\`json
{ "filePath": ".env", "filterRegex": "/^API_/" }
\`\`\`
Returns: \`{ file, keys: string[] }\`
### envsitter_fingerprint
Get a deterministic fingerprint of a key's value (for comparison/auditing).
\`\`\`json
{ "filePath": ".env", "key": "DATABASE_URL" }
\`\`\`
Returns: \`{ file, key, result: { algorithm, fingerprint, length } }\`
### envsitter_scan
Detect value shapes (JWT, URL, base64) without revealing values.
\`\`\`json
{ "filePath": ".env", "detect": ["jwt", "url", "base64"], "keysFilterRegex": "/TOKEN/" }
\`\`\`
Returns: \`{ file, findings: [{ key, detections }] }\`
`;
const matching = `
## Matching Tools (return booleans only)
### envsitter_match
Check if a key's value matches criteria without seeing the value.
**Operations** (op parameter):
- \`exists\`: key is present
- \`is_empty\`: value is empty string
- \`is_equal\`: matches candidate exactly (provide \`candidate\` or \`candidateEnvVar\`)
- \`partial_match_prefix\`: value starts with candidate
- \`partial_match_suffix\`: value ends with candidate
- \`partial_match_regex\`: value matches regex pattern
- \`is_number\`: value is numeric
- \`is_boolean\`: value is true/false
- \`is_string\`: value is neither number nor boolean
**Selectors** (provide exactly one):
- \`key\`: single key
- \`keys\`: array of keys
- \`allKeys: true\`: all keys in file
\`\`\`json
{ "filePath": ".env", "key": "NODE_ENV", "op": "is_equal", "candidate": "production" }
{ "filePath": ".env", "keys": ["API_KEY", "SECRET"], "op": "exists" }
{ "filePath": ".env", "allKeys": true, "op": "is_empty" }
\`\`\`
### envsitter_match_by_key
Bulk match different candidates against different keys.
\`\`\`json
{ "filePath": ".env", "candidatesByKey": { "API_KEY": "sk-xxx", "DB_PASS": "secret123" } }
\`\`\`
Returns: \`{ file, matches: [{ key, match: boolean }] }\`
`;
const mutations = `
## Mutation Tools (modify .env files safely)
All mutation tools are DRY-RUN by default. Set \`write: true\` to apply changes.
Output includes operation plan with line numbers, never values.
### envsitter_add
Add a NEW key (fails if key already exists).
\`\`\`json
{ "filePath": ".env", "key": "NEW_KEY", "value": "some-value", "write": true }
\`\`\`
Returns: \`{ file, key, hasChanges, plan: { action: "added"|"key_exists" } }\`
### envsitter_set
Create or update a key (upsert behavior).
\`\`\`json
{ "filePath": ".env", "key": "API_KEY", "value": "new-value", "write": true }
\`\`\`
Returns: \`{ file, key, hasChanges, plan: { action: "added"|"updated"|"no_change" } }\`
### envsitter_unset
Set a key to empty string (keeps the key line).
\`\`\`json
{ "filePath": ".env", "key": "OLD_KEY", "write": true }
\`\`\`
Returns: \`{ file, key, hasChanges, plan: { action: "unset"|"not_found" } }\`
### envsitter_delete
Remove key(s) entirely from the file.
\`\`\`json
{ "filePath": ".env", "keys": ["OLD_KEY", "UNUSED_KEY"], "write": true }
\`\`\`
Returns: \`{ file, keys, hasChanges, plan: [{ key, action: "deleted"|"not_found" }] }\`
`;
const fileOps = `
## File Operation Tools
### envsitter_validate
Check .env file syntax for errors.
\`\`\`json
{ "filePath": ".env" }
\`\`\`
Returns: \`{ file, ok: boolean, issues: [{ line, column, message }] }\`
### envsitter_copy
Copy keys between .env files. Dry-run unless \`write: true\`.
\`\`\`json
{
"from": ".env.production",
"to": ".env.staging",
"keys": ["API_URL", "REDIS_URL"],
"onConflict": "overwrite",
"write": true
}
\`\`\`
Options: \`includeRegex\`, \`excludeRegex\`, \`rename\` (e.g., "OLD=NEW,A=B")
Returns: \`{ from, to, hasChanges, plan: [{ fromKey, toKey, action }] }\`
### envsitter_format / envsitter_reorder
Format/reorder a .env file. Dry-run unless \`write: true\`.
\`\`\`json
{ "filePath": ".env", "mode": "sections", "sort": "alpha", "write": true }
\`\`\`
Modes: \`sections\` (preserve section groupings), \`global\` (treat as flat list)
Sort: \`alpha\` (alphabetical), \`none\` (preserve order within sections)
### envsitter_annotate
Add a comment above a key. Dry-run unless \`write: true\`.
\`\`\`json
{ "filePath": ".env", "key": "DATABASE_URL", "comment": "Production DB only", "write": true }
\`\`\`
Returns: \`{ file, key, hasChanges, plan: { action: "inserted"|"updated"|"not_found" } }\`
`;
const sections: Record<Exclude<HelpTopic, "all">, string> = {
overview,
reading,
matching,
mutations,
file_ops: fileOps,
};
export function helpText(topic: HelpTopic): string {
if (topic === "all") return [overview, reading, matching, mutations, fileOps].join("\n---\n");
return sections[topic];
}
+271
View File
@@ -0,0 +1,271 @@
import { isEnvSitterPepperFile, isSensitiveEnvFile } from "envsitter";
export type BlockedAccess = {
readonly kind: "read" | "edit" | "shell" | "access";
readonly tool: string;
readonly paths: readonly string[];
};
const PATH_ARG_KEYS = ["filePath", "file_path", "filepath", "path", "paths", "filePaths"] as const;
const READ_TOOLS = new Set(["read", "grep", "glob", "list", "view"]);
const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"]);
const PATCH_FILE_HEADER = /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/gm;
const UNIFIED_DIFF_HEADER = /^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)\s*$/gm;
const SHELL_SEGMENT_OPERATORS = new Set([";", "&", "|", "\n", "(", ")", "`"]);
const SHELL_WORD_BREAKS = new Set(["<", ">"]);
const SHELL_SUBSTITUTION = /\$\(([^()]*)\)|`([^`]*)`/g;
const SHELL_PREFIX_COMMANDS = new Set(["sudo", "env", "command", "exec", "time", "nohup", "nice"]);
const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
export function normalizePath(input: string): string {
return input.replace(/\\/g, "/");
}
export function stripAtPrefix(input: string): string {
return input.trim().replace(/^@+/, "");
}
export function isProtectedPath(input: string): boolean {
const candidate = normalizePath(stripAtPrefix(input));
return isSensitiveEnvFile(candidate) || isEnvSitterPepperFile(candidate);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function collectPathArgs(args: Record<string, unknown>): string[] {
const paths: string[] = [];
for (const key of PATH_ARG_KEYS) {
const value = args[key];
if (typeof value === "string") paths.push(value);
if (Array.isArray(value)) paths.push(...value.filter((item): item is string => typeof item === "string"));
}
return paths;
}
export function extractPatchPaths(patchText: string): string[] {
const paths: string[] = [];
for (const match of patchText.matchAll(PATCH_FILE_HEADER)) paths.push(match[1] ?? "");
for (const match of patchText.matchAll(UNIFIED_DIFF_HEADER)) paths.push(match[1] ?? "");
return paths.filter((p) => p.length > 0 && p !== "/dev/null");
}
function splitShellSegments(command: string): string[][] {
const segments: string[][] = [];
let words: string[] = [];
let word = "";
let inWord = false;
let quote: "'" | '"' | undefined;
const endWord = () => {
if (inWord) words.push(word);
word = "";
inWord = false;
};
const endSegment = () => {
endWord();
if (words.length > 0) segments.push(words);
words = [];
};
for (let i = 0; i < command.length; i += 1) {
const char = command[i] ?? "";
if (quote) {
if (char === quote) {
quote = undefined;
} else if (char === "\\" && quote === '"' && i + 1 < command.length) {
word += command[i + 1];
i += 1;
} else {
word += char;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
inWord = true;
} else if (char === "\\" && i + 1 < command.length) {
word += command[i + 1];
inWord = true;
i += 1;
} else if (SHELL_SEGMENT_OPERATORS.has(char)) {
endSegment();
} else if (SHELL_WORD_BREAKS.has(char) || /\s/.test(char)) {
endWord();
} else {
word += char;
inWord = true;
}
}
endSegment();
return segments;
}
function isEnvSitterPackage(word: string | undefined): boolean {
return word === "envsitter" || (word?.startsWith("envsitter@") ?? false);
}
function isEnvSitterInvocation(words: readonly string[]): boolean {
let index = 0;
while (index < words.length) {
const word = words[index] ?? "";
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) && !SHELL_PREFIX_COMMANDS.has(word)) break;
index += 1;
}
const program = (words[index] ?? "").split("/").pop() ?? "";
if (program === "envsitter") return true;
const rest = words.slice(index + 1).filter((word) => !word.startsWith("-"));
if (PACKAGE_RUNNERS.has(program)) return isEnvSitterPackage(rest[0]);
if (program === "pnpm" || program === "yarn") return rest[0] === "dlx" && isEnvSitterPackage(rest[1]);
return false;
}
function isProtectedGlob(word: string): boolean {
return /[*?[]/.test(word) && word.toLowerCase().includes(".env");
}
/**
* Best-effort scan for `.env*` / pepper paths named in a shell command. It is a guardrail against
* accidental reads (`cat .env`, `source .env`, `echo x >> .env`), not a sandbox: variables,
* eval, and generic commands such as `grep -r` without a path can still reach file contents.
* Segments that invoke the envsitter CLI are allowed because it never prints values.
*/
export function findProtectedPathsInCommand(command: string): string[] {
const found = new Set<string>();
const sources = [command, ...[...command.matchAll(SHELL_SUBSTITUTION)].map((m) => m[1] ?? m[2] ?? "")];
for (const source of sources) {
for (const words of splitShellSegments(source)) {
if (isEnvSitterInvocation(words)) continue;
for (const word of words) {
for (const candidate of [word, ...word.split("=").slice(1)]) {
if (candidate && (isProtectedPath(candidate) || isProtectedGlob(candidate))) found.add(candidate);
}
}
}
}
return [...found];
}
function kindForTool(tool: string): BlockedAccess["kind"] {
if (READ_TOOLS.has(tool)) return "read";
if (EDIT_TOOLS.has(tool)) return "edit";
return "access";
}
export function findBlockedAccess(tool: string, args: unknown): BlockedAccess | undefined {
if (tool.startsWith("envsitter_")) return undefined;
const record = asRecord(args);
if (!record) return undefined;
const paths = collectPathArgs(record);
if (typeof record.patchText === "string") paths.push(...extractPatchPaths(record.patchText));
const protectedPaths = paths.filter(isProtectedPath);
if (protectedPaths.length > 0) return { kind: kindForTool(tool), tool, paths: protectedPaths };
if (typeof record.command === "string") {
const shellPaths = findProtectedPathsInCommand(record.command);
if (shellPaths.length > 0) return { kind: "shell", tool, paths: shellPaths };
}
return undefined;
}
export function blockedAccessMessage(blocked: BlockedAccess): string {
const targets = blocked.paths.map((p) => `\`${p}\``).join(", ");
const help = "Call envsitter_help for a usage guide.";
if (blocked.kind === "read") {
return `Reading \`.env*\` is blocked to prevent secret leaks (${targets}). Use EnvSitter tools instead (never prints values). ${help}`;
}
if (blocked.kind === "edit") {
return `Editing \`.env*\` and \`.envsitter/pepper\` via standard tools is blocked (${targets}). Use EnvSitter mutation tools: envsitter_add, envsitter_set, envsitter_unset, envsitter_delete. ${help}`;
}
if (blocked.kind === "shell") {
return `Shell commands that reference \`.env*\` files or \`.envsitter/pepper\` are blocked (${targets}). Use EnvSitter tools or the envsitter CLI, which never print values. ${help}`;
}
return `Access to \`.env*\` files and \`.envsitter/pepper\` through \`${blocked.tool}\` is blocked (${targets}). Use EnvSitter tools instead. ${help}`;
}
export function permissionDenial(action: string, resources: readonly string[]): string | undefined {
if (action === "read" || action === "edit") {
const paths = resources.filter(isProtectedPath);
if (paths.length > 0) return blockedAccessMessage({ kind: action, tool: action, paths });
}
if (action === "shell") {
const paths = resources.flatMap(findProtectedPathsInCommand);
if (paths.length > 0) return blockedAccessMessage({ kind: "shell", tool: action, paths });
}
return undefined;
}
function hiddenMatchesNote(hidden: number): string {
return `(${hidden} match${hidden === 1 ? "" : "es"} in protected .env files hidden by envsitter-guard. Use EnvSitter tools to inspect them.)`;
}
// OpenCode grep output (V1 and V2): "Found N matches", then per file a "<path>:" header, " Line N: text" rows, and a blank line.
export function redactGrepOutput(output: string): { output: string; hidden: number } {
const kept: string[] = [];
let hidden = 0;
let skipping = false;
for (const line of output.split("\n")) {
const header = /^(\S.*):$/.exec(line);
if (header && !line.startsWith("Found ")) {
skipping = isProtectedPath(header[1] ?? "");
if (skipping) continue;
} else if (skipping && line.startsWith(" Line ")) {
hidden += 1;
continue;
} else if (skipping && line === "") {
skipping = false;
continue;
}
kept.push(line);
}
if (hidden === 0) return { output, hidden };
const recounted = kept.join("\n").replace(/^Found (\d+) matches/, (_match, total: string) => `Found ${Math.max(0, Number(total) - hidden)} matches`);
return { output: `${recounted.trimEnd()}\n\n${hiddenMatchesNote(hidden)}`, hidden };
}
type GrepMatch = { readonly entry?: { readonly path?: unknown } };
export function redactGrepResult<Result extends { readonly content?: unknown; readonly output?: unknown }>(result: Result): Result {
const content = result.content;
let redactedContent: unknown = content;
if (typeof content === "string") {
redactedContent = redactGrepOutput(content).output;
} else if (Array.isArray(content)) {
redactedContent = content.map((part: unknown) => {
const record = asRecord(part);
if (record?.type !== "text" || typeof record.text !== "string") return part;
return { ...record, text: redactGrepOutput(record.text).output };
});
}
const output = Array.isArray(result.output)
? (result.output as GrepMatch[]).filter((match) => {
const matchPath = match.entry?.path;
return typeof matchPath !== "string" || !isProtectedPath(matchPath);
})
: result.output;
return { ...result, content: redactedContent, output };
}
+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 };
}
+508
View File
@@ -0,0 +1,508 @@
import path from "node:path";
import { tool } from "@opencode-ai/plugin/tool";
import {
EnvSitter,
addEnvFileKey,
annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
isEnvFile,
isEnvSitterPepperFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile,
type EnvSitterMatcher,
} from "envsitter";
import type { z } from "zod";
import { HELP_TOPICS, helpText } from "./help.js";
import { normalizePath, stripAtPrefix } from "./policy.js";
const schema = tool.schema;
const MATCH_OPS = [
"exists",
"is_empty",
"is_equal",
"partial_match_prefix",
"partial_match_suffix",
"partial_match_regex",
"is_number",
"is_boolean",
"is_string",
] as const;
const SCAN_DETECTIONS = ["jwt", "url", "base64"] as const;
export type ToolEnvironment = {
readonly directory: string;
readonly root: string;
};
export type GuardTool = {
readonly name: string;
readonly description: string;
readonly args: z.ZodRawShape;
readonly inputSchema: Record<string, unknown>;
readonly run: (input: unknown, env: ToolEnvironment) => Promise<string>;
};
function defineTool<Shape extends z.ZodRawShape>(definition: {
name: string;
description: string;
args: Shape;
run: (args: z.infer<z.ZodObject<Shape>>, env: ToolEnvironment) => Promise<unknown>;
}): GuardTool {
const parser = schema.object(definition.args);
const { $schema: _dialect, ...inputSchema } = schema.toJSONSchema(parser);
return {
name: definition.name,
description: definition.description,
args: definition.args,
inputSchema,
async run(input, env) {
const output = await definition.run(parser.parse(input ?? {}), env);
return typeof output === "string" ? output : JSON.stringify(output, null, 2);
},
};
}
function resolveEnvFilePath(env: ToolEnvironment, filePath: string | undefined): { absolutePath: string; displayPath: string } {
const normalized = normalizePath(stripAtPrefix(filePath ?? ".env"));
if (isEnvSitterPepperFile(normalized)) {
throw new Error("Access to `.envsitter/pepper` is blocked.");
}
if (!isEnvFile(normalized)) {
throw new Error("Only dotenv files are allowed (e.g. `.env`, `.env.local`, `api.env`, `.env.example`).");
}
const absolutePath = path.resolve(env.directory, normalized);
const relativeToRoot = path.relative(env.root, absolutePath);
if (relativeToRoot.split(path.sep)[0] === ".." || path.isAbsolute(relativeToRoot)) {
throw new Error("EnvSitter tools only operate on files inside the current project.");
}
return { absolutePath, displayPath: relativeToRoot };
}
function parseUserRegExp(input: string): RegExp {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) return new RegExp(trimmed);
let lastSlashIndex = -1;
for (let i = trimmed.length - 1; i >= 1; i -= 1) {
if (trimmed[i] !== "/") continue;
let backslashCount = 0;
for (let j = i - 1; j >= 0 && trimmed[j] === "\\"; j -= 1) {
backslashCount += 1;
}
if (backslashCount % 2 === 0) {
lastSlashIndex = i;
break;
}
}
if (lastSlashIndex === -1) {
throw new Error("Invalid regex literal; expected a closing `/`.");
}
const body = trimmed.slice(1, lastSlashIndex);
const flags = trimmed.slice(lastSlashIndex + 1);
if (!/^[a-z]*$/.test(flags)) {
throw new Error("Invalid regex literal flags; expected only letters (e.g. `/abc/i`).");
}
return new RegExp(body, flags);
}
function resolveCandidate(params: { candidate?: string | undefined; candidateEnvVar?: string | undefined }): string {
if (typeof params.candidate === "string" && params.candidate.length > 0) return params.candidate;
if (typeof params.candidateEnvVar === "string" && params.candidateEnvVar.length > 0) {
const value = process.env[params.candidateEnvVar];
if (typeof value === "string" && value.length > 0) return value;
throw new Error(`Env var \`${params.candidateEnvVar}\` was not set.`);
}
throw new Error("Candidate is required for this operation. Provide `candidate` or `candidateEnvVar`.");
}
function buildMatcher(op: (typeof MATCH_OPS)[number], candidateSource: { candidate?: string | undefined; candidateEnvVar?: string | undefined }): EnvSitterMatcher {
if (op === "exists" || op === "is_empty" || op === "is_number" || op === "is_boolean" || op === "is_string") {
return { op };
}
const candidate = resolveCandidate(candidateSource);
if (op === "is_equal") return { op, candidate };
if (op === "partial_match_prefix") return { op, prefix: candidate };
if (op === "partial_match_suffix") return { op, suffix: candidate };
return { op, regex: parseUserRegExp(candidate) };
}
function parseCandidatesJson(json: string): Record<string, string> {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
}
const candidates: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== "string") {
throw new Error("Invalid candidates JSON; expected every value to be a string.");
}
candidates[key] = value;
}
return candidates;
}
const formatArgs = {
filePath: schema.string().optional(),
mode: schema.enum(["sections", "global"] as const).optional(),
sort: schema.enum(["alpha", "none"] as const).optional(),
write: schema.boolean().optional(),
};
async function runFormat(args: z.infer<z.ZodObject<typeof formatArgs>>, env: ToolEnvironment) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await formatEnvFile({
file: resolved.absolutePath,
mode: args.mode,
sort: args.sort,
write: args.write === true,
});
return {
file: resolved.displayPath,
mode: result.mode,
sort: result.sort,
willWrite: result.willWrite,
wrote: result.wrote,
hasChanges: result.hasChanges,
issues: result.issues,
};
}
export const guardTools: readonly GuardTool[] = [
defineTool({
name: "envsitter_keys",
description: "List keys in a .env file (never returns values).",
args: {
filePath: schema.string().optional(),
filterRegex: schema.string().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
const keys = await es.listKeys(args.filterRegex ? { filter: parseUserRegExp(args.filterRegex) } : {});
return { file: resolved.displayPath, keys };
},
}),
defineTool({
name: "envsitter_fingerprint",
description: "Compute a deterministic fingerprint for a single key (never returns the value).",
args: {
filePath: schema.string().optional(),
key: schema.string(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
const result = await es.fingerprintKey(args.key);
return { file: resolved.displayPath, key: args.key, result };
},
}),
defineTool({
name: "envsitter_match",
description:
"Match key values without printing them. Supports existence/shape checks and outside-in candidate matching.",
args: {
filePath: schema.string().optional(),
op: schema.enum(MATCH_OPS).optional(),
key: schema.string().optional(),
keys: schema.array(schema.string()).optional(),
allKeys: schema.boolean().optional(),
candidate: schema.string().optional(),
candidateEnvVar: schema.string().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const matcher = buildMatcher(args.op ?? "is_equal", args);
const hasKey = typeof args.key === "string";
const hasKeys = Array.isArray(args.keys) && args.keys.length > 0;
const allKeys = args.allKeys === true;
if (Number(hasKey) + Number(hasKeys) + Number(allKeys) !== 1) {
throw new Error("Provide exactly one of: `key`, `keys`, or `allKeys: true`.");
}
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
if (typeof args.key === "string") {
const match = await es.matchKey(args.key, matcher);
return { file: resolved.displayPath, key: args.key, op: matcher.op, match };
}
const matches = hasKeys && args.keys ? await es.matchKeyBulk(args.keys, matcher) : await es.matchKeyAll(matcher);
return { file: resolved.displayPath, op: matcher.op, matches };
},
}),
defineTool({
name: "envsitter_match_by_key",
description: "Bulk match candidates-by-key without printing values (returns booleans only).",
args: {
filePath: schema.string().optional(),
candidatesByKey: schema.record(schema.string(), schema.string()).optional(),
candidatesByKeyJson: schema.string().optional(),
candidatesByKeyEnvVar: schema.string().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const selectorCount =
Number(!!args.candidatesByKey) + Number(!!args.candidatesByKeyJson) + Number(!!args.candidatesByKeyEnvVar);
if (selectorCount !== 1) {
throw new Error(
"Provide exactly one of: `candidatesByKey`, `candidatesByKeyJson`, or `candidatesByKeyEnvVar`.",
);
}
let candidatesByKey: Record<string, string>;
if (args.candidatesByKey) {
candidatesByKey = args.candidatesByKey;
} else if (args.candidatesByKeyJson) {
candidatesByKey = parseCandidatesJson(args.candidatesByKeyJson);
} else {
const envVarName = args.candidatesByKeyEnvVar ?? "";
const value = process.env[envVarName];
if (typeof value !== "string") throw new Error(`Env var \`${envVarName}\` was not set.`);
candidatesByKey = parseCandidatesJson(value);
}
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
const matches = await es.matchCandidatesByKey(candidatesByKey);
return { file: resolved.displayPath, matches };
},
}),
defineTool({
name: "envsitter_scan",
description: "Scan value shapes (jwt/url/base64) without printing values.",
args: {
filePath: schema.string().optional(),
detect: schema.array(schema.enum(SCAN_DETECTIONS)).optional(),
keysFilterRegex: schema.string().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
const findings = await es.scan({
...(args.detect ? { detect: args.detect } : {}),
...(args.keysFilterRegex ? { keysFilter: parseUserRegExp(args.keysFilterRegex) } : {}),
});
return { file: resolved.displayPath, findings };
},
}),
defineTool({
name: "envsitter_validate",
description: "Validate dotenv syntax (never returns values).",
args: {
filePath: schema.string().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await validateEnvFile(resolved.absolutePath);
return { file: resolved.displayPath, ok: result.ok, issues: result.issues };
},
}),
defineTool({
name: "envsitter_copy",
description: "Copy keys between dotenv files safely (no values in output). Dry-run unless `write: true`.",
args: {
from: schema.string(),
to: schema.string(),
keys: schema.array(schema.string()).optional(),
includeRegex: schema.string().optional(),
excludeRegex: schema.string().optional(),
rename: schema.string().optional(),
onConflict: schema.enum(["error", "skip", "overwrite"] as const).optional(),
write: schema.boolean().optional(),
},
async run(args, env) {
const from = resolveEnvFilePath(env, args.from);
const to = resolveEnvFilePath(env, args.to);
if (typeof args.rename === "string" && args.rename.trim().length === 0) {
throw new Error("Invalid `rename`; expected a non-empty string like `A=B,C=D`.");
}
const result = await copyEnvFileKeys({
from: from.absolutePath,
to: to.absolutePath,
...(args.keys && args.keys.length > 0 ? { keys: args.keys } : {}),
...(args.includeRegex ? { include: parseUserRegExp(args.includeRegex) } : {}),
...(args.excludeRegex ? { exclude: parseUserRegExp(args.excludeRegex) } : {}),
...(args.rename ? { rename: args.rename } : {}),
...(args.onConflict ? { onConflict: args.onConflict } : {}),
write: args.write === true,
});
return {
from: from.displayPath,
to: to.displayPath,
onConflict: result.onConflict,
willWrite: result.willWrite,
wrote: result.wrote,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan,
};
},
}),
defineTool({
name: "envsitter_format",
description: "Format/reorder a dotenv file (no values in output). Dry-run unless `write: true`.",
args: formatArgs,
run: runFormat,
}),
defineTool({
name: "envsitter_reorder",
description: "Alias for envsitter_format.",
args: formatArgs,
run: runFormat,
}),
defineTool({
name: "envsitter_annotate",
description: "Annotate a dotenv key with a comment (no values in output). Dry-run unless `write: true`.",
args: {
filePath: schema.string().optional(),
key: schema.string(),
comment: schema.string(),
line: schema.number().int().optional(),
write: schema.boolean().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
if (args.comment.trim().length === 0) {
throw new Error("Comment must be a non-empty string.");
}
const result = await annotateEnvFile({
file: resolved.absolutePath,
key: args.key,
comment: args.comment,
...(args.line !== undefined ? { line: args.line } : {}),
write: args.write === true,
});
return {
file: resolved.displayPath,
key: result.key,
willWrite: result.willWrite,
wrote: result.wrote,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan,
};
},
}),
defineTool({
name: "envsitter_add",
description: "Add a new key to a dotenv file (fails if key already exists). Dry-run unless `write: true`.",
args: {
filePath: schema.string().optional(),
key: schema.string(),
value: schema.string(),
write: schema.boolean().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await addEnvFileKey({
file: resolved.absolutePath,
key: args.key,
value: args.value,
write: args.write === true,
});
return { ...result, file: resolved.displayPath };
},
}),
defineTool({
name: "envsitter_set",
description:
"Set a key's value in a dotenv file (creates if missing, updates if exists). Dry-run unless `write: true`.",
args: {
filePath: schema.string().optional(),
key: schema.string(),
value: schema.string(),
write: schema.boolean().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await setEnvFileKey({
file: resolved.absolutePath,
key: args.key,
value: args.value,
write: args.write === true,
});
return { ...result, file: resolved.displayPath };
},
}),
defineTool({
name: "envsitter_unset",
description:
"Unset a key's value in a dotenv file (sets to empty string, keeps the key). Dry-run unless `write: true`.",
args: {
filePath: schema.string().optional(),
key: schema.string(),
write: schema.boolean().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await unsetEnvFileKey({
file: resolved.absolutePath,
key: args.key,
write: args.write === true,
});
return { ...result, file: resolved.displayPath };
},
}),
defineTool({
name: "envsitter_delete",
description: "Delete key(s) from a dotenv file entirely (removes the line). Dry-run unless `write: true`.",
args: {
filePath: schema.string().optional(),
keys: schema.array(schema.string()),
write: schema.boolean().optional(),
},
async run(args, env) {
const resolved = resolveEnvFilePath(env, args.filePath);
const result = await deleteEnvFileKeys({
file: resolved.absolutePath,
keys: args.keys,
write: args.write === true,
});
return { ...result, file: resolved.displayPath };
},
}),
defineTool({
name: "envsitter_help",
description:
"Get comprehensive help on all EnvSitter tools. Call this to understand how to safely work with .env files without exposing secrets.",
args: {
topic: schema.enum(HELP_TOPICS).optional(),
},
async run(args) {
return helpText(args.topic ?? "all");
},
}),
];
+33
View File
@@ -0,0 +1,33 @@
import type { Hooks, Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin/tool";
import { blockedAccessMessage, findBlockedAccess, redactGrepOutput } from "./policy.js";
import { createScrubLoader } from "./scrub.js";
import { guardTools, type ToolEnvironment } from "./tools.js";
export const serverV1: Plugin = async ({ directory, worktree }) => {
const env: ToolEnvironment = { directory, root: worktree };
const loadScrub = createScrubLoader(worktree);
const tools: NonNullable<Hooks["tool"]> = {};
for (const guardTool of guardTools) {
tools[guardTool.name] = tool({
description: guardTool.description,
args: guardTool.args,
execute: (args) => guardTool.run(args, env),
});
}
return {
tool: tools,
"tool.execute.before": async (input, output) => {
const blocked = findBlockedAccess(input.tool, output.args);
if (blocked) throw new Error(blockedAccessMessage(blocked));
},
"tool.execute.after": async (input, output) => {
if (input.tool.startsWith("envsitter_") || typeof output.output !== "string") return;
const text = input.tool === "grep" ? redactGrepOutput(output.output).output : output.output;
output.output = (await loadScrub())(text);
},
};
};
+44
View File
@@ -0,0 +1,44 @@
import type { Plugin } from "@opencode/plugin";
import type { JsonSchema } from "effect";
import { blockedAccessMessage, findBlockedAccess, permissionDenial, redactGrepResult } from "./policy.js";
import { createScrubLoader, scrubToolResult } from "./scrub.js";
import { guardTools, type ToolEnvironment } from "./tools.js";
export async function setupV2(ctx: Plugin.Context): Promise<void> {
const env: ToolEnvironment = {
directory: ctx.location.directory,
root: ctx.location.project.directory,
};
const loadScrub = createScrubLoader(env.root);
await ctx.tool.transform((editor) => {
for (const guardTool of guardTools) {
editor.add({
name: guardTool.name,
description: guardTool.description,
input: guardTool.inputSchema as JsonSchema.JsonSchema,
options: { codemode: false },
execute: async (input) => ({ content: await guardTool.run(input, env) }),
});
}
});
await ctx.tool.hook("execute.before", (event) => {
const blocked = findBlockedAccess(event.tool, event.input);
if (blocked) throw new Error(blockedAccessMessage(blocked));
});
await ctx.tool.hook("execute.after", async (event) => {
if (event.status !== "completed" || event.tool.startsWith("envsitter_")) return;
const result = event.tool === "grep" ? redactGrepResult(event.result) : event.result;
event.result = scrubToolResult(result, await loadScrub());
});
await ctx.permission.hook("evaluate", (event) => {
const message = permissionDenial(event.action, event.resources);
if (!message) return;
event.effect = "deny";
event.message = message;
});
}