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:
@@ -1,4 +1,4 @@
|
|||||||
import EnvSitterGuard from "../../index";
|
import EnvSitterGuard from "../../index.js";
|
||||||
|
|
||||||
export default EnvSitterGuard;
|
export default EnvSitterGuard;
|
||||||
export { EnvSitterGuard } from "../../index";
|
export { EnvSitterGuard } from "../../index.js";
|
||||||
|
|||||||
@@ -1,916 +1,15 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin as V1Plugin } from "@opencode-ai/plugin";
|
||||||
import { tool } from "@opencode-ai/plugin/tool";
|
import type { Plugin as V2 } from "@opencode/plugin";
|
||||||
import {
|
|
||||||
EnvSitter,
|
import { serverV1 } from "./lib/v1.js";
|
||||||
addEnvFileKey,
|
import { setupV2 } from "./lib/v2.js";
|
||||||
annotateEnvFile,
|
|
||||||
copyEnvFileKeys,
|
// One default export serves both OpenCode majors: V2 reads `id` + `setup`, V1 (>= 1.18.29) calls `server`.
|
||||||
deleteEnvFileKeys,
|
// Keep every export of this module plugin-shaped: V1 rejects modules that export anything else.
|
||||||
formatEnvFile,
|
export const EnvSitterGuard: V2.Plugin & { readonly server: V1Plugin } = {
|
||||||
setEnvFileKey,
|
id: "envsitter-guard",
|
||||||
unsetEnvFileKey,
|
setup: setupV2,
|
||||||
validateEnvFile,
|
server: serverV1,
|
||||||
} from "envsitter";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
function normalizePath(input: string): string {
|
|
||||||
return input.replace(/\\/g, "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDotEnvExamplePath(input: string): boolean {
|
|
||||||
return /(^|\/)\.env\.example$/.test(normalizePath(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSensitiveDotEnvPath(input: string): boolean {
|
|
||||||
const normalized = normalizePath(input);
|
|
||||||
if (isDotEnvExamplePath(normalized)) return false;
|
|
||||||
return /(^|\/)\.env($|\.)/.test(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDotEnvishPath(input: string): boolean {
|
|
||||||
const normalized = normalizePath(input);
|
|
||||||
return isDotEnvExamplePath(normalized) || isSensitiveDotEnvPath(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnvSitterPepperPath(input: string): boolean {
|
|
||||||
const normalized = normalizePath(input);
|
|
||||||
return /(^|\/)\.envsitter\/pepper$/.test(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripAtPrefix(input: string): string {
|
|
||||||
return input.trim().replace(/^@+/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEscaped = backslashCount % 2 === 1;
|
|
||||||
if (!isEscaped) {
|
|
||||||
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; candidateEnvVar?: string }): 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 getFilePathFromArgs(args: unknown): string | undefined {
|
|
||||||
if (!args || typeof args !== "object") return;
|
|
||||||
const record = args as Record<string, unknown>;
|
|
||||||
|
|
||||||
const candidates: Array<unknown> = [record.filePath, record.path, record.file_path];
|
|
||||||
|
|
||||||
const found = candidates.find((value) => typeof value === "string") as string | undefined;
|
|
||||||
return found ? stripAtPrefix(found) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDotEnvPath(params: {
|
|
||||||
worktree: string;
|
|
||||||
directory: string;
|
|
||||||
filePath: string;
|
|
||||||
}): { absolutePath: string; displayPath: string } {
|
|
||||||
const normalized = normalizePath(params.filePath);
|
|
||||||
|
|
||||||
if (isEnvSitterPepperPath(normalized)) {
|
|
||||||
throw new Error("Access to `.envsitter/pepper` is blocked.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDotEnvishPath(normalized)) {
|
|
||||||
throw new Error("Only `.env`-style paths are allowed (e.g. `.env`, `.env.local`, `.env.example`).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolutePath = path.resolve(params.directory, normalized);
|
|
||||||
const relativeToWorktree = path.relative(params.worktree, absolutePath);
|
|
||||||
if (relativeToWorktree.startsWith("..") || path.isAbsolute(relativeToWorktree)) {
|
|
||||||
throw new Error("EnvSitter tools only operate on files inside the current project.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { absolutePath, displayPath: relativeToWorktree };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvSitterGuard: Plugin = async ({ directory, worktree }) => {
|
|
||||||
const matchOps = [
|
|
||||||
"exists",
|
|
||||||
"is_empty",
|
|
||||||
"is_equal",
|
|
||||||
"partial_match_prefix",
|
|
||||||
"partial_match_suffix",
|
|
||||||
"partial_match_regex",
|
|
||||||
"is_number",
|
|
||||||
"is_boolean",
|
|
||||||
"is_string",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const scanDetections = ["jwt", "url", "base64"] as const;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tool: {
|
|
||||||
envsitter_keys: tool({
|
|
||||||
description: "List keys in a .env file (never returns values).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
filterRegex: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const keys = await es.listKeys(
|
|
||||||
args.filterRegex
|
|
||||||
? {
|
|
||||||
filter: parseUserRegExp(args.filterRegex),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, keys }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_fingerprint: tool({
|
|
||||||
description: "Compute a deterministic fingerprint for a single key (never returns the value).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const result = await es.fingerprintKey(args.key);
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, key: args.key, result }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_match: tool({
|
|
||||||
description:
|
|
||||||
"Match key values without printing them. Supports existence/shape checks and outside-in candidate matching.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
op: tool.schema.enum(matchOps).optional(),
|
|
||||||
key: tool.schema.string().optional(),
|
|
||||||
keys: tool.schema.array(tool.schema.string()).optional(),
|
|
||||||
allKeys: tool.schema.boolean().optional(),
|
|
||||||
candidate: tool.schema.string().optional(),
|
|
||||||
candidateEnvVar: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const op = args.op ?? "is_equal";
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
|
|
||||||
let isEqualCandidate: string | undefined;
|
|
||||||
|
|
||||||
const matcher = (() => {
|
|
||||||
if (op === "exists") return { op } as const;
|
|
||||||
if (op === "is_empty") return { op } as const;
|
|
||||||
if (op === "is_number") return { op } as const;
|
|
||||||
if (op === "is_boolean") return { op } as const;
|
|
||||||
if (op === "is_string") return { op } as const;
|
|
||||||
|
|
||||||
const candidate = resolveCandidate({
|
|
||||||
candidate: args.candidate,
|
|
||||||
candidateEnvVar: args.candidateEnvVar,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (op === "is_equal") {
|
|
||||||
isEqualCandidate = candidate;
|
|
||||||
return { op, candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_prefix") {
|
|
||||||
return { op, prefix: candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_suffix") {
|
|
||||||
return { op, suffix: candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_regex") {
|
|
||||||
return { op, regex: parseUserRegExp(candidate) } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported op: ${op}`);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const key = args.key;
|
|
||||||
const keys = args.keys;
|
|
||||||
const allKeys = args.allKeys === true;
|
|
||||||
|
|
||||||
const selectorCount =
|
|
||||||
Number(typeof key === "string") + Number(Array.isArray(keys) && keys.length > 0) + Number(allKeys);
|
|
||||||
if (selectorCount !== 1) {
|
|
||||||
throw new Error("Provide exactly one of: `key`, `keys`, or `allKeys: true`. ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof key === "string") {
|
|
||||||
const match =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidate(key, isEqualCandidate)
|
|
||||||
: await es.matchKey(key, matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, key, op: matcher.op, match }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(keys) && keys.length > 0) {
|
|
||||||
const matches =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidateBulk(keys, isEqualCandidate)
|
|
||||||
: await es.matchKeyBulk(keys, matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, op: matcher.op, matches }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidateAll(isEqualCandidate)
|
|
||||||
: await es.matchKeyAll(matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, op: matcher.op, matches }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_match_by_key: tool({
|
|
||||||
description: "Bulk match candidates-by-key without printing values (returns booleans only).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
candidatesByKey: tool.schema.record(tool.schema.string(), tool.schema.string()).optional(),
|
|
||||||
candidatesByKeyJson: tool.schema.string().optional(),
|
|
||||||
candidatesByKeyEnvVar: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromRecord = args.candidatesByKey;
|
|
||||||
const fromJson = args.candidatesByKeyJson;
|
|
||||||
const fromEnvVar = args.candidatesByKeyEnvVar;
|
|
||||||
|
|
||||||
const selectorCount = Number(!!fromRecord) + Number(!!fromJson) + Number(!!fromEnvVar);
|
|
||||||
if (selectorCount !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"Provide exactly one of: `candidatesByKey`, `candidatesByKeyJson`, or `candidatesByKeyEnvVar`.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidatesByKey: Record<string, string>;
|
|
||||||
|
|
||||||
if (fromRecord) {
|
|
||||||
candidatesByKey = fromRecord;
|
|
||||||
} else {
|
|
||||||
const json =
|
|
||||||
typeof fromJson === "string"
|
|
||||||
? fromJson
|
|
||||||
: (() => {
|
|
||||||
const envVarName = fromEnvVar as string;
|
|
||||||
const value = process.env[envVarName];
|
|
||||||
if (typeof value !== "string") throw new Error(`Env var \`${envVarName}\` was not set.`);
|
|
||||||
return value;
|
|
||||||
})();
|
|
||||||
|
|
||||||
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 record = parsed as Record<string, unknown>;
|
|
||||||
const normalized: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new Error("Invalid candidates JSON; expected every value to be a string.");
|
|
||||||
}
|
|
||||||
normalized[key] = value;
|
|
||||||
}
|
|
||||||
candidatesByKey = normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const matches = await es.matchCandidatesByKey(candidatesByKey);
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, matches }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_scan: tool({
|
|
||||||
description: "Scan value shapes (jwt/url/base64) without printing values.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
detect: tool.schema.array(tool.schema.enum(scanDetections)).optional(),
|
|
||||||
keysFilterRegex: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const findings = await es.scan({
|
|
||||||
detect: args.detect,
|
|
||||||
keysFilter: args.keysFilterRegex ? parseUserRegExp(args.keysFilterRegex) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, findings }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_validate: tool({
|
|
||||||
description: "Validate dotenv syntax (never returns values).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await validateEnvFile(resolved.absolutePath);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, ok: result.ok, issues: result.issues }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_copy: tool({
|
|
||||||
description:
|
|
||||||
"Copy keys between dotenv files safely (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
from: tool.schema.string(),
|
|
||||||
to: tool.schema.string(),
|
|
||||||
keys: tool.schema.array(tool.schema.string()).optional(),
|
|
||||||
includeRegex: tool.schema.string().optional(),
|
|
||||||
excludeRegex: tool.schema.string().optional(),
|
|
||||||
rename: tool.schema.string().optional(),
|
|
||||||
onConflict: tool.schema.enum(["error", "skip", "overwrite"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolvedFrom = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.from,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedTo = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: 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: resolvedFrom.absolutePath,
|
|
||||||
to: resolvedTo.absolutePath,
|
|
||||||
keys: Array.isArray(args.keys) && args.keys.length > 0 ? args.keys : undefined,
|
|
||||||
include: args.includeRegex ? parseUserRegExp(args.includeRegex) : undefined,
|
|
||||||
exclude: args.excludeRegex ? parseUserRegExp(args.excludeRegex) : undefined,
|
|
||||||
rename: args.rename,
|
|
||||||
onConflict: args.onConflict,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
from: resolvedFrom.displayPath,
|
|
||||||
to: resolvedTo.displayPath,
|
|
||||||
onConflict: result.onConflict,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_format: tool({
|
|
||||||
description: "Format/reorder a dotenv file (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
mode: tool.schema.enum(["sections", "global"] as const).optional(),
|
|
||||||
sort: tool.schema.enum(["alpha", "none"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await formatEnvFile({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
mode: args.mode,
|
|
||||||
sort: args.sort,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
mode: result.mode,
|
|
||||||
sort: result.sort,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_reorder: tool({
|
|
||||||
description: "Alias for envsitter_format.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
mode: tool.schema.enum(["sections", "global"] as const).optional(),
|
|
||||||
sort: tool.schema.enum(["alpha", "none"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await formatEnvFile({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
mode: args.mode,
|
|
||||||
sort: args.sort,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
mode: result.mode,
|
|
||||||
sort: result.sort,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_annotate: tool({
|
|
||||||
description: "Annotate a dotenv key with a comment (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
comment: tool.schema.string(),
|
|
||||||
line: tool.schema.number().int().optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
line: args.line,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
key: result.key,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_add: tool({
|
|
||||||
description:
|
|
||||||
"Add a new key to a dotenv file (fails if key already exists). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
value: tool.schema.string(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await addEnvFileKey({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
key: args.key,
|
|
||||||
value: args.value,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
key: result.key,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_set: tool({
|
|
||||||
description:
|
|
||||||
"Set a key's value in a dotenv file (creates if missing, updates if exists). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
value: tool.schema.string(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setEnvFileKey({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
key: args.key,
|
|
||||||
value: args.value,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
key: result.key,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_unset: tool({
|
|
||||||
description:
|
|
||||||
"Unset a key's value in a dotenv file (sets to empty string, keeps the key). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await unsetEnvFileKey({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
key: args.key,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
key: result.key,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_delete: tool({
|
|
||||||
description:
|
|
||||||
"Delete key(s) from a dotenv file entirely (removes the line). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
keys: tool.schema.array(tool.schema.string()),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteEnvFileKeys({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
keys: args.keys,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
keys: result.keys,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_help: tool({
|
|
||||||
description:
|
|
||||||
"Get comprehensive help on all EnvSitter tools. Call this to understand how to safely work with .env files without exposing secrets.",
|
|
||||||
args: {
|
|
||||||
topic: tool.schema
|
|
||||||
.enum([
|
|
||||||
"overview",
|
|
||||||
"reading",
|
|
||||||
"matching",
|
|
||||||
"mutations",
|
|
||||||
"file_ops",
|
|
||||||
"all",
|
|
||||||
] as const)
|
|
||||||
.optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const topic = args.topic ?? "all";
|
|
||||||
|
|
||||||
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 reading of .env files is BLOCKED to prevent secret leaks
|
|
||||||
- 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<string, string> = {
|
|
||||||
overview,
|
|
||||||
reading,
|
|
||||||
matching,
|
|
||||||
mutations,
|
|
||||||
file_ops: fileOps,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (topic === "all") {
|
|
||||||
return [overview, reading, matching, mutations, fileOps].join("\n---\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections[topic] ?? overview;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"tool.execute.before": async (input, output) => {
|
|
||||||
const filePath = getFilePathFromArgs(output.args);
|
|
||||||
if (!filePath) return;
|
|
||||||
|
|
||||||
if (!isSensitiveDotEnvPath(filePath) && !isEnvSitterPepperPath(filePath)) return;
|
|
||||||
|
|
||||||
if (input.tool === "read") {
|
|
||||||
throw new Error(
|
|
||||||
"Reading `.env*` is blocked to prevent secret leaks. " +
|
|
||||||
"Use EnvSitter tools instead (never prints values). " +
|
|
||||||
"Call envsitter_help for comprehensive usage guide."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.tool === "edit" || input.tool === "write" || input.tool === "patch" || input.tool === "multiedit") {
|
|
||||||
throw new Error(
|
|
||||||
"Editing `.env*` and `.envsitter/pepper` via standard tools is blocked. " +
|
|
||||||
"Use EnvSitter mutation tools: envsitter_add, envsitter_set, envsitter_unset, envsitter_delete. " +
|
|
||||||
"Call envsitter_help for comprehensive usage guide."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EnvSitterGuard;
|
export default EnvSitterGuard;
|
||||||
|
|||||||
+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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+4161
-24
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter-guard",
|
"name": "envsitter-guard",
|
||||||
"version": "0.0.4",
|
"version": "0.1.0",
|
||||||
"description": "OpenCode plugin that prevents agents/tools from reading or editing sensitive .env* files, while still allowing safe inspection via EnvSitter.",
|
"description": "OpenCode plugin (OpenCode 1 and 2) that prevents agents/tools from reading or editing sensitive .env files, while still allowing safe inspection via EnvSitter.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"homepage": "https://boxpositron.github.io/envsitter/guard/",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
"opencode-plugin",
|
"opencode-plugin",
|
||||||
@@ -40,17 +41,27 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"build:test": "tsc -p tsconfig.test.json",
|
"build:test": "tsc -p tsconfig.test.json",
|
||||||
"test": "npm run build:test && node --test dist-test/test/*.test.js",
|
"test": "npm run build:test && node --test dist-test/test/*.test.js",
|
||||||
|
"test:e2e": "npm run build && node scripts/e2e-opencode.mjs",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.1.14",
|
"@opencode-ai/plugin": "^1.19.3",
|
||||||
"envsitter": "^0.0.4"
|
"envsitter": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opencode/plugin": ">=2.0.32"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opencode/plugin": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "*",
|
"@opencode/plugin": "^2.1.0",
|
||||||
|
"@types/node": "^22.20.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"typescript": "*"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Cross-version end-to-end check: loads the built plugin (dist/) into the real OpenCode 1 and OpenCode 2 CLIs,
|
||||||
|
// drives them with a scripted OpenAI-compatible model, and asserts what the model receives back from each tool.
|
||||||
|
// Everything runs in a temporary HOME with fake secrets, so real config, credentials, and databases are never touched.
|
||||||
|
//
|
||||||
|
// Usage: npm run build && node scripts/e2e-opencode.mjs
|
||||||
|
// Env: OPENCODE_V1_BIN (default "opencode"), OPENCODE_V2_BIN (default "opencode2"), KEEP_E2E=1 to keep the temp dir,
|
||||||
|
// E2E_REQUIRE_ALL=1 to fail (instead of skip) when a CLI is missing, as CI does.
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pluginEntry = path.join(repoRoot, "dist", "index.js");
|
||||||
|
const FAKE_SECRET = "sk-fake-guard-e2e-123";
|
||||||
|
const RUN_TIMEOUT_MS = 240_000;
|
||||||
|
|
||||||
|
if (!fs.existsSync(pluginEntry)) {
|
||||||
|
console.error("dist/index.js is missing. Run `npm run build` first.");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = [
|
||||||
|
{
|
||||||
|
name: "OpenCode 1",
|
||||||
|
major: 1,
|
||||||
|
bin: process.env.OPENCODE_V1_BIN ?? "opencode",
|
||||||
|
args: (model, prompt) => ["run", "--model", model, prompt],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenCode 2",
|
||||||
|
major: 2,
|
||||||
|
bin: process.env.OPENCODE_V2_BIN ?? "opencode2",
|
||||||
|
args: (model, prompt) => ["run", "--standalone", "--auto", "--model", model, prompt],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const textOf = (content) =>
|
||||||
|
typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => part?.text ?? "").join("") : "";
|
||||||
|
const toolNames = (tools) => tools.map((tool) => tool.function?.name).filter(Boolean);
|
||||||
|
const toolProps = (tools, name) => tools.find((tool) => tool.function?.name === name)?.function?.parameters?.properties ?? {};
|
||||||
|
const pathArg = (tools, name, value) => ("filePath" in toolProps(tools, name) ? { filePath: value } : { path: value });
|
||||||
|
const firstTool = (tools, names) => names.find((name) => toolNames(tools).includes(name));
|
||||||
|
|
||||||
|
function scenarios(project) {
|
||||||
|
const envFile = path.join(project, ".env");
|
||||||
|
const envBefore = fs.readFileSync(envFile, "utf8");
|
||||||
|
const noLeak = (result) => !result.includes(FAKE_SECRET);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "envsitter_keys lists keys without values",
|
||||||
|
plan: () => ["envsitter_keys", { filePath: ".env" }],
|
||||||
|
pass: (result) => result.includes("API_TOKEN") && result.includes("DB_PASSWORD") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read .env is blocked",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", envFile)],
|
||||||
|
pass: (result) => result.includes("Reading `.env*` is blocked") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read .env.example is allowed",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", path.join(project, ".env.example"))],
|
||||||
|
pass: (result) => result.includes("replace-me"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write .env is blocked",
|
||||||
|
plan: (tools) => ["write", { ...pathArg(tools, "write", envFile), content: "API_TOKEN=overwritten\n" }],
|
||||||
|
pass: (result) => result.includes("Editing `.env*`") && fs.readFileSync(envFile, "utf8") === envBefore,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shell `cat .env` is blocked",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["bash", "shell"]);
|
||||||
|
if (!name) return undefined;
|
||||||
|
const args = { command: "cat .env" };
|
||||||
|
if ("description" in toolProps(tools, name)) args.description = "Print env file";
|
||||||
|
return [name, args];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Shell commands that reference") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shell output reaching .env values without naming the file is scrubbed",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["bash", "shell"]);
|
||||||
|
if (!name) return undefined;
|
||||||
|
const args = { command: "cat logs/app.log" };
|
||||||
|
if ("description" in toolProps(tools, name)) args.description = "Print app log";
|
||||||
|
return [name, args];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Bearer [envsitter: API_TOKEN redacted]") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read of a non-env file containing a secret is scrubbed",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", path.join(project, "logs", "app.log"))],
|
||||||
|
pass: (result) => result.includes("Bearer [envsitter: API_TOKEN redacted]") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grep hides matches inside .env",
|
||||||
|
plan: () => ["grep", { pattern: "API_TOKEN" }],
|
||||||
|
pass: (result) => result.includes("app.ts") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "patch adding .env.local is blocked",
|
||||||
|
model: "mock/gpt-5-scripted",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["apply_patch", "patch"]);
|
||||||
|
return name && [name, { patchText: "*** Begin Patch\n*** Add File: .env.local\n+X=1\n*** End Patch" }];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Editing `.env*`") && !fs.existsSync(path.join(project, ".env.local")),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModel(state) {
|
||||||
|
const send = (res, chunks) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||||
|
for (const chunk of chunks) res.write(`data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
const base = { id: "chatcmpl-e2e", object: "chat.completion.chunk", created: 0, model: "scripted" };
|
||||||
|
const finish = (reason) => ({ ...base, choices: [{ index: 0, delta: {}, finish_reason: reason }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
|
||||||
|
const text = (res, content) =>
|
||||||
|
send(res, [{ ...base, choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }] }, finish("stop"), "[DONE]"]);
|
||||||
|
const toolCall = (res, name, args) =>
|
||||||
|
send(res, [
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_e2e_1", type: "function", function: { name, arguments: JSON.stringify(args) } }] },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish("tool_calls"),
|
||||||
|
"[DONE]",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => (raw += chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!req.url?.endsWith("/chat/completions")) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
const tools = body.tools ?? [];
|
||||||
|
const marker = messages.map((m) => (m.role === "user" ? /E2E-SCENARIO:(\d+)/.exec(textOf(m.content))?.[1] : undefined)).find(Boolean);
|
||||||
|
const scenario = marker === undefined ? undefined : state.scenarios[Number(marker)];
|
||||||
|
if (!scenario || tools.length === 0) return text(res, "Guard e2e");
|
||||||
|
|
||||||
|
state.toolNames = toolNames(tools);
|
||||||
|
const last = messages.at(-1);
|
||||||
|
if (last?.role === "tool") {
|
||||||
|
state.result = textOf(last.content);
|
||||||
|
return text(res, "DONE");
|
||||||
|
}
|
||||||
|
const planned = scenario.plan(tools);
|
||||||
|
if (!planned) {
|
||||||
|
state.result = "<<tool unavailable>>";
|
||||||
|
return text(res, "UNAVAILABLE");
|
||||||
|
}
|
||||||
|
state.call = planned;
|
||||||
|
return toolCall(res, planned[0], planned[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(root, baseURL) {
|
||||||
|
const project = path.join(root, "project");
|
||||||
|
fs.mkdirSync(path.join(project, "src"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(project, ".opencode", "plugin"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(project, ".env"), `API_TOKEN=${FAKE_SECRET}\nDB_PASSWORD=fake-db-pass-456\n`);
|
||||||
|
fs.writeFileSync(path.join(project, ".env.example"), "API_TOKEN=replace-me\n");
|
||||||
|
fs.writeFileSync(path.join(project, "src", "app.ts"), "export const token = process.env.API_TOKEN;\n");
|
||||||
|
fs.mkdirSync(path.join(project, "logs"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(project, "logs", "app.log"), `request ok\nauth header: Bearer ${FAKE_SECRET}\n`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(project, ".opencode", "plugin", "envsitter-guard.ts"),
|
||||||
|
`export { default, EnvSitterGuard } from ${JSON.stringify(pluginEntry)};\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(project, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
model: "mock/scripted",
|
||||||
|
small_model: "mock/scripted",
|
||||||
|
provider: {
|
||||||
|
mock: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
name: "Mock",
|
||||||
|
options: { baseURL, apiKey: "e2e" },
|
||||||
|
models: { scripted: { name: "Scripted", tool_call: true }, "gpt-5-scripted": { name: "GPT 5 Scripted", tool_call: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: { edit: "allow", bash: "allow", webfetch: "allow" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
spawnSync("git", ["init", "-q"], { cwd: project });
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolatedEnv(home, project) {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
PWD: project,
|
||||||
|
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||||
|
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||||
|
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
||||||
|
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||||
|
ENVSITTER_PEPPER: "e2e-pepper",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(bin, args, options) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(bin, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||||
|
child.stdout.resume();
|
||||||
|
const timer = setTimeout(() => child.kill("SIGKILL"), RUN_TIMEOUT_MS);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code, stderr });
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: -1, stderr: String(error) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "envsitter-guard-e2e-")));
|
||||||
|
const state = { scenarios: [] };
|
||||||
|
const server = await startModel(state);
|
||||||
|
const baseURL = `http://127.0.0.1:${server.address().port}/v1`;
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
for (const version of versions) {
|
||||||
|
const probe = spawnSync(version.bin, ["--version"], { encoding: "utf8" });
|
||||||
|
const reported = `${probe.stdout ?? ""}`.trim();
|
||||||
|
const major = Number(/(\d+)\.\d+\.\d+/.exec(reported)?.[1]);
|
||||||
|
if (probe.status !== 0 || major !== version.major) {
|
||||||
|
const required = process.env.E2E_REQUIRE_ALL === "1";
|
||||||
|
if (required) failures += 1;
|
||||||
|
console.log(`\n${version.name}: ${required ? "FAIL, missing" : "skipped"} (\`${version.bin} --version\` reported ${JSON.stringify(reported || probe.error?.message || "nothing")})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionRoot = path.join(tempRoot, `v${version.major}`);
|
||||||
|
const project = createProject(versionRoot, baseURL);
|
||||||
|
const env = isolatedEnv(path.join(versionRoot, "home"), project);
|
||||||
|
state.scenarios = scenarios(project);
|
||||||
|
console.log(`\n${version.name} (${reported})`);
|
||||||
|
|
||||||
|
for (const [index, scenario] of state.scenarios.entries()) {
|
||||||
|
state.result = undefined;
|
||||||
|
state.call = undefined;
|
||||||
|
const outcome = await run(version.bin, version.args(scenario.model ?? "mock/scripted", `E2E-SCENARIO:${index}`), { cwd: project, env });
|
||||||
|
const result = state.result ?? "<<no tool result>>";
|
||||||
|
const ok = outcome.code === 0 && result !== "<<tool unavailable>>" && scenario.pass(result);
|
||||||
|
if (!ok) failures += 1;
|
||||||
|
const via = state.call ? ` via ${state.call[0]}` : "";
|
||||||
|
console.log(` ${ok ? "PASS" : "FAIL"} ${scenario.name}${via}`);
|
||||||
|
if (!ok) console.log(` exit=${outcome.code} result=${JSON.stringify(result.slice(0, 300))} stderr=${JSON.stringify(outcome.stderr.slice(-300))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = (state.toolNames ?? []).filter((name) => name.startsWith("envsitter_")).length;
|
||||||
|
const toolsOk = visible === 15;
|
||||||
|
if (!toolsOk) failures += 1;
|
||||||
|
console.log(` ${toolsOk ? "PASS" : "FAIL"} model sees all 15 envsitter_* tools directly (${visible})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
if (process.env.KEEP_E2E === "1") console.log(`\nKept ${tempRoot}`);
|
||||||
|
else fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
|
||||||
|
console.log(failures === 0 ? "\nAll checks passed." : `\n${failures} check(s) failed.`);
|
||||||
|
process.exitCode = failures === 0 ? 0 : 1;
|
||||||
@@ -9,6 +9,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|||||||
import EnvSitterGuard from "../index.js";
|
import EnvSitterGuard from "../index.js";
|
||||||
|
|
||||||
type ToolExecuteBeforeHook = NonNullable<Hooks["tool.execute.before"]>;
|
type ToolExecuteBeforeHook = NonNullable<Hooks["tool.execute.before"]>;
|
||||||
|
type ToolExecuteAfterHook = NonNullable<Hooks["tool.execute.after"]>;
|
||||||
|
|
||||||
async function createTmpDir(): Promise<string> {
|
async function createTmpDir(): Promise<string> {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-"));
|
||||||
@@ -37,7 +38,7 @@ function createClientSpy(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBeforeHook(params: { directory: string; worktree: string }) {
|
async function getHooks(params: { directory: string; worktree: string }) {
|
||||||
const { client, calls } = createClientSpy();
|
const { client, calls } = createClientSpy();
|
||||||
|
|
||||||
const pluginInput: PluginInput = {
|
const pluginInput: PluginInput = {
|
||||||
@@ -45,72 +46,142 @@ async function getBeforeHook(params: { directory: string; worktree: string }) {
|
|||||||
project: {} as unknown as PluginInput["project"],
|
project: {} as unknown as PluginInput["project"],
|
||||||
directory: params.directory,
|
directory: params.directory,
|
||||||
worktree: params.worktree,
|
worktree: params.worktree,
|
||||||
|
experimental_workspace: { register() {} },
|
||||||
serverUrl: new URL("http://localhost"),
|
serverUrl: new URL("http://localhost"),
|
||||||
$: (() => {
|
$: (() => {
|
||||||
throw new Error("not used in tests");
|
throw new Error("not used in tests");
|
||||||
}) as unknown as PluginInput["$"],
|
}) as unknown as PluginInput["$"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const hooks = (await EnvSitterGuard(pluginInput)) as {
|
const hooks = (await EnvSitterGuard.server(pluginInput)) as {
|
||||||
"tool.execute.before": ToolExecuteBeforeHook;
|
"tool.execute.before": ToolExecuteBeforeHook;
|
||||||
|
"tool.execute.after": ToolExecuteAfterHook;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { hook: hooks["tool.execute.before"], calls };
|
return { before: hooks["tool.execute.before"], after: hooks["tool.execute.after"], calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBeforeHook() {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
return getHooks({ directory: worktree, worktree });
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(tool: string) {
|
||||||
|
return { tool, sessionID: "s", callID: "c" };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("blocks reading .env", async () => {
|
test("blocks reading .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
() => before(call("read"), { args: { filePath: ".env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows reading .env.example", async () => {
|
test("allows reading example and template env files", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env.example" } });
|
for (const filePath of [".env.example", ".env.sample", ".env.template", "api.env.example"]) {
|
||||||
|
await before(call("read"), { args: { filePath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks reading named env files such as api.env and config.env.local", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
for (const filePath of ["api.env", "config/config.env.local", "/abs/project/.env.production"]) {
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("read"), { args: { filePath } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
|
filePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks editing .env", async () => {
|
test("blocks editing .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "edit", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
() => before(call("edit"), { args: { filePath: ".env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks .envsitter/pepper", async () => {
|
test("blocks apply_patch when the patch touches .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
const patchText = ["*** Begin Patch", "*** Update File: src/app.ts", "@@", "*** Add File: .env.local", "+TOKEN=x", "*** End Patch"].join("\n");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".envsitter/pepper" } }),
|
() => before(call("apply_patch"), { args: { patchText } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("`.env.local`"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await before(call("apply_patch"), { args: { patchText: "*** Begin Patch\n*** Update File: src/app.ts\n*** End Patch" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks shell commands that name .env files but allows the envsitter CLI", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("bash"), { args: { command: "cat .env | head -5" } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Shell commands that reference"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await before(call("bash"), { args: { command: "npx envsitter keys --file .env --json" } });
|
||||||
|
await before(call("bash"), { args: { command: 'git commit -m "document .env usage"' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks .envsitter/pepper", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("read"), { args: { filePath: ".envsitter/pepper" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips @ prefix in filePath", async () => {
|
test("strips @ prefix in filePath", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: "@.env" } }),
|
() => before(call("read"), { args: { filePath: "@.env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocking is silent (no toasts)", async () => {
|
test("blocking is silent (no toasts)", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before, calls } = await getBeforeHook();
|
||||||
const { hook, calls } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||||
|
|
||||||
assert.equal(calls.showToast, 0, "should not show toasts, only throw errors");
|
assert.equal(calls.showToast, 0, "should not show toasts, only throw errors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hides grep matches from protected env files", async () => {
|
||||||
|
const { after } = await getBeforeHook();
|
||||||
|
const output = {
|
||||||
|
title: "TOKEN",
|
||||||
|
metadata: {},
|
||||||
|
output: ["Found 3 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 4: process.env.TOKEN", " Line 9: TOKEN"].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
await after({ ...call("grep"), args: { pattern: "TOKEN" } }, output);
|
||||||
|
|
||||||
|
assert.ok(!output.output.includes("super-secret"));
|
||||||
|
assert.ok(output.output.startsWith("Found 2 matches"));
|
||||||
|
assert.ok(output.output.includes("/repo/src/app.ts:"));
|
||||||
|
assert.ok(output.output.includes("1 match in protected .env files hidden"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrubs secret values from any tool output, such as shell commands that never name .env", async () => {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
await fs.writeFile(path.join(worktree, ".env"), "API_TOKEN=tok_live_9f8a7b6c5d4e\nNODE_ENV=development\n");
|
||||||
|
const { after } = await getHooks({ directory: worktree, worktree });
|
||||||
|
|
||||||
|
const output = { title: "cat logs/app.log", metadata: {}, output: "env=development\ncalling api with tok_live_9f8a7b6c5d4e\n" };
|
||||||
|
await after({ ...call("bash"), args: { command: "cat logs/app.log" } }, output);
|
||||||
|
|
||||||
|
assert.equal(output.output, "env=development\ncalling api with [envsitter: API_TOKEN redacted]\n");
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,13 +125,14 @@ async function getTools(params: { directory: string; worktree: string }): Promis
|
|||||||
project: {} as unknown as PluginInput["project"],
|
project: {} as unknown as PluginInput["project"],
|
||||||
directory: params.directory,
|
directory: params.directory,
|
||||||
worktree: params.worktree,
|
worktree: params.worktree,
|
||||||
|
experimental_workspace: { register() {} },
|
||||||
serverUrl: new URL("http://localhost"),
|
serverUrl: new URL("http://localhost"),
|
||||||
$: (() => {
|
$: (() => {
|
||||||
throw new Error("not used in tests");
|
throw new Error("not used in tests");
|
||||||
}) as unknown as PluginInput["$"],
|
}) as unknown as PluginInput["$"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const hooks = (await EnvSitterGuard(pluginInput)) as unknown as {
|
const hooks = (await EnvSitterGuard.server(pluginInput)) as unknown as {
|
||||||
tool: ToolApi;
|
tool: ToolApi;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +306,24 @@ test("tool execution rejects paths outside worktree", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("envsitter tools accept named env files such as api.env", async () => {
|
||||||
|
await withPepper(async () => {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
await fs.writeFile(path.join(worktree, "api.env"), "API_TOKEN=secret-token\n");
|
||||||
|
|
||||||
|
const tools = await getTools({ directory: worktree, worktree });
|
||||||
|
const out = await tools.envsitter_keys.execute({ filePath: "api.env" });
|
||||||
|
|
||||||
|
assert.ok(!out.includes("secret-token"));
|
||||||
|
assert.deepEqual((JSON.parse(out) as { keys: string[] }).keys, ["API_TOKEN"]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => tools.envsitter_keys.execute({ filePath: "src/index.ts" }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Only dotenv files are allowed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("tool execution blocks .envsitter/pepper", async () => {
|
test("tool execution blocks .envsitter/pepper", async () => {
|
||||||
const worktree = await createTmpDir();
|
const worktree = await createTmpDir();
|
||||||
const tools = await getTools({ directory: worktree, worktree });
|
const tools = await getTools({ directory: worktree, worktree });
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { Plugin } from "@opencode/plugin";
|
||||||
|
|
||||||
|
import EnvSitterGuard from "../index.js";
|
||||||
|
|
||||||
|
type RegisteredTool = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
options?: { codemode?: boolean };
|
||||||
|
execute: (input: unknown, context: unknown) => Promise<{ content?: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HookCallback = (event: Record<string, unknown>) => unknown;
|
||||||
|
|
||||||
|
const registration = { dispose: async () => {} };
|
||||||
|
|
||||||
|
async function setupV2(directory: string, root = directory) {
|
||||||
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const toolHooks = new Map<string, HookCallback>();
|
||||||
|
const permissionHooks = new Map<string, HookCallback>();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
location: { directory, project: { id: "project", directory: root, canonical: root } },
|
||||||
|
options: {},
|
||||||
|
tool: {
|
||||||
|
async transform(callback: (editor: { add(tool: RegisteredTool): void }) => void) {
|
||||||
|
callback({ add: (tool) => tools.set(tool.name, tool) });
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
async hook(name: string, callback: HookCallback) {
|
||||||
|
toolHooks.set(name, callback);
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
async hook(name: string, callback: HookCallback) {
|
||||||
|
permissionHooks.set(name, callback);
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Plugin.Context;
|
||||||
|
|
||||||
|
await EnvSitterGuard.setup(ctx);
|
||||||
|
|
||||||
|
const hook = (map: Map<string, HookCallback>, name: string): HookCallback => {
|
||||||
|
const callback = map.get(name);
|
||||||
|
assert.ok(callback, `missing hook ${name}`);
|
||||||
|
return callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools,
|
||||||
|
before: hook(toolHooks, "execute.before"),
|
||||||
|
after: hook(toolHooks, "execute.after"),
|
||||||
|
evaluate: hook(permissionHooks, "evaluate"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(files: Record<string, string>): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-v2-"));
|
||||||
|
for (const [name, contents] of Object.entries(files)) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, name), contents);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEvent(tool: string, input: Record<string, unknown>) {
|
||||||
|
return { tool, sessionID: "ses_1", agent: "build", messageID: "msg_1", id: "call_1", input };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("V2 default export carries a stable id plus both entrypoints", () => {
|
||||||
|
assert.equal(EnvSitterGuard.id, "envsitter-guard");
|
||||||
|
assert.equal(typeof EnvSitterGuard.setup, "function");
|
||||||
|
assert.equal(typeof EnvSitterGuard.server, "function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 registers every envsitter tool directly (not behind Code Mode) with JSON Schema inputs", async () => {
|
||||||
|
const { tools } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
assert.equal(tools.size, 15);
|
||||||
|
for (const tool of tools.values()) {
|
||||||
|
assert.ok(tool.name.startsWith("envsitter_"), tool.name);
|
||||||
|
assert.equal(tool.options?.codemode, false, tool.name);
|
||||||
|
assert.equal(tool.input.type, "object", tool.name);
|
||||||
|
assert.equal("$schema" in tool.input, false, tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = tools.get("envsitter_fingerprint");
|
||||||
|
assert.deepEqual(fingerprint?.input.required, ["key"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 tools return structured content without values and validate input", async () => {
|
||||||
|
const dir = await createProject({ ".env": "API_TOKEN=very-secret\nPORT=3000\n" });
|
||||||
|
const { tools } = await setupV2(dir);
|
||||||
|
|
||||||
|
const keys = tools.get("envsitter_keys");
|
||||||
|
assert.ok(keys);
|
||||||
|
const result = await keys.execute({ filePath: ".env" }, {});
|
||||||
|
assert.equal(typeof result.content, "string");
|
||||||
|
assert.ok(!String(result.content).includes("very-secret"));
|
||||||
|
assert.deepEqual((JSON.parse(String(result.content)) as { keys: string[] }).keys, ["API_TOKEN", "PORT"]);
|
||||||
|
|
||||||
|
const fingerprint = tools.get("envsitter_fingerprint");
|
||||||
|
assert.ok(fingerprint);
|
||||||
|
await assert.rejects(() => fingerprint.execute({ filePath: ".env" }, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 tools resolve paths from the plugin location and stay inside the project", async () => {
|
||||||
|
const root = await createProject({ "apps/api/.env": "A=1\n" });
|
||||||
|
const { tools } = await setupV2(path.join(root, "apps", "api"), root);
|
||||||
|
|
||||||
|
const keys = tools.get("envsitter_keys");
|
||||||
|
assert.ok(keys);
|
||||||
|
const result = await keys.execute({}, {});
|
||||||
|
assert.equal((JSON.parse(String(result.content)) as { file: string }).file, path.join("apps", "api", ".env"));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => keys.execute({ filePath: "../../../outside/.env" }, {}),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("inside the current project"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.before blocks built-in tools on protected files, including nested Code Mode calls", async () => {
|
||||||
|
const { before } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
assert.throws(() => before(toolEvent("read", { path: ".env" })), /Reading `\.env\*` is blocked/);
|
||||||
|
assert.throws(() => before(toolEvent("write", { path: "api.env", content: "X=1" })), /Editing `\.env\*`/);
|
||||||
|
assert.throws(
|
||||||
|
() => before(toolEvent("patch", { patchText: "*** Begin Patch\n*** Delete File: .env.production\n*** End Patch" })),
|
||||||
|
/Editing `\.env\*`/,
|
||||||
|
);
|
||||||
|
assert.throws(() => before(toolEvent("shell", { command: "source .env && env" })), /Shell commands that reference/);
|
||||||
|
assert.throws(() => before(toolEvent("grep", { pattern: "TOKEN", path: ".env.local" })), /Reading `\.env\*` is blocked/);
|
||||||
|
assert.throws(() => before(toolEvent("filesystem_read_file", { path: "/abs/.env" })), /through `filesystem_read_file`/);
|
||||||
|
|
||||||
|
before(toolEvent("read", { path: ".env.example" }));
|
||||||
|
before(toolEvent("read", { path: "src/index.ts" }));
|
||||||
|
before(toolEvent("shell", { command: "npm test" }));
|
||||||
|
before(toolEvent("envsitter_keys", { filePath: ".env" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.after hides grep matches from protected files in content and structured output", async () => {
|
||||||
|
const { after } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
...toolEvent("grep", { pattern: "TOKEN" }),
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
content: ["Found 2 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 3: TOKEN"].join("\n"),
|
||||||
|
output: [
|
||||||
|
{ entry: { path: ".env" }, line: 1, text: "TOKEN=super-secret" },
|
||||||
|
{ entry: { path: "src/app.ts" }, line: 3, text: "TOKEN" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await after(event);
|
||||||
|
|
||||||
|
const result = event.result as { content: string; output: Array<{ entry: { path: string } }> };
|
||||||
|
assert.ok(!result.content.includes("super-secret"));
|
||||||
|
assert.ok(result.content.startsWith("Found 1 matches"));
|
||||||
|
assert.deepEqual(
|
||||||
|
result.output.map((match) => match.entry.path),
|
||||||
|
["src/app.ts"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.after scrubs secret values from shell results that never name .env", async () => {
|
||||||
|
const { after } = await setupV2(await createProject({ ".env": "API_TOKEN=tok_live_9f8a7b6c5d4e\n" }));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
...toolEvent("shell", { command: "grep -r api ." }),
|
||||||
|
status: "completed",
|
||||||
|
result: { content: [{ type: "text", text: "./logs/app.log: api key tok_live_9f8a7b6c5d4e" }] },
|
||||||
|
};
|
||||||
|
await after(event);
|
||||||
|
|
||||||
|
const result = event.result as { content: Array<{ text: string }> };
|
||||||
|
assert.equal(result.content[0]?.text, "./logs/app.log: api key [envsitter: API_TOKEN redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 permission evaluate denies protected read, edit, and shell resources only", async () => {
|
||||||
|
const { evaluate } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
const deniedRead = { sessionID: "ses_1", action: "read", resources: ["/repo/.env"], effect: "allow" as string, message: undefined as string | undefined };
|
||||||
|
await evaluate(deniedRead);
|
||||||
|
assert.equal(deniedRead.effect, "deny");
|
||||||
|
assert.match(deniedRead.message ?? "", /Reading `\.env\*` is blocked/);
|
||||||
|
|
||||||
|
const deniedShell = { sessionID: "ses_1", action: "shell", resources: ["cat .env.local"], effect: "ask" as string };
|
||||||
|
await evaluate(deniedShell);
|
||||||
|
assert.equal(deniedShell.effect, "deny");
|
||||||
|
|
||||||
|
const allowed = { sessionID: "ses_1", action: "edit", resources: ["/repo/src/app.ts", "/repo/.env.example"], effect: "allow" as string };
|
||||||
|
await evaluate(allowed);
|
||||||
|
assert.equal(allowed.effect, "allow");
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { extractPatchPaths, findBlockedAccess, findProtectedPathsInCommand, redactGrepOutput } from "../lib/policy.js";
|
||||||
|
|
||||||
|
test("shell scan flags commands that name protected files", () => {
|
||||||
|
const blocked = [
|
||||||
|
"cat .env",
|
||||||
|
"source .env",
|
||||||
|
". .env.local",
|
||||||
|
"echo TOKEN=x >> .env",
|
||||||
|
"cat<.env",
|
||||||
|
"node --env-file=.env app.js",
|
||||||
|
'echo "$(cat .env)"',
|
||||||
|
"echo `cat api.env`",
|
||||||
|
"cat .env*",
|
||||||
|
"cat .envsitter/pepper",
|
||||||
|
"ls && cat config/app.env",
|
||||||
|
"envsitter keys --file .env; cat .env",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const command of blocked) {
|
||||||
|
assert.ok(findProtectedPathsInCommand(command).length > 0, command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shell scan allows envsitter CLI calls, example files, and prose mentions", () => {
|
||||||
|
const allowed = [
|
||||||
|
"npx envsitter keys --file .env",
|
||||||
|
"npx -y envsitter@latest match --file .env --key A --op exists",
|
||||||
|
"envsitter set --file .env.local --key A --value-stdin --write",
|
||||||
|
"pnpm dlx envsitter validate --file .env",
|
||||||
|
'git commit -m "document .env usage"',
|
||||||
|
"cat .env.example",
|
||||||
|
"cp .env.example .env.sample",
|
||||||
|
"ls -la",
|
||||||
|
"npm test",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const command of allowed) {
|
||||||
|
assert.deepEqual(findProtectedPathsInCommand(command), [], command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patch path extraction covers patch envelopes and unified diffs", () => {
|
||||||
|
const envelope = ["*** Begin Patch", "*** Update File: src/a.ts", "*** Move to: src/b.ts", "*** Add File: .env", "*** Delete File: old.env", "*** End Patch"].join("\n");
|
||||||
|
assert.deepEqual(extractPatchPaths(envelope), ["src/a.ts", "src/b.ts", ".env", "old.env"]);
|
||||||
|
|
||||||
|
const unified = ["--- a/.env.local", "+++ b/.env.local", "@@ -1 +1 @@", "-A=1", "+A=2"].join("\n");
|
||||||
|
assert.deepEqual(extractPatchPaths(unified), [".env.local", ".env.local"]);
|
||||||
|
assert.deepEqual(extractPatchPaths("--- /dev/null\n+++ b/src/new.ts"), ["src/new.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findBlockedAccess reads path arguments from V1 and V2 tool shapes", () => {
|
||||||
|
assert.equal(findBlockedAccess("read", { filePath: ".env" })?.kind, "read");
|
||||||
|
assert.equal(findBlockedAccess("read", { path: ".env" })?.kind, "read");
|
||||||
|
assert.equal(findBlockedAccess("multi_read", { paths: ["a.ts", ".env.production"] })?.kind, "access");
|
||||||
|
assert.equal(findBlockedAccess("envsitter_keys", { filePath: ".env" }), undefined);
|
||||||
|
assert.equal(findBlockedAccess("read", "not-an-object"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grep redaction is a no-op when nothing is protected", () => {
|
||||||
|
const output = "Found 1 matches\n/repo/src/a.ts:\n Line 1: TOKEN";
|
||||||
|
assert.deepEqual(redactGrepOutput(output), { output, hidden: 0 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { createScrubLoader, findSensitiveEnvFiles, scrubToolResult } from "../lib/scrub.js";
|
||||||
|
|
||||||
|
const TOKEN = "tok_live_9f8a7b6c5d4e";
|
||||||
|
const DB_PASSWORD = "s3cretPassw0rd";
|
||||||
|
|
||||||
|
async function createProject(files: Record<string, string>): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-scrub-"));
|
||||||
|
for (const [name, contents] of Object.entries(files)) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, name), contents);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("finds sensitive env files in the project, skipping examples and dependency folders", async () => {
|
||||||
|
const root = await createProject({
|
||||||
|
".env": `API_TOKEN=${TOKEN}\n`,
|
||||||
|
".env.example": "API_TOKEN=replace-me\n",
|
||||||
|
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||||
|
"node_modules/pkg/.env": "X=1\n",
|
||||||
|
"a/b/c/d/.env": "TOO_DEEP=1\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = (await findSensitiveEnvFiles(root)).map((file) => path.relative(root, file));
|
||||||
|
assert.deepEqual(files, [".env", path.join("apps", "web", ".env.local")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrub replaces secret values from every env file and leaves other text alone", async () => {
|
||||||
|
const root = await createProject({
|
||||||
|
".env": `API_TOKEN=${TOKEN}\nNODE_ENV=development\n`,
|
||||||
|
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrub = await createScrubLoader(root)();
|
||||||
|
const output = scrub(`Bearer ${TOKEN}\npassword=${DB_PASSWORD}\nNODE_ENV=development`);
|
||||||
|
assert.equal(output, "Bearer [envsitter: API_TOKEN redacted]\npassword=[envsitter: DATABASE_URL redacted]\nNODE_ENV=development");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrub picks up env file changes", async () => {
|
||||||
|
const root = await createProject({ ".env": `API_TOKEN=${TOKEN}\n` });
|
||||||
|
const loadScrub = createScrubLoader(root);
|
||||||
|
assert.ok(!(await loadScrub())(TOKEN).includes(TOKEN));
|
||||||
|
|
||||||
|
const rotated = "tok_live_0a1b2c3d4e5f6a7b8c";
|
||||||
|
await fs.writeFile(path.join(root, ".env"), `API_TOKEN=${rotated}\n`);
|
||||||
|
const scrub = await loadScrub();
|
||||||
|
assert.equal(scrub(rotated), "[envsitter: API_TOKEN redacted]");
|
||||||
|
assert.equal(scrub(TOKEN), TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrubToolResult scrubs text content and structured output, and keeps untouched results identical", () => {
|
||||||
|
const scrub = (text: string) => text.replaceAll(TOKEN, "[x]");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "text", text: `token ${TOKEN}` }, { type: "image", data: TOKEN }],
|
||||||
|
output: { stdout: `ok ${TOKEN}`, exitCode: 0, lines: [TOKEN, "fine"] },
|
||||||
|
metadata: { title: "t" },
|
||||||
|
};
|
||||||
|
const scrubbed = scrubToolResult(result, scrub);
|
||||||
|
assert.deepEqual(scrubbed.content, [{ type: "text", text: "token [x]" }, { type: "image", data: TOKEN }]);
|
||||||
|
assert.deepEqual(scrubbed.output, { stdout: "ok [x]", exitCode: 0, lines: ["[x]", "fine"] });
|
||||||
|
assert.equal(scrubbed.metadata, result.metadata);
|
||||||
|
|
||||||
|
const clean = { content: "nothing secret", output: { exitCode: 0 } };
|
||||||
|
assert.equal(scrubToolResult(clean, scrub), clean);
|
||||||
|
});
|
||||||
+1
-1
@@ -8,5 +8,5 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["index.ts", ".opencode/plugin/**/*.ts"]
|
"include": ["index.ts", ".opencode/plugin/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user