commit 3a56870ff541ad5c897fe10e0745451a44287db0 Author: David Ibia Date: Mon Jan 12 13:20:27 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..657711b --- /dev/null +++ b/.gitignore @@ -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* diff --git a/.opencode/plugin/envsitter-guard.ts b/.opencode/plugin/envsitter-guard.ts new file mode 100644 index 0000000..9aaf3b3 --- /dev/null +++ b/.opencode/plugin/envsitter-guard.ts @@ -0,0 +1,4 @@ +import EnvSitterGuard from "../../index"; + +export default EnvSitterGuard; +export { EnvSitterGuard } from "../../index"; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a94c306 --- /dev/null +++ b/AGENTS.md @@ -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 Node’s 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`). + +## Security & secrets + +- Do not read or print `.env` values. +- Treat `.env.secure` as sensitive; never commit secrets. +- This repo’s 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. diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ad27605 --- /dev/null +++ b/index.ts @@ -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; + + const candidates: Array = [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 { + 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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..55afa57 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b927ff9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ab9f4a --- /dev/null +++ b/tsconfig.json @@ -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"] +}