0595794385
- 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).
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
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");
|
|
},
|
|
}),
|
|
];
|