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