diff --git a/AGENTS.md b/AGENTS.md index 3979461..0743048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,40 @@ # AGENTS.md — envsitter-guard -This repository is a minimal TypeScript plugin meant to run under OpenCode (`@opencode-ai/plugin`). +This repository is a minimal TypeScript plugin for OpenCode 1 (`@opencode-ai/plugin`, 1.18.29+) and OpenCode 2 (`@opencode/plugin`, 2.0.32+), served from one default export. ## Repo layout -- `index.ts`: main plugin implementation (`EnvSitterGuard`). -- `tsconfig.json`: TypeScript config (strict, `noEmit`). +- `index.ts`: package entry. Exports only `EnvSitterGuard` = `{ id, setup, server }` (also the default export). +- `lib/tools.ts`: the 15 `envsitter_*` tools, defined once (zod args, JSON Schema for V2, `run(input, env)`). +- `lib/policy.ts`: pure blocking logic (protected paths, patch paths, shell scan, grep redaction, permission denial). +- `lib/scrub.ts`: finds the project's sensitive env files and scrubs secret values from tool output with envsitter's redactor. +- `lib/v1.ts` / `lib/v2.ts`: thin adapters (V1 hooks object; V2 `ctx.tool.transform`/`hook`, `ctx.permission.hook`). +- `lib/help.ts`: `envsitter_help` text. +- `test/`: `*.hook.test.ts` (V1 hooks), `*.tools.test.ts` (tools via V1), `*.v2.test.ts` (V2 setup with a fake ctx), `policy.test.ts`, `scrub.test.ts`. +- `scripts/e2e-opencode.mjs`: `npm run test:e2e`, real OpenCode 1 (`opencode`) and 2 (`opencode2`) CLIs vs a scripted model, isolated `HOME`. +- `.github/workflows/`: `ci.yml` (unit tests + pinned OpenCode e2e), `e2e-latest.yml` (weekly e2e vs newest OpenCode), `release.yml` (tag `vX.Y.Z` -> CI -> npm trusted publishing -> GitHub Release). Keep the `release.yml` name: npm's trusted-publisher config pins it. `origin` is Gitea (`git.boxpositron.dev`), mirrored to GitHub; jobs carry `if: github.server_url == 'https://github.com'` so Gitea's runner skips them. +- `tsconfig.json`: strict, `noEmit` (typecheck). `tsconfig.build.json` emits `dist/` (publish); `tsconfig.test.json` emits `dist-test/`. - `.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). + - `.opencode/plugin/envsitter-guard.ts` re-exports the plugin from `index.ts` (the only tracked file here). + - `.opencode/package.json` and `.opencode/bun.lock` are local-only (ignored by `.opencode/.gitignore`). +- `node_modules/`: npm-installed deps (do not edit); `envsitter` comes from npm, not the sibling checkout. ## Commands (build / lint / test) ### Install -- Root install (lockfile: `package-lock.json`): - - `npm ci` - - `npm install` (ok for local dev; `npm ci` preferred for reproducibility) +- Root install (lockfile: `package-lock.json`): `npm ci` (preferred) or `npm install` -- `.opencode/` install (Bun lockfile present): +- `.opencode/` install (local-only Bun lockfile, untracked): - 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. +Day-to-day work only typechecks. `npm run build` (`tsconfig.build.json` -> `dist/`) produces the published package and runs on `prepack`; `prepublishOnly` runs typecheck, test, then build. -- Typecheck (canonical): - - `npm run typecheck` - - Equivalent: `npx tsc -p tsconfig.json` +- Typecheck (canonical): `npm run typecheck` (= `npx tsc -p tsconfig.json`) ### Lint / format @@ -42,12 +46,12 @@ This repo does not emit JS as part of its normal workflow. Tests use Node’s built-in test runner, compiled via `tsc` into `dist-test/`. -- Run all tests (builds then runs): - - `npm test` -- Build test output only: - - `npm run build:test` +- Run all tests (builds then runs): `npm test` +- Build test output only: `npm run build:test` +- Cross-version e2e (needs the CLIs installed; not part of `npm test`): `npm run test:e2e` - Run a single test file: - - `npm run build:test && node --test dist-test/test/envsitter-guard.hook.test.js` + - `npm run build:test && node --test dist-test/test/envsitter-guard.v2.test.js` +- Until `envsitter` 0.1.0 is published, tests need a local build: `npm pack` in `../envsitter`, then `npm install --no-save ` here. Pre-commit checks are enforced via Husky: - `npm install` installs the git hook. @@ -66,13 +70,13 @@ Pre-commit checks are enforced via Husky: - 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`). +- Keep functions small and single-purpose (see `lib/policy.ts`). ## Naming conventions - `camelCase`: functions, locals, parameters. - `PascalCase`: exported plugin symbol (`EnvSitterGuard`). -- Booleans use `is*` / `has*` prefixes (e.g. `isSensitiveDotEnvPath`). +- Booleans use `is*` / `has*` prefixes (e.g. `isProtectedPath`). ## Error handling & validation @@ -87,42 +91,38 @@ This plugin is security-sensitive. Follow these patterns: - 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. +- The plugin blocks protected env files and the pepper, and points agents to EnvSitter tools (keys, fingerprints, booleans; never values). ## 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) +- Keep behavior consistent with `lib/` patterns: + - normalize paths before matching; decide "protected" only via envsitter's `isSensitiveEnvFile` / `isEnvSitterPepperFile` + - ensure file paths stay within the project root (V1 `worktree`, V2 `ctx.location.project.directory`) + - block silently: throw an actionable `Error`, never show a toast (hook test asserts zero toasts) +- Never add non-plugin exports to `index.ts`: OpenCode 1 throws when a plugin module exports anything else. +- Add or change a tool once in `lib/tools.ts`; both adapters pick it up. Update `lib/help.ts` with it. ## 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`. +- Tool surface (15 tools; every file-taking tool resolves paths via `resolveEnvFilePath` in `lib/tools.ts`): + - Read-only: `envsitter_keys`, `envsitter_fingerprint`, `envsitter_match`, `envsitter_match_by_key`, `envsitter_scan`, `envsitter_validate`. + - Mutating, dry-run unless `write: true`: `envsitter_copy`, `envsitter_format`, `envsitter_reorder`, `envsitter_annotate`, `envsitter_add`, `envsitter_set`, `envsitter_unset`, `envsitter_delete`. + - `envsitter_help`: agent-facing guide; update its topic text whenever a tool changes. + - V2 registers them with `codemode: false` so agents call them by name. +- Blocking (`findBlockedAccess`, used by V1 `tool.execute.before` and V2 `execute.before`): + - Any non-`envsitter_*` tool whose `filePath` / `path` / `file_path` / `paths` args hit a protected file. + - `patchText` envelopes (`patch`, V1.19 `apply_patch`) and unified diffs touching protected files. + - `command` strings naming protected files (best-effort shell scan; envsitter CLI segments allowed). +- `grep` output is redacted after execution and every non-envsitter tool's output is scrubbed of secret values (`lib/scrub.ts`); V2 also denies protected `read`/`edit`/`shell` permission checks. +- Protected = envsitter `isSensitiveEnvFile` (examples/templates stay readable) or `isEnvSitterPepperFile`. ## 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. +- Ensure resolved paths stay within the project root using `path.relative()` checks. ## Output conventions @@ -143,11 +143,7 @@ This plugin is security-sensitive. Follow these patterns: ## Verification checklist -- `npm run typecheck` passes. +- `npm run typecheck` and `npm test` pass (V1 hook/tool tests and V2 tests). - 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/CHANGELOG.md b/CHANGELOG.md index 2de4b00..b0669d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.1.0 (Unreleased) + +### Added + +- OpenCode 2 support. The default export is now `{ id: "envsitter-guard", setup, server }`: OpenCode 2 calls `setup(ctx)`, OpenCode 1 (1.18.29+) calls `server()`. +- OpenCode 2 tools are registered with `ctx.tool.transform` and `codemode: false`, so agents see them directly instead of behind Code Mode. +- OpenCode 2 blocking runs in `ctx.tool.hook("execute.before")` (which also sees calls made inside Code Mode) plus a `ctx.permission.hook("evaluate")` layer that denies `read`, `edit`, and `shell` checks on protected files. +- Blocks `patch` / `apply_patch` calls whose patch touches a protected file. +- Blocks shell commands (`bash` / `shell`) that name a protected file; envsitter CLI invocations stay allowed. +- Hides `grep` matches from protected files (V1 `tool.execute.after`, V2 `execute.after`). +- Blocks any other tool, such as MCP filesystem tools, whose path arguments point at a protected file. +- `npm run test:e2e`: cross-version end-to-end check that drives the real OpenCode 1 and OpenCode 2 CLIs with a scripted model in an isolated home. Verified against OpenCode 1.18.31 + 2.0.32 (the minimums) and 1.19.3 + 2.1.0. +- Release automation: CI on every push and pull request (unit tests on Node 22, 24, and 26 plus the OpenCode 1 + 2 end-to-end check against both version pairs), and tag-driven releases that only publish after that CI passes, using npm trusted publishing with provenance. +- Documentation page at https://boxpositron.github.io/envsitter/guard/. +- Tool output scrubbing: secret-looking values from the project's protected env files are replaced with `[envsitter: KEY redacted]` in the output of every non-EnvSitter tool (OpenCode 1 `tool.execute.after`, OpenCode 2 `execute.after`), so commands that never name `.env` (`grep -r`, `cat *`, log files) no longer hand values to the model. Uses envsitter's redactor; env files are found up to three levels deep and re-read when they change. +- Weekly end-to-end run against the newest OpenCode 1 and 2 releases (`e2e-latest.yml`), and Dependabot for npm dependencies and GitHub Actions. + +### Changed + +- **Breaking:** the named export `EnvSitterGuard` is now the plugin definition object, not a V1 plugin function. Code that called `EnvSitterGuard(input)` directly should call `EnvSitterGuard.server(input)`. Re-exporting it from a local plugin file keeps working. +- OpenCode 1 support now requires 1.18.29 or newer (object-form plugin entrypoint). +- Protected files now come from envsitter's path policy: named env files such as `api.env` and `config.env.local` are protected, and EnvSitter tools accept them. +- Example/template files `.env.sample`, `.env.template`, `.env.dist`, and `.env.default(s)` are now readable, matching envsitter's definition (previously only `.env.example`). +- Tool definitions are shared between OpenCode 1 and 2; V2 receives JSON Schema generated from the same argument schemas, and inputs are validated before execution. +- Bumped `envsitter` dependency to `^0.1.0`. +- Bumped `@opencode-ai/plugin` dependency to `^1.19.3`. + +### Fixed + +- OpenCode 1.19 names its patch tool `apply_patch`; the previous `patch` check never matched it. + ## 0.0.4 ### Added diff --git a/README.md b/README.md index bf55ca2..45001b3 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,50 @@ # envsitter-guard -OpenCode plugin that prevents agents/tools from reading or editing sensitive `.env*` files, while still allowing safe inspection via EnvSitter (keys + deterministic fingerprints; never values). +[![CI](https://github.com/boxpositron/envsitter-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/boxpositron/envsitter-guard/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/envsitter-guard)](https://www.npmjs.com/package/envsitter-guard) + +OpenCode plugin that prevents agents/tools from reading or editing sensitive `.env` files, while still allowing safe inspection via EnvSitter (keys + deterministic fingerprints; never values). + +Docs: https://boxpositron.github.io/envsitter/guard/ + +One package supports both OpenCode majors: + +- **OpenCode 2** (`@opencode/plugin` API, 2.0.32+): loaded through the default export's `id` + `setup()`. +- **OpenCode 1** (1.18.29+): loaded through the same default export's `server()`. + +## Compatibility + +| OpenCode | Minimum | Verified end to end | Entry point | Built-in tools covered | +|----------|---------|---------------------|-------------|------------------------| +| 2.x | 2.0.32 | 2.0.32, 2.1.0 | `setup(ctx)` | `read`, `write`, `edit`, `patch`, `shell`, `grep` | +| 1.x | 1.18.29 | 1.18.31, 1.19.3 | `server()` | `read`, `write`, `edit`, `apply_patch`, `bash`, `grep` | + +"Verified end to end" means `npm run test:e2e` passed against that CLI: the plugin loaded, all 15 `envsitter_*` tools were visible to the model, and every blocking scenario held (see [Development](#development)). ## Quickstart (OpenCode) -OpenCode supports loading plugins from npm or local plugin files. - Reference docs: -- https://opencode.ai/docs/plugins/ -- https://opencode.ai/docs/config/#plugins +- OpenCode 2: https://opencode.ai/v2/docs/plugins +- OpenCode 1: https://opencode.ai/docs/plugins/ -### Option A (recommended): load from npm via `opencode.json` +### Option A (recommended): load from npm -Add the plugin package to your OpenCode config. +OpenCode 2 (`opencode.json` uses `plugins`, plural): -`opencode.json`: +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["envsitter-guard@latest"] +} +``` + +Or install it globally with the OpenCode 2 CLI: + +```bash +opencode plugin add envsitter-guard@latest +``` + +OpenCode 1 (`plugin`, singular; OpenCode 2 also accepts this V1 key): ```json { @@ -23,7 +53,7 @@ Add the plugin package to your OpenCode config. } ``` -Restart OpenCode after updating config. +Restart OpenCode after updating config (OpenCode 2 reloads watched config automatically). ## Why @@ -77,20 +107,29 @@ Notes for file operations: ### Blocking behavior -- Sensitive paths: `.env`, `.env.local`, `.env.production`, etc. (`.env*`) -- Allowed: `.env.example` -- Always blocked: `.envsitter/pepper` +Protected files come from envsitter's path policy (`isSensitiveEnvFile` / `isEnvSitterPepperFile`): -Blocked operations via tool hooks: +- Protected: `.env`, `.env.`, `.env`, `.env.` (e.g. `.env.local`, `api.env`, `config.env.local`) +- Readable: example/template files `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.default(s)` +- Not env files: source files such as `app.env.ts` +- Always protected: `.envsitter/pepper` -- `read` on sensitive `.env*` paths -- `edit` / `write` / `patch` / `multiedit` on sensitive `.env*` paths +Blocked operations: + +- Any non-EnvSitter tool whose path arguments (`filePath`, `path`, `file_path`, `paths`) point at a protected file: `read`, `edit`, `write`, `grep` on a file, MCP filesystem tools, and so on +- `patch` / `apply_patch` when the patch adds, updates, moves, or deletes a protected file +- Shell commands (`bash` / `shell`) that name a protected file, e.g. `cat .env`, `source .env`, `echo X >> .env`, `node --env-file=.env` (envsitter CLI calls such as `npx envsitter keys --file .env` are allowed) +- `grep` results: matches inside protected files are removed from the output +- Secret values in tool output: secret-looking values from the project's protected env files are replaced with `[envsitter: KEY redacted]` in the result of every non-EnvSitter tool (shell, `read` of other files, `grep`, MCP tools). The plugin finds env files up to three directories deep, skipping `node_modules`, `.git`, and build output, and re-reads them when they change. Which values count as secret is decided by envsitter's redactor (see the envsitter README). +- OpenCode 2 only: a permission `evaluate` hook also denies `read`, `edit`, and `shell` permission checks on protected files, including tool calls made from Code Mode When blocked, the plugin throws an error with guidance on which EnvSitter tools to use instead. +Limits: this is a guardrail against accidental reads, not a sandbox. Commands that reach file contents without naming the file (`grep -r TOKEN .`, `cat *`, variables, `eval`) are not blocked; their output is scrubbed instead, which catches values as written but not encoded or transformed ones (base64, split strings) or values that do not look secret. Custom pepper locations are not recognized. + ## Tools -Tools only operate on `.env`-style files inside the current project. +Tools only operate on dotenv files (`.env*`, `*.env`, including examples) inside the current project. - Most tools accept a `filePath` that defaults to `.env`. - File operations are dry-run unless `write: true` is provided. @@ -319,7 +358,7 @@ Example (inside OpenCode): ### Option B: local plugin file (project-level) -If you want a local plugin file in-repo (or need local overrides), create `.opencode/plugin/envsitter-guard.ts`: +If you want a local plugin file in-repo (or need local overrides), create `.opencode/plugins/envsitter-guard.ts` (OpenCode 2) or `.opencode/plugin/envsitter-guard.ts` (OpenCode 1; OpenCode 2 also scans this directory): ```ts import EnvSitterGuard from "envsitter-guard"; @@ -338,11 +377,11 @@ Then create `.opencode/package.json` with the dependency so OpenCode can install } ``` -Restart OpenCode; files in `.opencode/plugin/` are loaded automatically. +Restart OpenCode; files in `.opencode/plugins/` (and `.opencode/plugin/`) are loaded automatically. ### Option C: global plugin file -Place a plugin file in `~/.config/opencode/plugin/` if you want it enabled for all projects. +Place a plugin file in `~/.config/opencode/plugins/` (OpenCode 2) or `~/.config/opencode/plugin/` (OpenCode 1) if you want it enabled for all projects. (You can use the same contents as Option B.) @@ -366,12 +405,60 @@ npm run typecheck npm test ``` +### End-to-end check against OpenCode 1 and 2 + +```bash +npm run test:e2e +``` + +`scripts/e2e-opencode.mjs` builds the plugin, then runs the real `opencode` (1.x) and `opencode2` (2.x) CLIs against a scripted OpenAI-compatible model served on `127.0.0.1`. For each version it checks what the model gets back from each tool: + +- `envsitter_keys` lists keys without values, and all 15 `envsitter_*` tools are offered directly +- `read` of `.env` is blocked, `read` of `.env.example` is allowed +- `write` to `.env` and a patch adding `.env.local` are blocked, and the files stay untouched +- `cat .env` through the shell tool is blocked +- `cat logs/app.log` through the shell tool and `read` of that log return the token in it as `[envsitter: API_TOKEN redacted]` +- `grep` results omit matches inside `.env` + +Each version runs in its own temporary `HOME` with fake secrets, so your real config, credentials, and session database are never read or modified, and no paid model is called. A version whose CLI is missing is skipped. CI runs the check against the minimum and latest-tested versions on every push, and `.github/workflows/e2e-latest.yml` runs it weekly against the newest `opencode-ai` and `@opencode/cli` releases. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `OPENCODE_V1_BIN` | `opencode` | OpenCode 1 CLI to test | +| `OPENCODE_V2_BIN` | `opencode2` | OpenCode 2 CLI to test | +| `KEEP_E2E` | unset | `1` keeps the temporary projects for inspection | +| `E2E_REQUIRE_ALL` | unset | `1` fails instead of skipping when a CLI is missing (CI sets it) | + +### Running OpenCode 2 next to OpenCode 1 + +The OpenCode 2 curl installer replaces the OpenCode 1 binary. To keep both, install OpenCode 2 from npm into its own prefix; the package also ships an `opencode2` command: + +```bash +npm install -g --prefix ~/.local/opt/opencode2 @opencode/cli +~/.local/opt/opencode2/bin/opencode2 --version +``` + +OpenCode 2 reads the same config and data directories as OpenCode 1 and, by default, the same `opencode.db`, which it migrates in place. Give it its own database unless you mean to migrate: + +```bash +OPENCODE_DB=opencode-v2.db ~/.local/opt/opencode2/bin/opencode2 +``` + ### Build (for publishing) ```bash npm run build ``` +### Releasing + +Releases are published by GitHub Actions with npm trusted publishing, so no npm token is stored anywhere and every version ships with a provenance attestation. + +1. Release `envsitter` first when this version depends on a new one, then run `npm install` here so `package-lock.json` picks it up. +2. Bump `version` in `package.json` and replace `(Unreleased)` in the matching `CHANGELOG.md` heading with the release date. +3. Commit, then push a matching tag: `git tag v0.1.0 && git push origin main v0.1.0`. +4. The Release workflow reruns CI, including the OpenCode 1 + 2 end-to-end check (pinned in `.github/workflows/ci.yml`), checks that the tag, `package.json`, and the CHANGELOG agree, publishes to npm (prereleases go to the `next` dist-tag), and creates the GitHub Release from the CHANGELOG section. + ## Related - [envsitter](https://github.com/boxpositron/envsitter) — The underlying library this plugin is built on. Provides CLI and programmatic API for safe `.env` inspection.