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
This commit is contained in:
David Ibia
2026-01-15 23:01:58 +01:00
parent 093258641f
commit 17e37f2f76
6 changed files with 490 additions and 37 deletions

View File

@@ -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

102
README.md
View File

@@ -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 keys 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

379
index.ts
View File

@@ -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<void> {
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<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);
@@ -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."
);
}
},
};

8
package-lock.json generated
View File

@@ -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"

View File

@@ -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": "*",

View File

@@ -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");
});