From 17e37f2f76ea2c3fcc793a9082e06ab01f6f2e82 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Thu, 15 Jan 2026 23:01:58 +0100 Subject: [PATCH] feat: add mutation tools and help command for v0.0.4 - Add envsitter_add, envsitter_set, envsitter_unset, envsitter_delete tools - Add envsitter_help tool with comprehensive usage guide for agents - Change blocking to silent mode (error messages only, no toasts) - Bump envsitter dependency to ^0.0.4 - Update README with new tools and envsitter library reference - Update CHANGELOG for v0.0.4 --- CHANGELOG.md | 16 +- README.md | 102 +++++++- index.ts | 379 ++++++++++++++++++++++++++++-- package-lock.json | 8 +- package.json | 18 +- test/envsitter-guard.hook.test.ts | 4 +- 6 files changed, 490 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d39536..2de4b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## Unreleased +## 0.0.4 + +### Added + +- `envsitter_add`: add a new key to a dotenv file (fails if key exists; dry-run unless `write: true`). +- `envsitter_set`: set a key's value (creates or updates; dry-run unless `write: true`). +- `envsitter_unset`: unset a key's value to empty string (keeps the key; dry-run unless `write: true`). +- `envsitter_delete`: delete key(s) from a dotenv file entirely (dry-run unless `write: true`). +- `envsitter_help`: comprehensive help tool explaining all EnvSitter tools to agents. Supports topics: `overview`, `reading`, `matching`, `mutations`, `file_ops`, `all`. + +### Changed + +- Blocking behavior is now silent (error message only, no toast notifications). +- Improved error messages to reference `envsitter_help` for guidance. +- Bumped `envsitter` dependency to `^0.0.4`. ## 0.0.3 diff --git a/README.md b/README.md index cc9b669..bf55ca2 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Accidentally printing `.env` contents is one of the easiest ways for an agentic `envsitter-guard` blocks risky operations and points you to safe alternatives. +This plugin is built on top of [envsitter](https://github.com/boxpositron/envsitter), a library for safely inspecting and matching `.env` secrets without ever printing values. + ## What it does This plugin provides safe EnvSitter-backed tools and blocks sensitive file access via OpenCode tool hooks. @@ -44,16 +46,30 @@ This plugin provides safe EnvSitter-backed tools and blocks sensitive file acces These tools never return raw `.env` values: +**Reading:** - `envsitter_keys`: list keys in a dotenv file -- `envsitter_fingerprint`: deterministic fingerprint of a single key’s value +- `envsitter_fingerprint`: deterministic fingerprint of a single key's value +- `envsitter_scan`: scan value *shapes* (jwt/url/base64) without printing values + +**Matching:** - `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 + +**Mutations:** +- `envsitter_add`: add a new key (fails if key exists) +- `envsitter_set`: set a key's value (creates or updates) +- `envsitter_unset`: unset a key's value (sets to empty, keeps the key) +- `envsitter_delete`: delete key(s) entirely from the file + +**File Operations:** - `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) +**Help:** +- `envsitter_help`: comprehensive usage guide for all tools (topics: `overview`, `reading`, `matching`, `mutations`, `file_ops`, `all`) + Notes for file operations: - File operations are dry-run unless `write: true` is provided. @@ -70,7 +86,7 @@ Blocked operations via tool hooks: - `read` on sensitive `.env*` paths - `edit` / `write` / `patch` / `multiedit` on sensitive `.env*` paths -When blocked, the plugin shows a throttled warning toast and suggests using EnvSitter tools instead. +When blocked, the plugin throws an error with guidance on which EnvSitter tools to use instead. ## Tools @@ -226,6 +242,79 @@ Example (inside OpenCode): { "tool": "envsitter_annotate", "args": { "filePath": ".env", "key": "DATABASE_URL", "comment": "prod only", "write": true } } ``` +### `envsitter_add` + +Add a new key to a dotenv file (fails if key already exists). + +- Input: `{ "filePath"?: string, "key": string, "value": string, "write"?: boolean }` +- Output: JSON `{ file, key, willWrite, wrote, hasChanges, issues, plan }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_add", "args": { "filePath": ".env", "key": "NEW_API_KEY", "value": "sk-xxx", "write": true } } +``` + +### `envsitter_set` + +Set a key's value in a dotenv file (creates if missing, updates if exists). + +- Input: `{ "filePath"?: string, "key": string, "value": string, "write"?: boolean }` +- Output: JSON `{ file, key, willWrite, wrote, hasChanges, issues, plan }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_set", "args": { "filePath": ".env", "key": "API_KEY", "value": "new-value", "write": true } } +``` + +### `envsitter_unset` + +Unset a key's value (sets to empty string, keeps the key line). + +- Input: `{ "filePath"?: string, "key": string, "write"?: boolean }` +- Output: JSON `{ file, key, willWrite, wrote, hasChanges, issues, plan }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_unset", "args": { "filePath": ".env", "key": "OLD_KEY", "write": true } } +``` + +### `envsitter_delete` + +Delete key(s) from a dotenv file entirely (removes the line). + +- Input: `{ "filePath"?: string, "keys": string[], "write"?: boolean }` +- Output: JSON `{ file, keys, willWrite, wrote, hasChanges, issues, plan }` + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_delete", "args": { "filePath": ".env", "keys": ["OLD_KEY", "UNUSED_KEY"], "write": true } } +``` + +### `envsitter_help` + +Get comprehensive help on all EnvSitter tools. + +- Input: `{ "topic"?: "overview" | "reading" | "matching" | "mutations" | "file_ops" | "all" }` +- Output: Markdown documentation for the requested topic + +Topics: +- `overview`: What EnvSitter is and tool categories +- `reading`: `envsitter_keys`, `envsitter_fingerprint`, `envsitter_scan` +- `matching`: `envsitter_match`, `envsitter_match_by_key` with all operators +- `mutations`: `envsitter_add`, `envsitter_set`, `envsitter_unset`, `envsitter_delete` +- `file_ops`: `envsitter_validate`, `envsitter_copy`, `envsitter_format`, `envsitter_annotate` +- `all`: Full guide (default) + +Example (inside OpenCode): + +```json +{ "tool": "envsitter_help", "args": { "topic": "mutations" } } +``` + ## Install & enable in OpenCode (alternatives) ### Option B: local plugin file (project-level) @@ -283,10 +372,15 @@ npm test npm run build ``` +## Related + +- [envsitter](https://github.com/boxpositron/envsitter) — The underlying library this plugin is built on. Provides CLI and programmatic API for safe `.env` inspection. +- EnvSitter CLI: `npx envsitter keys --file .env` (alternative to plugin tools) + ## Notes - This project intentionally avoids reading or printing `.env` values. -- EnvSitter CLI exists as an additional safe inspection option, for example: `npx envsitter keys --file .env`. +- All tools return keys, booleans, line numbers, and operation plans — never secret values. ## License diff --git a/index.ts b/index.ts index 776ed11..de27be9 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,16 @@ import type { Plugin } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin/tool"; -import { EnvSitter, annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from "envsitter"; +import { + EnvSitter, + addEnvFileKey, + annotateEnvFile, + copyEnvFileKeys, + deleteEnvFileKeys, + formatEnvFile, + setEnvFileKey, + unsetEnvFileKey, + validateEnvFile, +} from "envsitter"; import path from "node:path"; function normalizePath(input: string): string { @@ -110,9 +120,7 @@ function resolveDotEnvPath(params: { return { absolutePath, displayPath: relativeToWorktree }; } -export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => { - let lastToastAt = 0; - +export const EnvSitterGuard: Plugin = async ({ directory, worktree }) => { const matchOps = [ "exists", "is_empty", @@ -127,22 +135,6 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => const scanDetections = ["jwt", "url", "base64"] as const; - async function notifyBlocked(action: string): Promise { - const now = Date.now(); - if (now - lastToastAt < 5000) return; - lastToastAt = now; - - await client.tui.showToast({ - body: { - title: "Blocked sensitive .env access", - variant: "warning", - message: - `${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.", - }, - }); - } - return { tool: { envsitter_keys: tool({ @@ -560,6 +552,341 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => ); }, }), + 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 = { + 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); @@ -568,15 +895,19 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => if (!isSensitiveDotEnvPath(filePath) && !isEnvSitterPepperPath(filePath)) return; if (input.tool === "read") { - await notifyBlocked("Reading"); throw new Error( - "Reading `.env*` is blocked. Use EnvSitter tools instead: envsitter_keys / envsitter_fingerprint (never prints values)." + "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") { - await notifyBlocked("Editing"); - throw new Error("Editing `.env*` and `.envsitter/pepper` via tools is blocked."); + 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." + ); } }, }; diff --git a/package-lock.json b/package-lock.json index 6cbaa46..97c69be 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.3" + "envsitter": "^0.0.4" }, "devDependencies": { "@types/node": "*", @@ -45,9 +45,9 @@ } }, "node_modules/envsitter": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.3.tgz", - "integrity": "sha512-l7YuX/ptwODY0ooU5JuPLN5zrNpjoWU5O01Wi66t24FRYLrXK33ReEr3/TP4Fhaa+S4sQ7uBMLIe8VGmgvIs9g==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.4.tgz", + "integrity": "sha512-Uxy5XpYQ9B6+70cQrIOKQMxaitKjMDxaK1uPaivhtVbYxQMRhJzdvx+6AgSrRrZBcnuPlLbnVPXrn7PXpi+veg==", "license": "MIT", "bin": { "envsitter": "dist/cli.js" diff --git a/package.json b/package.json index ed08976..1beab06 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,22 @@ { "name": "envsitter-guard", - "version": "0.0.3", + "version": "0.0.4", "description": "OpenCode plugin that prevents agents/tools from reading or editing sensitive .env* files, while still allowing safe inspection via EnvSitter.", "license": "MIT", + "keywords": [ + "opencode", + "opencode-plugin", + "dotenv", + "env", + "secrets", + "security", + "guard", + "agent", + "llm", + "ai", + "mcp", + "envsitter" + ], "type": "module", "repository": { "type": "git", @@ -31,7 +45,7 @@ }, "dependencies": { "@opencode-ai/plugin": "^1.1.14", - "envsitter": "^0.0.3" + "envsitter": "^0.0.4" }, "devDependencies": { "@types/node": "*", diff --git a/test/envsitter-guard.hook.test.ts b/test/envsitter-guard.hook.test.ts index 2b2d2f0..0621aec 100644 --- a/test/envsitter-guard.hook.test.ts +++ b/test/envsitter-guard.hook.test.ts @@ -105,12 +105,12 @@ test("strips @ prefix in filePath", async () => { ); }); -test("toasts are throttled", async () => { +test("blocking is silent (no toasts)", async () => { const worktree = await createTmpDir(); const { hook, calls } = await getBeforeHook({ directory: worktree, worktree }); await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } })); await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } })); - assert.equal(calls.showToast, 1); + assert.equal(calls.showToast, 0, "should not show toasts, only throw errors"); });