feat: support OpenCode 2 and scrub secret values from tool output

- Default export is { id, setup, server }: OpenCode 2 calls setup(ctx), OpenCode 1 (1.18.29+) calls server()
- Tools defined once in lib/tools.ts and registered for both versions (V2 with codemode: false)
- Blocking covers path args of any tool, patch/apply_patch envelopes, shell commands naming env files, grep output, and V2 permission checks
- Secret-looking values from the project's env files are replaced in every non-envsitter tool's output
- Protected files come from envsitter's path policy; example/template files stay readable
- npm run test:e2e drives the real OpenCode 1 and 2 CLIs with a scripted model
- @opencode-ai/plugin ^1.19.3, @opencode/plugin ^2.1.0 (peer >=2.0.32), envsitter ^0.1.0, TypeScript 6

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