Initial commit

This commit is contained in:
David Ibia
2026-01-12 13:20:27 +01:00
commit 3a56870ff5
7 changed files with 440 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# dependencies
node_modules/
.opencode/node_modules/
# secrets / local env
.env*
!.env.example
# misc
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

View File

@@ -0,0 +1,4 @@
import EnvSitterGuard from "../../index";
export default EnvSitterGuard;
export { EnvSitterGuard } from "../../index";

152
AGENTS.md Normal file
View File

@@ -0,0 +1,152 @@
# AGENTS.md — envsitter-guard
This repository is a minimal TypeScript plugin meant to run under OpenCode (`@opencode-ai/plugin`).
## Repo layout
- `index.ts`: main plugin implementation (`EnvSitterGuard`).
- `tsconfig.json`: TypeScript config (strict, `noEmit`).
- `.opencode/`: OpenCode packaging/runtime wrapper.
- `.opencode/plugin/envsitter-guard.ts` re-exports the plugin from `index.ts`.
- `.opencode/bun.lock` indicates Bun-managed deps inside `.opencode/`.
- `node_modules/`: vendored deps (present locally; do not edit).
## Commands (build / lint / test)
### Install
- Root install (lockfile: `package-lock.json`):
- `npm ci`
- `npm install` (ok for local dev; `npm ci` preferred for reproducibility)
- `.opencode/` install (Bun lockfile present):
- If you need to refresh `.opencode/node_modules`, use Bun from `.opencode/`:
- `cd .opencode && bun install`
- If Bun is unavailable, do not guess; ask before changing `.opencode/` dependency management.
### Build / typecheck
This repo does not emit JS as part of its normal workflow.
- Typecheck (canonical):
- `npm run typecheck`
- Equivalent: `npx tsc -p tsconfig.json`
### Lint / format
- No linting or formatting commands are configured in the root project.
- No `.eslintrc*`, `eslint.config.*`, `biome.json`, or Prettier config exists at repo root.
- Do not introduce new linters/formatters unless explicitly requested.
### Tests
- No test runner/config is configured in the root project (`package.json` has no `test` script).
If/when tests are added, prefer Nodes built-in test runner (matches existing ecosystem usage):
- Run all tests:
- `node --test`
- Run a single test file:
- `node --test path/to/some.test.js`
- Run a single test by name/pattern:
- `node --test --test-name-pattern "pattern" path/to/some.test.js`
Note: This repo is TypeScript-only and `tsconfig.json` uses `noEmit: true`. If you add tests, ensure the test runner can execute them (either compile first, or use a TS-capable runner) and document the chosen approach.
## TypeScript / module conventions
- ESM project: `package.json` has `"type": "module"`.
- TS config: `module: "NodeNext"`, `moduleResolution: "NodeNext"`, `target: "ES2022"`, `strict: true`, `noEmit: true`.
- Prefer `import type { ... }` for type-only imports (see `index.ts`).
- Prefer `node:` specifier for Node built-ins (e.g. `import path from "node:path"`).
## Formatting conventions (match existing code)
- Indentation: 4 spaces.
- Quotes: double quotes.
- Semicolons: required.
- Trailing commas: used in multiline objects/args.
- Keep functions small and single-purpose (see path validation helpers in `index.ts`).
## Naming conventions
- `camelCase`: functions, locals, parameters.
- `PascalCase`: exported plugin symbol (`EnvSitterGuard`).
- Booleans use `is*` / `has*` prefixes (e.g. `isSensitiveDotEnvPath`).
## Error handling & validation
This plugin is security-sensitive. Follow these patterns:
- Prefer early validation + `throw new Error("...")` with a clear, user-facing message.
- Avoid swallowing errors (no empty `catch` blocks).
- Avoid `any` and type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`).
- When parsing unknown inputs, narrow types explicitly (e.g. `typeof args === "object"`, then access via `Record<string, unknown>`).
## Security & secrets
- Do not read or print `.env` values.
- Treat `.env.secure` as sensitive; never commit secrets.
- This repos plugin is designed to block sensitive `.env*` and `.envsitter/pepper` access via tooling and to guide users toward safe inspection:
- `envsitter_keys` (lists keys only)
- `envsitter_fingerprint` (hash/fingerprint only)
## Working with `.opencode/`
- `.opencode/` is a packaging/runtime layer for OpenCode plugins; treat it as part of the deployment surface.
- Keep `.opencode/plugin/envsitter-guard.ts` as a thin re-export (do not add logic there).
- Avoid editing `.opencode/node_modules` directly.
## Change discipline
- Prefer minimal diffs; avoid refactors during bugfixes.
- Keep behavior consistent with `index.ts` patterns:
- normalize paths before regex matching
- ensure file paths stay within `worktree`
- throttle UI toasts (`lastToastAt` pattern)
## Plugin behavior notes
- OpenCode entrypoint is `EnvSitterGuard` exported from `index.ts`.
- Plugin registration file `.opencode/plugin/envsitter-guard.ts` must remain a thin re-export.
- Tool surface area intentionally small:
- `envsitter_keys`: lists keys only (no values)
- `envsitter_fingerprint`: deterministic fingerprint of a single key (no value)
- Blocking behavior is enforced in `"tool.execute.before"`:
- Blocks reads of `.env*` (except `.env.example`).
- Blocks edits/writes/patching of `.env*` and `.envsitter/pepper`.
- Throttles UI toasts via `lastToastAt`.
## Path handling rules
- Treat all incoming tool args as untrusted.
- Normalize path separators before matching (Windows `\\``/`).
- Validate allowed paths before resolving to absolute paths.
- Ensure resolved paths stay within `worktree` using `path.relative()` checks.
## Output conventions
- Prefer returning structured JSON via `JSON.stringify(obj, null, 2)`.
- Never include secret values in output strings, prompts, or toast messages.
- Errors should be user-facing and actionable (what was blocked + safe alternative).
## Dependency & change policy
- Keep this repo minimalist: prefer existing dependencies over adding new ones.
- Do not change `.opencode/` dependency management unless you know the OpenCode packaging constraints.
- Never edit vendored `node_modules/` or `.opencode/node_modules/`.
## Repo hygiene
- When searching the codebase, exclude `node_modules/` and `.opencode/node_modules/` unless you are explicitly auditing dependency behavior.
- Do not add new config files (ESLint/Prettier/Biome/etc.) unless explicitly requested.
## Verification checklist
- `npm run typecheck` passes.
- No tool path allows reading raw `.env*` values.
- `.opencode/plugin/envsitter-guard.ts` remains a re-export only.
- Error messages remain clear and do not leak file contents.
## Cursor / Copilot rules
- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` were found in this repository.

