From fa23dd07f8af5b43aabaf5f256c0d4485cae3dfd Mon Sep 17 00:00:00 2001 From: David Ibia Date: Tue, 13 Jan 2026 19:28:39 +0100 Subject: [PATCH] feat: add secure envsitter dotenv operations Expose validate/copy/format/annotate tools with dry-run by default, and switch is_equal matching to candidate hashing. Remove prompt-append warning to avoid writing into OpenCode input. --- README.md | 71 ++++++++- index.ts | 226 ++++++++++++++++++++++++++--- package-lock.json | 8 +- package.json | 2 +- test/envsitter-guard.hook.test.ts | 9 +- test/envsitter-guard.tools.test.ts | 112 ++++++++++++++ 6 files changed, 398 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index efce881..cc9b669 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,15 @@ These tools never return raw `.env` values: - `envsitter_match`: boolean/shape checks and outside-in candidate matching (without printing values) - `envsitter_match_by_key`: bulk candidate-by-key matching (returns booleans only) - `envsitter_scan`: scan value *shapes* (jwt/url/base64) without printing values +- `envsitter_validate`: validate dotenv syntax (no values; issues only) +- `envsitter_copy`: copy keys between env files (no values; plan + line numbers only) +- `envsitter_format` / `envsitter_reorder`: reorder/format env files (no values) +- `envsitter_annotate`: add comments near keys (no values) + +Notes for file operations: + +- File operations are dry-run unless `write: true` is provided. +- Tools only return keys, booleans, and line numbers/operation plans. ### Blocking behavior @@ -65,7 +74,10 @@ When blocked, the plugin shows a throttled warning toast and suggests using EnvS ## Tools -All tools accept a `filePath` that defaults to `.env`. Tools only operate on `.env`-style files inside the current project. +Tools only operate on `.env`-style files inside the current project. + +- Most tools accept a `filePath` that defaults to `.env`. +- File operations are dry-run unless `write: true` is provided. ### `envsitter_keys` @@ -157,6 +169,63 @@ Example (inside OpenCode): { "tool": "envsitter_scan", "args": { "filePath": ".env", "detect": ["jwt", "url"] } } ``` +### `envsitter_validate` + +Validate dotenv syntax. + +- Input: `{ "filePath"?: string }` +- Output: JSON `{ file, ok, issues }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_validate", "args": { "filePath": ".env" } } +``` + +### `envsitter_copy` + +Copy keys between env files. Output includes a plan (keys + line numbers), never values. + +- Input: + - `{ "from": string, "to": string, "keys"?: string[], "includeRegex"?: string, "excludeRegex"?: string, "rename"?: string, "onConflict"?: "error"|"skip"|"overwrite", "write"?: boolean }` +- Output: JSON `{ from, to, onConflict, willWrite, wrote, hasChanges, issues, plan }` + +Examples (inside OpenCode): + +```json +{ "tool": "envsitter_copy", "args": { "from": ".env.production", "to": ".env.staging", "keys": ["API_URL"], "onConflict": "overwrite" } } +``` + +```json +{ "tool": "envsitter_copy", "args": { "from": ".env.production", "to": ".env.staging", "keys": ["API_URL"], "onConflict": "overwrite", "write": true } } +``` + +### `envsitter_format` / `envsitter_reorder` + +Format/reorder an env file (no values in output). + +- Input: `{ "filePath"?: string, "mode"?: "sections"|"global", "sort"?: "alpha"|"none", "write"?: boolean }` +- Output: JSON `{ file, mode, sort, willWrite, wrote, hasChanges, issues }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_format", "args": { "filePath": ".env", "mode": "sections", "sort": "alpha", "write": true } } +``` + +### `envsitter_annotate` + +Annotate an env key with a comment (no values in output). + +- Input: `{ "filePath"?: string, "key": string, "comment": string, "line"?: number, "write"?: boolean }` +- Output: JSON `{ file, key, willWrite, wrote, hasChanges, issues, plan }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_annotate", "args": { "filePath": ".env", "key": "DATABASE_URL", "comment": "prod only", "write": true } } +``` + ## Install & enable in OpenCode (alternatives) ### Option B: local plugin file (project-level) diff --git a/index.ts b/index.ts index a952f3b..776ed11 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,6 @@ import type { Plugin } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin/tool"; -import { EnvSitter } from "envsitter"; +import { EnvSitter, annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from "envsitter"; import path from "node:path"; function normalizePath(input: string): string { @@ -134,21 +134,11 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => await client.tui.showToast({ body: { - title: "Blocked sensitive file access", + title: "Blocked sensitive .env access", variant: "warning", message: - `${action} of sensitive env files is blocked. Use EnvSitter instead (never prints values):\n` + - "- envsitter_keys { filePath: '.env' }\n" + - "- envsitter_fingerprint { filePath: '.env', key: 'SOME_KEY' }\n" + - "- envsitter_match { filePath: '.env', key: 'SOME_KEY', op: 'exists' }\n" + - "- envsitter_scan { filePath: '.env', detect: ['jwt','url'] }\n" + - "(CLI: `npx envsitter keys --file .env`) ", - }, - }); - - await client.tui.appendPrompt({ - body: { - text: "\nTip: use EnvSitter for `.env*` inspection (keys/fingerprints) instead of reading the file.\n", + `${action} of \`.env*\` is blocked to prevent secret leaks. ` + + "Use EnvSitter tools instead (never prints values): envsitter_keys, envsitter_fingerprint, envsitter_match, envsitter_scan, envsitter_validate, envsitter_format, envsitter_annotate, envsitter_copy.", }, }); } @@ -221,6 +211,8 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => 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; @@ -234,6 +226,7 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => }); if (op === "is_equal") { + isEqualCandidate = candidate; return { op, candidate } as const; } @@ -263,16 +256,25 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => } if (typeof key === "string") { - const match = await es.matchKey(key, matcher); + 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 = await es.matchKeyBulk(keys, matcher); + 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 = await es.matchKeyAll(matcher); + 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); }, }), @@ -368,6 +370,196 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => 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, + ); + }, + }), }, "tool.execute.before": async (input, output) => { const filePath = getFilePathFromArgs(output.args); diff --git a/package-lock.json b/package-lock.json index e191364..252e66c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.1.14", - "envsitter": "^0.0.2" + "envsitter": "^0.0.3" }, "devDependencies": { "@types/node": "*", @@ -45,9 +45,9 @@ } }, "node_modules/envsitter": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.2.tgz", - "integrity": "sha512-2rVcSQnRrDqk//2XM5c+M85YkNc6ljFgfMC4gBTvA/U21OUGB53zfANIEZZXfOcSVNmoRHVCGcogFHLHsBjd1w==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.3.tgz", + "integrity": "sha512-l7YuX/ptwODY0ooU5JuPLN5zrNpjoWU5O01Wi66t24FRYLrXK33ReEr3/TP4Fhaa+S4sQ7uBMLIe8VGmgvIs9g==", "license": "MIT", "bin": { "envsitter": "dist/cli.js" diff --git a/package.json b/package.json index 9955d35..9b871be 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@opencode-ai/plugin": "^1.1.14", - "envsitter": "^0.0.2" + "envsitter": "^0.0.3" }, "devDependencies": { "@types/node": "*", diff --git a/test/envsitter-guard.hook.test.ts b/test/envsitter-guard.hook.test.ts index 59a8311..2b2d2f0 100644 --- a/test/envsitter-guard.hook.test.ts +++ b/test/envsitter-guard.hook.test.ts @@ -19,12 +19,11 @@ function createClientSpy(): { client: { tui: { showToast: (input: { body: { title: string; variant: string; message: string } }) => Promise; - appendPrompt: (input: { body: { text: string } }) => Promise; }; }; - calls: { showToast: number; appendPrompt: number }; + calls: { showToast: number }; } { - const calls = { showToast: 0, appendPrompt: 0 }; + const calls = { showToast: 0 }; return { calls, @@ -33,9 +32,6 @@ function createClientSpy(): { async showToast() { calls.showToast += 1; }, - async appendPrompt() { - calls.appendPrompt += 1; - }, }, }, }; @@ -117,5 +113,4 @@ test("toasts are throttled", async () => { await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } })); assert.equal(calls.showToast, 1); - assert.equal(calls.appendPrompt, 1); }); diff --git a/test/envsitter-guard.tools.test.ts b/test/envsitter-guard.tools.test.ts index 99c4764..161186a 100644 --- a/test/envsitter-guard.tools.test.ts +++ b/test/envsitter-guard.tools.test.ts @@ -50,6 +50,40 @@ type ToolApi = { envsitter_scan: { execute: (args: { filePath?: string; detect?: ScanDetection[]; keysFilterRegex?: string }) => Promise; }; + envsitter_validate: { + execute: (args: { filePath?: string }) => Promise; + }; + envsitter_copy: { + execute: (args: { + from: string; + to: string; + keys?: string[]; + includeRegex?: string; + excludeRegex?: string; + rename?: string; + onConflict?: "error" | "skip" | "overwrite"; + write?: boolean; + }) => Promise; + }; + envsitter_format: { + execute: (args: { + filePath?: string; + mode?: "sections" | "global"; + sort?: "alpha" | "none"; + write?: boolean; + }) => Promise; + }; + envsitter_reorder: { + execute: (args: { + filePath?: string; + mode?: "sections" | "global"; + sort?: "alpha" | "none"; + write?: boolean; + }) => Promise; + }; + envsitter_annotate: { + execute: (args: { filePath?: string; key: string; comment: string; line?: number; write?: boolean }) => Promise; + }; }; async function createTmpDir(): Promise { @@ -280,3 +314,81 @@ test("tool execution blocks .envsitter/pepper", async () => { (err: unknown) => err instanceof Error && err.message.includes("blocked"), ); }); + +test("envsitter_validate returns issues without leaking values", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env"), "GOOD=supersecret\nBAD\n"); + + const tools = await getTools({ directory: worktree, worktree }); + const out = await tools.envsitter_validate.execute({ filePath: ".env" }); + + assert.ok(!out.includes("supersecret")); + + const parsed = JSON.parse(out) as { file: string; ok: boolean; issues: Array<{ line: number; column: number; message: string }> }; + assert.equal(parsed.file, ".env"); + assert.equal(parsed.ok, false); + assert.ok(parsed.issues.length > 0); +}); + +test("envsitter_copy dry-runs unless write=true", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env.production"), "FOO=bar\nBAZ=qux\n"); + await fs.writeFile(path.join(worktree, ".env.staging"), "FOO=old\n"); + + const tools = await getTools({ directory: worktree, worktree }); + + const outDryRun = await tools.envsitter_copy.execute({ + from: ".env.production", + to: ".env.staging", + keys: ["BAZ"], + onConflict: "overwrite", + }); + + assert.ok(!outDryRun.includes("bar")); + assert.ok(!outDryRun.includes("qux")); + + const stagingAfterDryRun = await fs.readFile(path.join(worktree, ".env.staging"), "utf8"); + assert.ok(!stagingAfterDryRun.includes("BAZ=qux")); + + const outWrite = await tools.envsitter_copy.execute({ + from: ".env.production", + to: ".env.staging", + keys: ["BAZ"], + onConflict: "overwrite", + write: true, + }); + + assert.ok(!outWrite.includes("bar")); + assert.ok(!outWrite.includes("qux")); + + const stagingAfterWrite = await fs.readFile(path.join(worktree, ".env.staging"), "utf8"); + assert.ok(stagingAfterWrite.includes("BAZ=qux")); +}); + +test("envsitter_format sorts keys without leaking values", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env"), "B=2\nA=1\n"); + + const tools = await getTools({ directory: worktree, worktree }); + const out = await tools.envsitter_format.execute({ filePath: ".env", mode: "global", sort: "alpha", write: true }); + + assert.ok(!out.includes("=1")); + assert.ok(!out.includes("=2")); + + const content = await fs.readFile(path.join(worktree, ".env"), "utf8"); + assert.ok(content.indexOf("A=1") < content.indexOf("B=2")); +}); + +test("envsitter_annotate adds comments without leaking values", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env"), "FOO=bar\n"); + + const tools = await getTools({ directory: worktree, worktree }); + const out = await tools.envsitter_annotate.execute({ filePath: ".env", key: "FOO", comment: "prod only", write: true }); + + assert.ok(!out.includes("bar")); + + const content = await fs.readFile(path.join(worktree, ".env"), "utf8"); + assert.ok(content.includes("prod only")); + assert.ok(content.includes("FOO=bar")); +});