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:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,6 +1,20 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.3
|
||||||
|
|
||||||
|
|||||||
102
README.md
102
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.
|
`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
|
## What it does
|
||||||
|
|
||||||
This plugin provides safe EnvSitter-backed tools and blocks sensitive file access via OpenCode tool hooks.
|
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:
|
These tools never return raw `.env` values:
|
||||||
|
|
||||||
|
**Reading:**
|
||||||
- `envsitter_keys`: list keys in a dotenv file
|
- `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`: boolean/shape checks and outside-in candidate matching (without printing values)
|
||||||
- `envsitter_match_by_key`: bulk candidate-by-key matching (returns booleans only)
|
- `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_validate`: validate dotenv syntax (no values; issues only)
|
||||||
- `envsitter_copy`: copy keys between env files (no values; plan + line numbers 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_format` / `envsitter_reorder`: reorder/format env files (no values)
|
||||||
- `envsitter_annotate`: add comments near keys (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:
|
Notes for file operations:
|
||||||
|
|
||||||
- File operations are dry-run unless `write: true` is provided.
|
- File operations are dry-run unless `write: true` is provided.
|
||||||
@@ -70,7 +86,7 @@ Blocked operations via tool hooks:
|
|||||||
- `read` on sensitive `.env*` paths
|
- `read` on sensitive `.env*` paths
|
||||||
- `edit` / `write` / `patch` / `multiedit` 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
|
## Tools
|
||||||
|
|
||||||
@@ -226,6 +242,79 @@ Example (inside OpenCode):
|
|||||||
{ "tool": "envsitter_annotate", "args": { "filePath": ".env", "key": "DATABASE_URL", "comment": "prod only", "write": true } }
|
{ "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)
|
## Install & enable in OpenCode (alternatives)
|
||||||
|
|
||||||
### Option B: local plugin file (project-level)
|
### Option B: local plugin file (project-level)
|
||||||
@@ -283,10 +372,15 @@ npm test
|
|||||||
npm run build
|
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
|
## Notes
|
||||||
|
|
||||||
- This project intentionally avoids reading or printing `.env` values.
|
- 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
|
## License
|
||||||
|
|
||||||
|
|||||||
379
index.ts
379
index.ts
@@ -1,6 +1,16 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin } from "@opencode-ai/plugin";
|
||||||
import { tool } from "@opencode-ai/plugin/tool";
|
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";
|
import path from "node:path";
|
||||||
|
|
||||||
function normalizePath(input: string): string {
|
function normalizePath(input: string): string {
|
||||||
@@ -110,9 +120,7 @@ function resolveDotEnvPath(params: {
|
|||||||
return { absolutePath, displayPath: relativeToWorktree };
|
return { absolutePath, displayPath: relativeToWorktree };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) => {
|
export const EnvSitterGuard: Plugin = async ({ directory, worktree }) => {
|
||||||
let lastToastAt = 0;
|
|
||||||
|
|
||||||
const matchOps = [
|
const matchOps = [
|
||||||
"exists",
|
"exists",
|
||||||
"is_empty",
|
"is_empty",
|
||||||
@@ -127,22 +135,6 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) =>
|
|||||||
|
|
||||||
const scanDetections = ["jwt", "url", "base64"] as const;
|
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 {
|
return {
|
||||||
tool: {
|
tool: {
|
||||||
envsitter_keys: 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) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
const filePath = getFilePathFromArgs(output.args);
|
const filePath = getFilePathFromArgs(output.args);
|
||||||
@@ -568,15 +895,19 @@ export const EnvSitterGuard: Plugin = async ({ client, directory, worktree }) =>
|
|||||||
if (!isSensitiveDotEnvPath(filePath) && !isEnvSitterPepperPath(filePath)) return;
|
if (!isSensitiveDotEnvPath(filePath) && !isEnvSitterPepperPath(filePath)) return;
|
||||||
|
|
||||||
if (input.tool === "read") {
|
if (input.tool === "read") {
|
||||||
await notifyBlocked("Reading");
|
|
||||||
throw new Error(
|
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") {
|
if (input.tool === "edit" || input.tool === "write" || input.tool === "patch" || input.tool === "multiedit") {
|
||||||
await notifyBlocked("Editing");
|
throw new Error(
|
||||||
throw new Error("Editing `.env*` and `.envsitter/pepper` via tools is blocked.");
|
"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
8
package-lock.json
generated
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.1.14",
|
"@opencode-ai/plugin": "^1.1.14",
|
||||||
"envsitter": "^0.0.3"
|
"envsitter": "^0.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/envsitter": {
|
"node_modules/envsitter": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.4.tgz",
|
||||||
"integrity": "sha512-l7YuX/ptwODY0ooU5JuPLN5zrNpjoWU5O01Wi66t24FRYLrXK33ReEr3/TP4Fhaa+S4sQ7uBMLIe8VGmgvIs9g==",
|
"integrity": "sha512-Uxy5XpYQ9B6+70cQrIOKQMxaitKjMDxaK1uPaivhtVbYxQMRhJzdvx+6AgSrRrZBcnuPlLbnVPXrn7PXpi+veg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"envsitter": "dist/cli.js"
|
"envsitter": "dist/cli.js"
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,8 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter-guard",
|
"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.",
|
"description": "OpenCode plugin that prevents agents/tools from reading or editing sensitive .env* files, while still allowing safe inspection via EnvSitter.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"opencode",
|
||||||
|
"opencode-plugin",
|
||||||
|
"dotenv",
|
||||||
|
"env",
|
||||||
|
"secrets",
|
||||||
|
"security",
|
||||||
|
"guard",
|
||||||
|
"agent",
|
||||||
|
"llm",
|
||||||
|
"ai",
|
||||||
|
"mcp",
|
||||||
|
"envsitter"
|
||||||
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -31,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.1.14",
|
"@opencode-ai/plugin": "^1.1.14",
|
||||||
"envsitter": "^0.0.3"
|
"envsitter": "^0.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
|
|||||||
@@ -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 worktree = await createTmpDir();
|
||||||
const { hook, calls } = await getBeforeHook({ directory: worktree, worktree });
|
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" } }));
|
||||||
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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user