156
index.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin/tool";
import { EnvSitter } 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 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 ({ client, directory, worktree }) => {
let lastToastAt = 0;
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 file 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" +
"(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",
},
});
}
return {
tool: {
envsitter_keys: tool({
description: "List keys in a .env file (never returns values).",
args: {
filePath: 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();
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);
},
}),
},
"tool.execute.before": async (input, output) => {
const filePath = getFilePathFromArgs(output.args);
if (!filePath) return;
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)."
);
}
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.");
}
},
};
};
export default EnvSitterGuard;

86
package-lock.json generated Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "envsitter-guard",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "envsitter-guard",
"dependencies": {
"envsitter": "*"
},
"devDependencies": {
"@opencode-ai/plugin": "*",
"@types/node": "*",
"typescript": "*"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.14.tgz",
"integrity": "sha512-tfF4bEjeF7Gm0W0ViQUhzy77AaZfRxQ/kcPa7/Bc/YM9HddzjEqz0wOJ6ePG8UdUYc0dkKSJOJVhapUbAn/tOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.1.14",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.14.tgz",
"integrity": "sha512-PJFu2QPxnOk0VZzlPm+IxhD1wSA41PJyCG6gkxAMI767gfAO96A0ukJJN7VK/gO6MbxLF5oTFaxBX5rAGcBRVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz",
"integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/envsitter": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/envsitter/-/envsitter-0.0.1.tgz",
"integrity": "sha512-gDJ/ZMjD0z31MSpj88IiaHNdJVfzWpXANm4uLwUD1ScKtt3LaKZYir2nY+peJOBjZM9579d5y+rTIQD+kU4lIA==",
"license": "MIT",
"bin": {
"envsitter": "dist/cli.js"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "envsitter-guard",
"private": true,
"type": "module",
"devDependencies": {
"@opencode-ai/plugin": "*",
"@types/node": "*",
"typescript": "*"
},
"dependencies": {
"envsitter": "*"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json"
}
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["index.ts", ".opencode/plugin/**/*.ts"]
}