Compare commits
5 Commits
17e37f2f76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e8b5c9689 | |||
| 7c8e1251a3 | |||
| 5325e83ac1 | |||
| 0595794385 | |||
| 2a53fe2d9c |
@@ -0,0 +1,30 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
opencode-plugin-sdks:
|
||||||
|
patterns: ["@opencode-ai/plugin", "@opencode/plugin"]
|
||||||
|
dev-dependencies:
|
||||||
|
dependency-type: development
|
||||||
|
ignore:
|
||||||
|
# TypeScript 7 ships no tsserver.js, which editors and typescript-language-server still need.
|
||||||
|
- dependency-name: typescript
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
|
# @types/node tracks the oldest Node version CI tests (22).
|
||||||
|
- dependency-name: "@types/node"
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns: ["*"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Prints the CHANGELOG.md section for a version (used as GitHub Release notes).
|
||||||
|
// Fails when the section is missing or still marked "Unreleased", so a release cannot ship undated notes.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const version = process.argv[2];
|
||||||
|
if (!version) {
|
||||||
|
console.error("Usage: changelog-section.mjs <version>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = readFileSync(new URL("../../CHANGELOG.md", import.meta.url), "utf8").split("\n");
|
||||||
|
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const start = lines.findIndex((line) => new RegExp(`^## ${escaped}(\\s|$)`).test(line));
|
||||||
|
|
||||||
|
if (start === -1) {
|
||||||
|
console.error(`CHANGELOG.md has no "## ${version}" section.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (/unreleased/i.test(lines[start] ?? "")) {
|
||||||
|
console.error(`CHANGELOG.md section "${lines[start]}" is still marked Unreleased; replace it with the release date.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = lines.findIndex((line, index) => index > start && line.startsWith("## "));
|
||||||
|
const body = lines.slice(start + 1, end === -1 ? undefined : end).join("\n").trim();
|
||||||
|
process.stdout.write(`${body}\n`);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test (Node ${{ matrix.node }})
|
||||||
|
# GitHub only: the Gitea origin also runs .github/workflows.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node: [22, 24, 26]
|
||||||
|
env:
|
||||||
|
HUSKY: "0"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
- name: Check package contents
|
||||||
|
run: npm pack --dry-run
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E (OpenCode ${{ matrix.opencode1 }} + ${{ matrix.opencode2 }})
|
||||||
|
# GitHub only: the Gitea origin also runs .github/workflows.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- opencode1: 1.18.31
|
||||||
|
opencode2: 2.0.32
|
||||||
|
- opencode1: 1.19.3
|
||||||
|
opencode2: 2.1.0
|
||||||
|
env:
|
||||||
|
HUSKY: "0"
|
||||||
|
E2E_REQUIRE_ALL: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Install OpenCode 1 and OpenCode 2 side by side
|
||||||
|
run: |
|
||||||
|
npm install -g --prefix "$RUNNER_TEMP/opencode1" "opencode-ai@${{ matrix.opencode1 }}"
|
||||||
|
npm install -g --prefix "$RUNNER_TEMP/opencode2" "@opencode/cli@${{ matrix.opencode2 }}"
|
||||||
|
echo "OPENCODE_V1_BIN=$RUNNER_TEMP/opencode1/bin/opencode" >> "$GITHUB_ENV"
|
||||||
|
echo "OPENCODE_V2_BIN=$RUNNER_TEMP/opencode2/bin/opencode2" >> "$GITHUB_ENV"
|
||||||
|
- run: npm run test:e2e
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: E2E (latest OpenCode)
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 6 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: E2E (latest OpenCode 1 + 2)
|
||||||
|
# GitHub only: the Gitea origin also runs .github/workflows.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
HUSKY: "0"
|
||||||
|
E2E_REQUIRE_ALL: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Install the latest OpenCode 1 and OpenCode 2 side by side
|
||||||
|
run: |
|
||||||
|
npm install -g --prefix "$RUNNER_TEMP/opencode1" "opencode-ai@latest"
|
||||||
|
npm install -g --prefix "$RUNNER_TEMP/opencode2" "@opencode/cli@latest"
|
||||||
|
echo "OPENCODE_V1_BIN=$RUNNER_TEMP/opencode1/bin/opencode" >> "$GITHUB_ENV"
|
||||||
|
echo "OPENCODE_V2_BIN=$RUNNER_TEMP/opencode2/bin/opencode2" >> "$GITHUB_ENV"
|
||||||
|
- run: npm run test:e2e
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Push a vX.Y.Z tag after bumping package.json and dating the CHANGELOG section.
|
||||||
|
# The Verify job reruns CI, including the OpenCode 1 + 2 end-to-end check, before anything is published.
|
||||||
|
# Publishing uses npm trusted publishing (OIDC): no npm token is stored in this repository.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Verify
|
||||||
|
# GitHub only: npm trusted publishing needs GitHub's OIDC token; later jobs skip with this one.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
uses: ./.github/workflows/ci.yml
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to npm
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: npm
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
env:
|
||||||
|
HUSKY: "0"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
package-manager-cache: false
|
||||||
|
- name: Use npm 12 (trusted publishing needs 11.5.1 or later)
|
||||||
|
run: npm install -g npm@12
|
||||||
|
- name: Check tag, package.json, and CHANGELOG agree
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
package_version="$(node -p "require('./package.json').version")"
|
||||||
|
if [ "$version" != "$package_version" ]; then
|
||||||
|
echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $package_version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node .github/scripts/changelog-section.mjs "$version" > /dev/null
|
||||||
|
- run: npm ci
|
||||||
|
- name: Publish
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
if [[ "$version" == *-* ]]; then
|
||||||
|
npm publish --access public --tag next
|
||||||
|
else
|
||||||
|
npm publish --access public
|
||||||
|
fi
|
||||||
|
|
||||||
|
github-release:
|
||||||
|
name: GitHub Release
|
||||||
|
needs: publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- name: Create release from CHANGELOG
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
node .github/scripts/changelog-section.mjs "$version" > release-notes.md
|
||||||
|
prerelease=""
|
||||||
|
if [[ "$version" == *-* ]]; then prerelease="--prerelease"; fi
|
||||||
|
gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md $prerelease
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import EnvSitterGuard from "../../index";
|
import EnvSitterGuard from "../../index.js";
|
||||||
|
|
||||||
export default EnvSitterGuard;
|
export default EnvSitterGuard;
|
||||||
export { EnvSitterGuard } from "../../index";
|
export { EnvSitterGuard } from "../../index.js";
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
# AGENTS.md — envsitter-guard
|
# 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
|
## Repo layout
|
||||||
|
|
||||||
- `index.ts`: main plugin implementation (`EnvSitterGuard`).
|
- `index.ts`: package entry. Exports only `EnvSitterGuard` = `{ id, setup, server }` (also the default export).
|
||||||
- `tsconfig.json`: TypeScript config (strict, `noEmit`).
|
- `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/`: OpenCode packaging/runtime wrapper.
|
||||||
- `.opencode/plugin/envsitter-guard.ts` re-exports the plugin from `index.ts`.
|
- `.opencode/plugin/envsitter-guard.ts` re-exports the plugin from `index.ts` (the only tracked file here).
|
||||||
- `.opencode/bun.lock` indicates Bun-managed deps inside `.opencode/`.
|
- `.opencode/package.json` and `.opencode/bun.lock` are local-only (ignored by `.opencode/.gitignore`).
|
||||||
- `node_modules/`: vendored deps (present locally; do not edit).
|
- `node_modules/`: npm-installed deps (do not edit); `envsitter` comes from npm, not the sibling checkout.
|
||||||
|
|
||||||
## Commands (build / lint / test)
|
## Commands (build / lint / test)
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
- Root install (lockfile: `package-lock.json`):
|
- Root install (lockfile: `package-lock.json`): `npm ci` (preferred) or `npm install`
|
||||||
- `npm ci`
|
|
||||||
- `npm install` (ok for local dev; `npm ci` preferred for reproducibility)
|
|
||||||
|
|
||||||
- `.opencode/` install (Bun lockfile present):
|
- `.opencode/` install (local-only Bun lockfile, untracked):
|
||||||
- If you need to refresh `.opencode/node_modules`, use Bun from `.opencode/`:
|
- If you need to refresh `.opencode/node_modules`, use Bun from `.opencode/`:
|
||||||
- `cd .opencode && bun install`
|
- `cd .opencode && bun install`
|
||||||
- If Bun is unavailable, do not guess; ask before changing `.opencode/` dependency management.
|
- If Bun is unavailable, do not guess; ask before changing `.opencode/` dependency management.
|
||||||
|
|
||||||
### Build / typecheck
|
### 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):
|
- Typecheck (canonical): `npm run typecheck` (= `npx tsc -p tsconfig.json`)
|
||||||
- `npm run typecheck`
|
|
||||||
- Equivalent: `npx tsc -p tsconfig.json`
|
|
||||||
|
|
||||||
### Lint / format
|
### 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/`.
|
Tests use Node’s built-in test runner, compiled via `tsc` into `dist-test/`.
|
||||||
|
|
||||||
- Run all tests (builds then runs):
|
- Run all tests (builds then runs): `npm test`
|
||||||
- `npm test`
|
- Build test output only: `npm run build:test`
|
||||||
- Build test output only:
|
- Cross-version e2e (needs the CLIs installed; not part of `npm test`): `npm run test:e2e`
|
||||||
- `npm run build:test`
|
|
||||||
- Run a single test file:
|
- 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`
|
||||||
|
- To test against an unreleased `envsitter`: `npm pack` in `../envsitter`, then `npm install --no-save <tarball>` here (CI always uses the published version from the lockfile).
|
||||||
|
|
||||||
Pre-commit checks are enforced via Husky:
|
Pre-commit checks are enforced via Husky:
|
||||||
- `npm install` installs the git hook.
|
- `npm install` installs the git hook.
|
||||||
@@ -66,13 +70,13 @@ Pre-commit checks are enforced via Husky:
|
|||||||
- Quotes: double quotes.
|
- Quotes: double quotes.
|
||||||
- Semicolons: required.
|
- Semicolons: required.
|
||||||
- Trailing commas: used in multiline objects/args.
|
- 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
|
## Naming conventions
|
||||||
|
|
||||||
- `camelCase`: functions, locals, parameters.
|
- `camelCase`: functions, locals, parameters.
|
||||||
- `PascalCase`: exported plugin symbol (`EnvSitterGuard`).
|
- `PascalCase`: exported plugin symbol (`EnvSitterGuard`).
|
||||||
- Booleans use `is*` / `has*` prefixes (e.g. `isSensitiveDotEnvPath`).
|
- Booleans use `is*` / `has*` prefixes (e.g. `isProtectedPath`).
|
||||||
|
|
||||||
## Error handling & validation
|
## Error handling & validation
|
||||||
|
|
||||||
@@ -87,42 +91,38 @@ This plugin is security-sensitive. Follow these patterns:
|
|||||||
|
|
||||||
- Do not read or print `.env` values.
|
- Do not read or print `.env` values.
|
||||||
- Treat `.env.secure` as sensitive; never commit secrets.
|
- 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:
|
- The plugin blocks protected env files and the pepper, and points agents to EnvSitter tools (keys, fingerprints, booleans; never values).
|
||||||
- `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
|
## Change discipline
|
||||||
|
|
||||||
- Prefer minimal diffs; avoid refactors during bugfixes.
|
- Prefer minimal diffs; avoid refactors during bugfixes.
|
||||||
- Keep behavior consistent with `index.ts` patterns:
|
- Keep behavior consistent with `lib/` patterns:
|
||||||
- normalize paths before regex matching
|
- normalize paths before matching; decide "protected" only via envsitter's `isSensitiveEnvFile` / `isEnvSitterPepperFile`
|
||||||
- ensure file paths stay within `worktree`
|
- ensure file paths stay within the project root (V1 `worktree`, V2 `ctx.location.project.directory`)
|
||||||
- throttle UI toasts (`lastToastAt` pattern)
|
- 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
|
## Plugin behavior notes
|
||||||
|
|
||||||
- OpenCode entrypoint is `EnvSitterGuard` exported from `index.ts`.
|
- Tool surface (15 tools; every file-taking tool resolves paths via `resolveEnvFilePath` in `lib/tools.ts`):
|
||||||
- Plugin registration file `.opencode/plugin/envsitter-guard.ts` must remain a thin re-export.
|
- Read-only: `envsitter_keys`, `envsitter_fingerprint`, `envsitter_match`, `envsitter_match_by_key`, `envsitter_scan`, `envsitter_validate`.
|
||||||
- Tool surface area intentionally small:
|
- Mutating, dry-run unless `write: true`: `envsitter_copy`, `envsitter_format`, `envsitter_reorder`, `envsitter_annotate`, `envsitter_add`, `envsitter_set`, `envsitter_unset`, `envsitter_delete`.
|
||||||
- `envsitter_keys`: lists keys only (no values)
|
- `envsitter_help`: agent-facing guide; update its topic text whenever a tool changes.
|
||||||
- `envsitter_fingerprint`: deterministic fingerprint of a single key (no value)
|
- V2 registers them with `codemode: false` so agents call them by name.
|
||||||
- Blocking behavior is enforced in `"tool.execute.before"`:
|
- Blocking (`findBlockedAccess`, used by V1 `tool.execute.before` and V2 `execute.before`):
|
||||||
- Blocks reads of `.env*` (except `.env.example`).
|
- Any non-`envsitter_*` tool whose `filePath` / `path` / `file_path` / `paths` args hit a protected file.
|
||||||
- Blocks edits/writes/patching of `.env*` and `.envsitter/pepper`.
|
- `patchText` envelopes (`patch`, V1.19 `apply_patch`) and unified diffs touching protected files.
|
||||||
- Throttles UI toasts via `lastToastAt`.
|
- `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
|
## Path handling rules
|
||||||
|
|
||||||
- Treat all incoming tool args as untrusted.
|
- Treat all incoming tool args as untrusted.
|
||||||
- Normalize path separators before matching (Windows `\\` → `/`).
|
- Normalize path separators before matching (Windows `\\` → `/`).
|
||||||
- Validate allowed paths before resolving to absolute paths.
|
- 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
|
## Output conventions
|
||||||
|
|
||||||
@@ -143,11 +143,7 @@ This plugin is security-sensitive. Follow these patterns:
|
|||||||
|
|
||||||
## Verification checklist
|
## 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.
|
- No tool path allows reading raw `.env*` values.
|
||||||
- `.opencode/plugin/envsitter-guard.ts` remains a re-export only.
|
- `.opencode/plugin/envsitter-guard.ts` remains a re-export only.
|
||||||
- Error messages remain clear and do not leak file contents.
|
- 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.
|
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 (2026-10-21)
|
||||||
|
|
||||||
|
### 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
|
## 0.0.4
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
# envsitter-guard
|
# 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).
|
[](https://github.com/boxpositron/envsitter-guard/actions/workflows/ci.yml)
|
||||||
|
[](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)
|
## Quickstart (OpenCode)
|
||||||
|
|
||||||
OpenCode supports loading plugins from npm or local plugin files.
|
|
||||||
|
|
||||||
Reference docs:
|
Reference docs:
|
||||||
- https://opencode.ai/docs/plugins/
|
- OpenCode 2: https://opencode.ai/v2/docs/plugins
|
||||||
- https://opencode.ai/docs/config/#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
|
```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
|
## Why
|
||||||
|
|
||||||
@@ -77,20 +107,29 @@ Notes for file operations:
|
|||||||
|
|
||||||
### Blocking behavior
|
### Blocking behavior
|
||||||
|
|
||||||
- Sensitive paths: `.env`, `.env.local`, `.env.production`, etc. (`.env*`)
|
Protected files come from envsitter's path policy (`isSensitiveEnvFile` / `isEnvSitterPepperFile`):
|
||||||
- Allowed: `.env.example`
|
|
||||||
- Always blocked: `.envsitter/pepper`
|
|
||||||
|
|
||||||
Blocked operations via tool hooks:
|
- Protected: `.env`, `.env.<anything>`, `<name>.env`, `<name>.env.<anything>` (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
|
Blocked operations:
|
||||||
- `edit` / `write` / `patch` / `multiedit` on sensitive `.env*` paths
|
|
||||||
|
- 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.
|
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
|
||||||
|
|
||||||
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`.
|
- Most tools accept a `filePath` that defaults to `.env`.
|
||||||
- File operations are dry-run unless `write: true` is provided.
|
- File operations are dry-run unless `write: true` is provided.
|
||||||
@@ -319,7 +358,7 @@ Example (inside OpenCode):
|
|||||||
|
|
||||||
### Option B: local plugin file (project-level)
|
### 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
|
```ts
|
||||||
import EnvSitterGuard from "envsitter-guard";
|
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
|
### 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.)
|
(You can use the same contents as Option B.)
|
||||||
|
|
||||||
@@ -366,12 +405,60 @@ npm run typecheck
|
|||||||
npm test
|
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)
|
### Build (for publishing)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
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
|
## 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](https://github.com/boxpositron/envsitter) — The underlying library this plugin is built on. Provides CLI and programmatic API for safe `.env` inspection.
|
||||||
|
|||||||
@@ -1,916 +1,15 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin as V1Plugin } from "@opencode-ai/plugin";
|
||||||
import { tool } from "@opencode-ai/plugin/tool";
|
import type { Plugin as V2 } from "@opencode/plugin";
|
||||||
import {
|
|
||||||
EnvSitter,
|
import { serverV1 } from "./lib/v1.js";
|
||||||
addEnvFileKey,
|
import { setupV2 } from "./lib/v2.js";
|
||||||
annotateEnvFile,
|
|
||||||
copyEnvFileKeys,
|
// One default export serves both OpenCode majors: V2 reads `id` + `setup`, V1 (>= 1.18.29) calls `server`.
|
||||||
deleteEnvFileKeys,
|
// Keep every export of this module plugin-shaped: V1 rejects modules that export anything else.
|
||||||
formatEnvFile,
|
export const EnvSitterGuard: V2.Plugin & { readonly server: V1Plugin } = {
|
||||||
setEnvFileKey,
|
id: "envsitter-guard",
|
||||||
unsetEnvFileKey,
|
setup: setupV2,
|
||||||
validateEnvFile,
|
server: serverV1,
|
||||||
} 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 parseUserRegExp(input: string): RegExp {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed.startsWith("/")) return new RegExp(trimmed);
|
|
||||||
|
|
||||||
let lastSlashIndex = -1;
|
|
||||||
for (let i = trimmed.length - 1; i >= 1; i -= 1) {
|
|
||||||
if (trimmed[i] !== "/") continue;
|
|
||||||
|
|
||||||
let backslashCount = 0;
|
|
||||||
for (let j = i - 1; j >= 0 && trimmed[j] === "\\"; j -= 1) {
|
|
||||||
backslashCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEscaped = backslashCount % 2 === 1;
|
|
||||||
if (!isEscaped) {
|
|
||||||
lastSlashIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastSlashIndex === -1) {
|
|
||||||
throw new Error("Invalid regex literal; expected a closing `/`.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = trimmed.slice(1, lastSlashIndex);
|
|
||||||
const flags = trimmed.slice(lastSlashIndex + 1);
|
|
||||||
if (!/^[a-z]*$/.test(flags)) {
|
|
||||||
throw new Error("Invalid regex literal flags; expected only letters (e.g. `/abc/i`).");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RegExp(body, flags);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCandidate(params: { candidate?: string; candidateEnvVar?: string }): string {
|
|
||||||
if (typeof params.candidate === "string" && params.candidate.length > 0) return params.candidate;
|
|
||||||
|
|
||||||
if (typeof params.candidateEnvVar === "string" && params.candidateEnvVar.length > 0) {
|
|
||||||
const value = process.env[params.candidateEnvVar];
|
|
||||||
if (typeof value === "string" && value.length > 0) return value;
|
|
||||||
throw new Error(`Env var \`${params.candidateEnvVar}\` was not set.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Candidate is required for this operation. Provide `candidate` or `candidateEnvVar`.");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ({ directory, worktree }) => {
|
|
||||||
const matchOps = [
|
|
||||||
"exists",
|
|
||||||
"is_empty",
|
|
||||||
"is_equal",
|
|
||||||
"partial_match_prefix",
|
|
||||||
"partial_match_suffix",
|
|
||||||
"partial_match_regex",
|
|
||||||
"is_number",
|
|
||||||
"is_boolean",
|
|
||||||
"is_string",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const scanDetections = ["jwt", "url", "base64"] as const;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tool: {
|
|
||||||
envsitter_keys: tool({
|
|
||||||
description: "List keys in a .env file (never returns values).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
filterRegex: 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(
|
|
||||||
args.filterRegex
|
|
||||||
? {
|
|
||||||
filter: parseUserRegExp(args.filterRegex),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_match: tool({
|
|
||||||
description:
|
|
||||||
"Match key values without printing them. Supports existence/shape checks and outside-in candidate matching.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
op: tool.schema.enum(matchOps).optional(),
|
|
||||||
key: tool.schema.string().optional(),
|
|
||||||
keys: tool.schema.array(tool.schema.string()).optional(),
|
|
||||||
allKeys: tool.schema.boolean().optional(),
|
|
||||||
candidate: tool.schema.string().optional(),
|
|
||||||
candidateEnvVar: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const op = args.op ?? "is_equal";
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
|
|
||||||
let isEqualCandidate: string | undefined;
|
|
||||||
|
|
||||||
const matcher = (() => {
|
|
||||||
if (op === "exists") return { op } as const;
|
|
||||||
if (op === "is_empty") return { op } as const;
|
|
||||||
if (op === "is_number") return { op } as const;
|
|
||||||
if (op === "is_boolean") return { op } as const;
|
|
||||||
if (op === "is_string") return { op } as const;
|
|
||||||
|
|
||||||
const candidate = resolveCandidate({
|
|
||||||
candidate: args.candidate,
|
|
||||||
candidateEnvVar: args.candidateEnvVar,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (op === "is_equal") {
|
|
||||||
isEqualCandidate = candidate;
|
|
||||||
return { op, candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_prefix") {
|
|
||||||
return { op, prefix: candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_suffix") {
|
|
||||||
return { op, suffix: candidate } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op === "partial_match_regex") {
|
|
||||||
return { op, regex: parseUserRegExp(candidate) } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported op: ${op}`);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const key = args.key;
|
|
||||||
const keys = args.keys;
|
|
||||||
const allKeys = args.allKeys === true;
|
|
||||||
|
|
||||||
const selectorCount =
|
|
||||||
Number(typeof key === "string") + Number(Array.isArray(keys) && keys.length > 0) + Number(allKeys);
|
|
||||||
if (selectorCount !== 1) {
|
|
||||||
throw new Error("Provide exactly one of: `key`, `keys`, or `allKeys: true`. ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof key === "string") {
|
|
||||||
const match =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidate(key, isEqualCandidate)
|
|
||||||
: await es.matchKey(key, matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, key, op: matcher.op, match }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(keys) && keys.length > 0) {
|
|
||||||
const matches =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidateBulk(keys, isEqualCandidate)
|
|
||||||
: await es.matchKeyBulk(keys, matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, op: matcher.op, matches }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches =
|
|
||||||
matcher.op === "is_equal" && typeof isEqualCandidate === "string"
|
|
||||||
? await es.matchCandidateAll(isEqualCandidate)
|
|
||||||
: await es.matchKeyAll(matcher);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, op: matcher.op, matches }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_match_by_key: tool({
|
|
||||||
description: "Bulk match candidates-by-key without printing values (returns booleans only).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
candidatesByKey: tool.schema.record(tool.schema.string(), tool.schema.string()).optional(),
|
|
||||||
candidatesByKeyJson: tool.schema.string().optional(),
|
|
||||||
candidatesByKeyEnvVar: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromRecord = args.candidatesByKey;
|
|
||||||
const fromJson = args.candidatesByKeyJson;
|
|
||||||
const fromEnvVar = args.candidatesByKeyEnvVar;
|
|
||||||
|
|
||||||
const selectorCount = Number(!!fromRecord) + Number(!!fromJson) + Number(!!fromEnvVar);
|
|
||||||
if (selectorCount !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"Provide exactly one of: `candidatesByKey`, `candidatesByKeyJson`, or `candidatesByKeyEnvVar`.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidatesByKey: Record<string, string>;
|
|
||||||
|
|
||||||
if (fromRecord) {
|
|
||||||
candidatesByKey = fromRecord;
|
|
||||||
} else {
|
|
||||||
const json =
|
|
||||||
typeof fromJson === "string"
|
|
||||||
? fromJson
|
|
||||||
: (() => {
|
|
||||||
const envVarName = fromEnvVar as string;
|
|
||||||
const value = process.env[envVarName];
|
|
||||||
if (typeof value !== "string") throw new Error(`Env var \`${envVarName}\` was not set.`);
|
|
||||||
return value;
|
|
||||||
})();
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(json);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
||||||
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
|
||||||
const normalized: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new Error("Invalid candidates JSON; expected every value to be a string.");
|
|
||||||
}
|
|
||||||
normalized[key] = value;
|
|
||||||
}
|
|
||||||
candidatesByKey = normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const matches = await es.matchCandidatesByKey(candidatesByKey);
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, matches }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_scan: tool({
|
|
||||||
description: "Scan value shapes (jwt/url/base64) without printing values.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
detect: tool.schema.array(tool.schema.enum(scanDetections)).optional(),
|
|
||||||
keysFilterRegex: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
|
||||||
const findings = await es.scan({
|
|
||||||
detect: args.detect,
|
|
||||||
keysFilter: args.keysFilterRegex ? parseUserRegExp(args.keysFilterRegex) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, findings }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_validate: tool({
|
|
||||||
description: "Validate dotenv syntax (never returns values).",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await validateEnvFile(resolved.absolutePath);
|
|
||||||
return JSON.stringify({ file: resolved.displayPath, ok: result.ok, issues: result.issues }, null, 2);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_copy: tool({
|
|
||||||
description:
|
|
||||||
"Copy keys between dotenv files safely (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
from: tool.schema.string(),
|
|
||||||
to: tool.schema.string(),
|
|
||||||
keys: tool.schema.array(tool.schema.string()).optional(),
|
|
||||||
includeRegex: tool.schema.string().optional(),
|
|
||||||
excludeRegex: tool.schema.string().optional(),
|
|
||||||
rename: tool.schema.string().optional(),
|
|
||||||
onConflict: tool.schema.enum(["error", "skip", "overwrite"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolvedFrom = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.from,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedTo = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.to,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof args.rename === "string" && args.rename.trim().length === 0) {
|
|
||||||
throw new Error("Invalid `rename`; expected a non-empty string like `A=B,C=D`. ");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await copyEnvFileKeys({
|
|
||||||
from: resolvedFrom.absolutePath,
|
|
||||||
to: resolvedTo.absolutePath,
|
|
||||||
keys: Array.isArray(args.keys) && args.keys.length > 0 ? args.keys : undefined,
|
|
||||||
include: args.includeRegex ? parseUserRegExp(args.includeRegex) : undefined,
|
|
||||||
exclude: args.excludeRegex ? parseUserRegExp(args.excludeRegex) : undefined,
|
|
||||||
rename: args.rename,
|
|
||||||
onConflict: args.onConflict,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
from: resolvedFrom.displayPath,
|
|
||||||
to: resolvedTo.displayPath,
|
|
||||||
onConflict: result.onConflict,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_format: tool({
|
|
||||||
description: "Format/reorder a dotenv file (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
mode: tool.schema.enum(["sections", "global"] as const).optional(),
|
|
||||||
sort: tool.schema.enum(["alpha", "none"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await formatEnvFile({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
mode: args.mode,
|
|
||||||
sort: args.sort,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
mode: result.mode,
|
|
||||||
sort: result.sort,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_reorder: tool({
|
|
||||||
description: "Alias for envsitter_format.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
mode: tool.schema.enum(["sections", "global"] as const).optional(),
|
|
||||||
sort: tool.schema.enum(["alpha", "none"] as const).optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await formatEnvFile({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
mode: args.mode,
|
|
||||||
sort: args.sort,
|
|
||||||
write: args.write === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
file: resolved.displayPath,
|
|
||||||
mode: result.mode,
|
|
||||||
sort: result.sort,
|
|
||||||
willWrite: result.willWrite,
|
|
||||||
wrote: result.wrote,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
envsitter_annotate: tool({
|
|
||||||
description: "Annotate a dotenv key with a comment (no values in output). Dry-run unless `write: true`.",
|
|
||||||
args: {
|
|
||||||
filePath: tool.schema.string().optional(),
|
|
||||||
key: tool.schema.string(),
|
|
||||||
comment: tool.schema.string(),
|
|
||||||
line: tool.schema.number().int().optional(),
|
|
||||||
write: tool.schema.boolean().optional(),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const resolved = resolveDotEnvPath({
|
|
||||||
worktree,
|
|
||||||
directory,
|
|
||||||
filePath: args.filePath ?? ".env",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (args.comment.trim().length === 0) {
|
|
||||||
throw new Error("Comment must be a non-empty string.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await annotateEnvFile({
|
|
||||||
file: resolved.absolutePath,
|
|
||||||
key: args.key,
|
|
||||||
comment: args.comment,
|
|
||||||
line: args.line,
|
|
||||||
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_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);
|
|
||||||
if (!filePath) return;
|
|
||||||
|
|
||||||
if (!isSensitiveDotEnvPath(filePath) && !isEnvSitterPepperPath(filePath)) return;
|
|
||||||
|
|
||||||
if (input.tool === "read") {
|
|
||||||
throw new Error(
|
|
||||||
"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") {
|
|
||||||
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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EnvSitterGuard;
|
export default EnvSitterGuard;
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
export const HELP_TOPICS = ["overview", "reading", "matching", "mutations", "file_ops", "all"] as const;
|
||||||
|
|
||||||
|
export type HelpTopic = (typeof HELP_TOPICS)[number];
|
||||||
|
|
||||||
|
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 access to sensitive env files (\`.env\`, \`.env.*\`, \`*.env\`) is BLOCKED to prevent secret leaks:
|
||||||
|
reads, edits/writes/patches, shell commands that name them, and grep matches inside them
|
||||||
|
- Secret-looking values from those files are replaced with \`[envsitter: KEY redacted]\` in other tools' output
|
||||||
|
- Example/template files (\`.env.example\`, \`.env.sample\`, \`.env.template\`, \`.env.dist\`, \`.env.default(s)\`) stay readable
|
||||||
|
- 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<Exclude<HelpTopic, "all">, string> = {
|
||||||
|
overview,
|
||||||
|
reading,
|
||||||
|
matching,
|
||||||
|
mutations,
|
||||||
|
file_ops: fileOps,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function helpText(topic: HelpTopic): string {
|
||||||
|
if (topic === "all") return [overview, reading, matching, mutations, fileOps].join("\n---\n");
|
||||||
|
return sections[topic];
|
||||||
|
}
|
||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
import { isEnvSitterPepperFile, isSensitiveEnvFile } from "envsitter";
|
||||||
|
|
||||||
|
export type BlockedAccess = {
|
||||||
|
readonly kind: "read" | "edit" | "shell" | "access";
|
||||||
|
readonly tool: string;
|
||||||
|
readonly paths: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATH_ARG_KEYS = ["filePath", "file_path", "filepath", "path", "paths", "filePaths"] as const;
|
||||||
|
const READ_TOOLS = new Set(["read", "grep", "glob", "list", "view"]);
|
||||||
|
const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"]);
|
||||||
|
|
||||||
|
const PATCH_FILE_HEADER = /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/gm;
|
||||||
|
const UNIFIED_DIFF_HEADER = /^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)\s*$/gm;
|
||||||
|
|
||||||
|
const SHELL_SEGMENT_OPERATORS = new Set([";", "&", "|", "\n", "(", ")", "`"]);
|
||||||
|
const SHELL_WORD_BREAKS = new Set(["<", ">"]);
|
||||||
|
const SHELL_SUBSTITUTION = /\$\(([^()]*)\)|`([^`]*)`/g;
|
||||||
|
const SHELL_PREFIX_COMMANDS = new Set(["sudo", "env", "command", "exec", "time", "nohup", "nice"]);
|
||||||
|
const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
|
||||||
|
|
||||||
|
export function normalizePath(input: string): string {
|
||||||
|
return input.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripAtPrefix(input: string): string {
|
||||||
|
return input.trim().replace(/^@+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProtectedPath(input: string): boolean {
|
||||||
|
const candidate = normalizePath(stripAtPrefix(input));
|
||||||
|
return isSensitiveEnvFile(candidate) || isEnvSitterPepperFile(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPathArgs(args: Record<string, unknown>): string[] {
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const key of PATH_ARG_KEYS) {
|
||||||
|
const value = args[key];
|
||||||
|
if (typeof value === "string") paths.push(value);
|
||||||
|
if (Array.isArray(value)) paths.push(...value.filter((item): item is string => typeof item === "string"));
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPatchPaths(patchText: string): string[] {
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const match of patchText.matchAll(PATCH_FILE_HEADER)) paths.push(match[1] ?? "");
|
||||||
|
for (const match of patchText.matchAll(UNIFIED_DIFF_HEADER)) paths.push(match[1] ?? "");
|
||||||
|
return paths.filter((p) => p.length > 0 && p !== "/dev/null");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitShellSegments(command: string): string[][] {
|
||||||
|
const segments: string[][] = [];
|
||||||
|
let words: string[] = [];
|
||||||
|
let word = "";
|
||||||
|
let inWord = false;
|
||||||
|
let quote: "'" | '"' | undefined;
|
||||||
|
|
||||||
|
const endWord = () => {
|
||||||
|
if (inWord) words.push(word);
|
||||||
|
word = "";
|
||||||
|
inWord = false;
|
||||||
|
};
|
||||||
|
const endSegment = () => {
|
||||||
|
endWord();
|
||||||
|
if (words.length > 0) segments.push(words);
|
||||||
|
words = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < command.length; i += 1) {
|
||||||
|
const char = command[i] ?? "";
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) {
|
||||||
|
quote = undefined;
|
||||||
|
} else if (char === "\\" && quote === '"' && i + 1 < command.length) {
|
||||||
|
word += command[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
word += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
inWord = true;
|
||||||
|
} else if (char === "\\" && i + 1 < command.length) {
|
||||||
|
word += command[i + 1];
|
||||||
|
inWord = true;
|
||||||
|
i += 1;
|
||||||
|
} else if (SHELL_SEGMENT_OPERATORS.has(char)) {
|
||||||
|
endSegment();
|
||||||
|
} else if (SHELL_WORD_BREAKS.has(char) || /\s/.test(char)) {
|
||||||
|
endWord();
|
||||||
|
} else {
|
||||||
|
word += char;
|
||||||
|
inWord = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endSegment();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnvSitterPackage(word: string | undefined): boolean {
|
||||||
|
return word === "envsitter" || (word?.startsWith("envsitter@") ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnvSitterInvocation(words: readonly string[]): boolean {
|
||||||
|
let index = 0;
|
||||||
|
while (index < words.length) {
|
||||||
|
const word = words[index] ?? "";
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) && !SHELL_PREFIX_COMMANDS.has(word)) break;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = (words[index] ?? "").split("/").pop() ?? "";
|
||||||
|
if (program === "envsitter") return true;
|
||||||
|
|
||||||
|
const rest = words.slice(index + 1).filter((word) => !word.startsWith("-"));
|
||||||
|
if (PACKAGE_RUNNERS.has(program)) return isEnvSitterPackage(rest[0]);
|
||||||
|
if (program === "pnpm" || program === "yarn") return rest[0] === "dlx" && isEnvSitterPackage(rest[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedGlob(word: string): boolean {
|
||||||
|
return /[*?[]/.test(word) && word.toLowerCase().includes(".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort scan for `.env*` / pepper paths named in a shell command. It is a guardrail against
|
||||||
|
* accidental reads (`cat .env`, `source .env`, `echo x >> .env`), not a sandbox: variables,
|
||||||
|
* eval, and generic commands such as `grep -r` without a path can still reach file contents.
|
||||||
|
* Segments that invoke the envsitter CLI are allowed because it never prints values.
|
||||||
|
*/
|
||||||
|
export function findProtectedPathsInCommand(command: string): string[] {
|
||||||
|
const found = new Set<string>();
|
||||||
|
const sources = [command, ...[...command.matchAll(SHELL_SUBSTITUTION)].map((m) => m[1] ?? m[2] ?? "")];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const words of splitShellSegments(source)) {
|
||||||
|
if (isEnvSitterInvocation(words)) continue;
|
||||||
|
for (const word of words) {
|
||||||
|
for (const candidate of [word, ...word.split("=").slice(1)]) {
|
||||||
|
if (candidate && (isProtectedPath(candidate) || isProtectedGlob(candidate))) found.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found];
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindForTool(tool: string): BlockedAccess["kind"] {
|
||||||
|
if (READ_TOOLS.has(tool)) return "read";
|
||||||
|
if (EDIT_TOOLS.has(tool)) return "edit";
|
||||||
|
return "access";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBlockedAccess(tool: string, args: unknown): BlockedAccess | undefined {
|
||||||
|
if (tool.startsWith("envsitter_")) return undefined;
|
||||||
|
|
||||||
|
const record = asRecord(args);
|
||||||
|
if (!record) return undefined;
|
||||||
|
|
||||||
|
const paths = collectPathArgs(record);
|
||||||
|
if (typeof record.patchText === "string") paths.push(...extractPatchPaths(record.patchText));
|
||||||
|
|
||||||
|
const protectedPaths = paths.filter(isProtectedPath);
|
||||||
|
if (protectedPaths.length > 0) return { kind: kindForTool(tool), tool, paths: protectedPaths };
|
||||||
|
|
||||||
|
if (typeof record.command === "string") {
|
||||||
|
const shellPaths = findProtectedPathsInCommand(record.command);
|
||||||
|
if (shellPaths.length > 0) return { kind: "shell", tool, paths: shellPaths };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockedAccessMessage(blocked: BlockedAccess): string {
|
||||||
|
const targets = blocked.paths.map((p) => `\`${p}\``).join(", ");
|
||||||
|
const help = "Call envsitter_help for a usage guide.";
|
||||||
|
|
||||||
|
if (blocked.kind === "read") {
|
||||||
|
return `Reading \`.env*\` is blocked to prevent secret leaks (${targets}). Use EnvSitter tools instead (never prints values). ${help}`;
|
||||||
|
}
|
||||||
|
if (blocked.kind === "edit") {
|
||||||
|
return `Editing \`.env*\` and \`.envsitter/pepper\` via standard tools is blocked (${targets}). Use EnvSitter mutation tools: envsitter_add, envsitter_set, envsitter_unset, envsitter_delete. ${help}`;
|
||||||
|
}
|
||||||
|
if (blocked.kind === "shell") {
|
||||||
|
return `Shell commands that reference \`.env*\` files or \`.envsitter/pepper\` are blocked (${targets}). Use EnvSitter tools or the envsitter CLI, which never print values. ${help}`;
|
||||||
|
}
|
||||||
|
return `Access to \`.env*\` files and \`.envsitter/pepper\` through \`${blocked.tool}\` is blocked (${targets}). Use EnvSitter tools instead. ${help}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function permissionDenial(action: string, resources: readonly string[]): string | undefined {
|
||||||
|
if (action === "read" || action === "edit") {
|
||||||
|
const paths = resources.filter(isProtectedPath);
|
||||||
|
if (paths.length > 0) return blockedAccessMessage({ kind: action, tool: action, paths });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "shell") {
|
||||||
|
const paths = resources.flatMap(findProtectedPathsInCommand);
|
||||||
|
if (paths.length > 0) return blockedAccessMessage({ kind: "shell", tool: action, paths });
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenMatchesNote(hidden: number): string {
|
||||||
|
return `(${hidden} match${hidden === 1 ? "" : "es"} in protected .env files hidden by envsitter-guard. Use EnvSitter tools to inspect them.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCode grep output (V1 and V2): "Found N matches", then per file a "<path>:" header, " Line N: text" rows, and a blank line.
|
||||||
|
export function redactGrepOutput(output: string): { output: string; hidden: number } {
|
||||||
|
const kept: string[] = [];
|
||||||
|
let hidden = 0;
|
||||||
|
let skipping = false;
|
||||||
|
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const header = /^(\S.*):$/.exec(line);
|
||||||
|
if (header && !line.startsWith("Found ")) {
|
||||||
|
skipping = isProtectedPath(header[1] ?? "");
|
||||||
|
if (skipping) continue;
|
||||||
|
} else if (skipping && line.startsWith(" Line ")) {
|
||||||
|
hidden += 1;
|
||||||
|
continue;
|
||||||
|
} else if (skipping && line === "") {
|
||||||
|
skipping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden === 0) return { output, hidden };
|
||||||
|
|
||||||
|
const recounted = kept.join("\n").replace(/^Found (\d+) matches/, (_match, total: string) => `Found ${Math.max(0, Number(total) - hidden)} matches`);
|
||||||
|
return { output: `${recounted.trimEnd()}\n\n${hiddenMatchesNote(hidden)}`, hidden };
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrepMatch = { readonly entry?: { readonly path?: unknown } };
|
||||||
|
|
||||||
|
export function redactGrepResult<Result extends { readonly content?: unknown; readonly output?: unknown }>(result: Result): Result {
|
||||||
|
const content = result.content;
|
||||||
|
let redactedContent: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
redactedContent = redactGrepOutput(content).output;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
redactedContent = content.map((part: unknown) => {
|
||||||
|
const record = asRecord(part);
|
||||||
|
if (record?.type !== "text" || typeof record.text !== "string") return part;
|
||||||
|
return { ...record, text: redactGrepOutput(record.text).output };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = Array.isArray(result.output)
|
||||||
|
? (result.output as GrepMatch[]).filter((match) => {
|
||||||
|
const matchPath = match.entry?.path;
|
||||||
|
return typeof matchPath !== "string" || !isProtectedPath(matchPath);
|
||||||
|
})
|
||||||
|
: result.output;
|
||||||
|
|
||||||
|
return { ...result, content: redactedContent, output };
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { EnvSitter, isSensitiveEnvFile, type EnvSitterRedactor } from "envsitter";
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "out", "coverage", "target", "vendor", ".next", ".nuxt", ".turbo", ".cache", ".venv", "venv"]);
|
||||||
|
const MAX_DEPTH = 3;
|
||||||
|
const MAX_ENV_FILES = 50;
|
||||||
|
|
||||||
|
export type Scrub = (text: string) => string;
|
||||||
|
|
||||||
|
type CachedRedactor = {
|
||||||
|
readonly mtimeMs: number;
|
||||||
|
readonly size: number;
|
||||||
|
readonly redactor: EnvSitterRedactor;
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorCode(error: unknown): string | undefined {
|
||||||
|
return error instanceof Error && "code" in error ? String((error as { code?: unknown }).code) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSensitiveEnvFiles(root: string): Promise<string[]> {
|
||||||
|
const found: string[] = [];
|
||||||
|
let level = [root];
|
||||||
|
|
||||||
|
for (let depth = 0; depth <= MAX_DEPTH && level.length > 0; depth++) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const dir of level) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
const code = errorCode(error);
|
||||||
|
if (code === "ENOENT" || code === "EACCES" || code === "ENOTDIR") continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!SKIP_DIRS.has(entry.name)) next.push(fullPath);
|
||||||
|
} else if ((entry.isFile() || entry.isSymbolicLink()) && isSensitiveEnvFile(fullPath)) {
|
||||||
|
found.push(fullPath);
|
||||||
|
if (found.length >= MAX_ENV_FILES) return found.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redactorFor(file: string, cache: Map<string, CachedRedactor>): Promise<EnvSitterRedactor | undefined> {
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await fs.stat(file);
|
||||||
|
} catch (error) {
|
||||||
|
if (errorCode(error) !== "ENOENT") throw error;
|
||||||
|
cache.delete(file);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!info.isFile()) return undefined;
|
||||||
|
|
||||||
|
const cached = cache.get(file);
|
||||||
|
if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.redactor;
|
||||||
|
|
||||||
|
const redactor = await EnvSitter.fromDotenvFile(file).redactor();
|
||||||
|
cache.set(file, { mtimeMs: info.mtimeMs, size: info.size, redactor });
|
||||||
|
return redactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redactors are cached per env file and rebuilt when the file's mtime or size changes.
|
||||||
|
export function createScrubLoader(root: string): () => Promise<Scrub> {
|
||||||
|
const cache = new Map<string, CachedRedactor>();
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
const redactors: EnvSitterRedactor[] = [];
|
||||||
|
for (const file of await findSensitiveEnvFiles(root)) {
|
||||||
|
const redactor = await redactorFor(file, cache);
|
||||||
|
if (redactor && redactor.keys.length > 0) redactors.push(redactor);
|
||||||
|
}
|
||||||
|
return (text) => redactors.reduce((current, redactor) => redactor.redact(current).text, text);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrubContent(content: unknown, scrub: Scrub): unknown {
|
||||||
|
if (typeof content === "string") return scrub(content);
|
||||||
|
if (!Array.isArray(content)) return content;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const parts = content.map((part: unknown) => {
|
||||||
|
const record = asRecord(part);
|
||||||
|
if (record?.type !== "text" || typeof record.text !== "string") return part;
|
||||||
|
const text = scrub(record.text);
|
||||||
|
if (text === record.text) return part;
|
||||||
|
changed = true;
|
||||||
|
return { ...record, text };
|
||||||
|
});
|
||||||
|
return changed ? parts : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrubStrings(value: unknown, scrub: Scrub): unknown {
|
||||||
|
if (typeof value === "string") return scrub(value);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
let changed = false;
|
||||||
|
const items = value.map((item: unknown) => {
|
||||||
|
const scrubbed = scrubStrings(item, scrub);
|
||||||
|
if (scrubbed !== item) changed = true;
|
||||||
|
return scrubbed;
|
||||||
|
});
|
||||||
|
return changed ? items : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record || (Object.getPrototypeOf(record) !== Object.prototype && Object.getPrototypeOf(record) !== null)) return value;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const entries = Object.entries(record).map(([key, item]) => {
|
||||||
|
const scrubbed = scrubStrings(item, scrub);
|
||||||
|
if (scrubbed !== item) changed = true;
|
||||||
|
return [key, scrubbed] as const;
|
||||||
|
});
|
||||||
|
return changed ? Object.fromEntries(entries) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrubToolResult<Result extends { readonly content?: unknown; readonly output?: unknown }>(result: Result, scrub: Scrub): Result {
|
||||||
|
const content = scrubContent(result.content, scrub);
|
||||||
|
const output = scrubStrings(result.output, scrub);
|
||||||
|
if (content === result.content && output === result.output) return result;
|
||||||
|
return { ...result, content, output };
|
||||||
|
}
|
||||||
+508
@@ -0,0 +1,508 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { tool } from "@opencode-ai/plugin/tool";
|
||||||
|
import {
|
||||||
|
EnvSitter,
|
||||||
|
addEnvFileKey,
|
||||||
|
annotateEnvFile,
|
||||||
|
copyEnvFileKeys,
|
||||||
|
deleteEnvFileKeys,
|
||||||
|
formatEnvFile,
|
||||||
|
isEnvFile,
|
||||||
|
isEnvSitterPepperFile,
|
||||||
|
setEnvFileKey,
|
||||||
|
unsetEnvFileKey,
|
||||||
|
validateEnvFile,
|
||||||
|
type EnvSitterMatcher,
|
||||||
|
} from "envsitter";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { HELP_TOPICS, helpText } from "./help.js";
|
||||||
|
import { normalizePath, stripAtPrefix } from "./policy.js";
|
||||||
|
|
||||||
|
const schema = tool.schema;
|
||||||
|
|
||||||
|
const MATCH_OPS = [
|
||||||
|
"exists",
|
||||||
|
"is_empty",
|
||||||
|
"is_equal",
|
||||||
|
"partial_match_prefix",
|
||||||
|
"partial_match_suffix",
|
||||||
|
"partial_match_regex",
|
||||||
|
"is_number",
|
||||||
|
"is_boolean",
|
||||||
|
"is_string",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SCAN_DETECTIONS = ["jwt", "url", "base64"] as const;
|
||||||
|
|
||||||
|
export type ToolEnvironment = {
|
||||||
|
readonly directory: string;
|
||||||
|
readonly root: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuardTool = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly args: z.ZodRawShape;
|
||||||
|
readonly inputSchema: Record<string, unknown>;
|
||||||
|
readonly run: (input: unknown, env: ToolEnvironment) => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function defineTool<Shape extends z.ZodRawShape>(definition: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
args: Shape;
|
||||||
|
run: (args: z.infer<z.ZodObject<Shape>>, env: ToolEnvironment) => Promise<unknown>;
|
||||||
|
}): GuardTool {
|
||||||
|
const parser = schema.object(definition.args);
|
||||||
|
const { $schema: _dialect, ...inputSchema } = schema.toJSONSchema(parser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: definition.name,
|
||||||
|
description: definition.description,
|
||||||
|
args: definition.args,
|
||||||
|
inputSchema,
|
||||||
|
async run(input, env) {
|
||||||
|
const output = await definition.run(parser.parse(input ?? {}), env);
|
||||||
|
return typeof output === "string" ? output : JSON.stringify(output, null, 2);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvFilePath(env: ToolEnvironment, filePath: string | undefined): { absolutePath: string; displayPath: string } {
|
||||||
|
const normalized = normalizePath(stripAtPrefix(filePath ?? ".env"));
|
||||||
|
|
||||||
|
if (isEnvSitterPepperFile(normalized)) {
|
||||||
|
throw new Error("Access to `.envsitter/pepper` is blocked.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEnvFile(normalized)) {
|
||||||
|
throw new Error("Only dotenv files are allowed (e.g. `.env`, `.env.local`, `api.env`, `.env.example`).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(env.directory, normalized);
|
||||||
|
const relativeToRoot = path.relative(env.root, absolutePath);
|
||||||
|
if (relativeToRoot.split(path.sep)[0] === ".." || path.isAbsolute(relativeToRoot)) {
|
||||||
|
throw new Error("EnvSitter tools only operate on files inside the current project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { absolutePath, displayPath: relativeToRoot };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUserRegExp(input: string): RegExp {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed.startsWith("/")) return new RegExp(trimmed);
|
||||||
|
|
||||||
|
let lastSlashIndex = -1;
|
||||||
|
for (let i = trimmed.length - 1; i >= 1; i -= 1) {
|
||||||
|
if (trimmed[i] !== "/") continue;
|
||||||
|
|
||||||
|
let backslashCount = 0;
|
||||||
|
for (let j = i - 1; j >= 0 && trimmed[j] === "\\"; j -= 1) {
|
||||||
|
backslashCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backslashCount % 2 === 0) {
|
||||||
|
lastSlashIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSlashIndex === -1) {
|
||||||
|
throw new Error("Invalid regex literal; expected a closing `/`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = trimmed.slice(1, lastSlashIndex);
|
||||||
|
const flags = trimmed.slice(lastSlashIndex + 1);
|
||||||
|
if (!/^[a-z]*$/.test(flags)) {
|
||||||
|
throw new Error("Invalid regex literal flags; expected only letters (e.g. `/abc/i`).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(body, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCandidate(params: { candidate?: string | undefined; candidateEnvVar?: string | undefined }): string {
|
||||||
|
if (typeof params.candidate === "string" && params.candidate.length > 0) return params.candidate;
|
||||||
|
|
||||||
|
if (typeof params.candidateEnvVar === "string" && params.candidateEnvVar.length > 0) {
|
||||||
|
const value = process.env[params.candidateEnvVar];
|
||||||
|
if (typeof value === "string" && value.length > 0) return value;
|
||||||
|
throw new Error(`Env var \`${params.candidateEnvVar}\` was not set.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Candidate is required for this operation. Provide `candidate` or `candidateEnvVar`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatcher(op: (typeof MATCH_OPS)[number], candidateSource: { candidate?: string | undefined; candidateEnvVar?: string | undefined }): EnvSitterMatcher {
|
||||||
|
if (op === "exists" || op === "is_empty" || op === "is_number" || op === "is_boolean" || op === "is_string") {
|
||||||
|
return { op };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = resolveCandidate(candidateSource);
|
||||||
|
if (op === "is_equal") return { op, candidate };
|
||||||
|
if (op === "partial_match_prefix") return { op, prefix: candidate };
|
||||||
|
if (op === "partial_match_suffix") return { op, suffix: candidate };
|
||||||
|
return { op, regex: parseUserRegExp(candidate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCandidatesJson(json: string): Record<string, string> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("Invalid candidates JSON; expected an object mapping key -> candidate string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new Error("Invalid candidates JSON; expected every value to be a string.");
|
||||||
|
}
|
||||||
|
candidates[key] = value;
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatArgs = {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
mode: schema.enum(["sections", "global"] as const).optional(),
|
||||||
|
sort: schema.enum(["alpha", "none"] as const).optional(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runFormat(args: z.infer<z.ZodObject<typeof formatArgs>>, env: ToolEnvironment) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await formatEnvFile({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
mode: args.mode,
|
||||||
|
sort: args.sort,
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: resolved.displayPath,
|
||||||
|
mode: result.mode,
|
||||||
|
sort: result.sort,
|
||||||
|
willWrite: result.willWrite,
|
||||||
|
wrote: result.wrote,
|
||||||
|
hasChanges: result.hasChanges,
|
||||||
|
issues: result.issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guardTools: readonly GuardTool[] = [
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_keys",
|
||||||
|
description: "List keys in a .env file (never returns values).",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
filterRegex: schema.string().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
||||||
|
const keys = await es.listKeys(args.filterRegex ? { filter: parseUserRegExp(args.filterRegex) } : {});
|
||||||
|
return { file: resolved.displayPath, keys };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_fingerprint",
|
||||||
|
description: "Compute a deterministic fingerprint for a single key (never returns the value).",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
key: schema.string(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
||||||
|
const result = await es.fingerprintKey(args.key);
|
||||||
|
return { file: resolved.displayPath, key: args.key, result };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_match",
|
||||||
|
description:
|
||||||
|
"Match key values without printing them. Supports existence/shape checks and outside-in candidate matching.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
op: schema.enum(MATCH_OPS).optional(),
|
||||||
|
key: schema.string().optional(),
|
||||||
|
keys: schema.array(schema.string()).optional(),
|
||||||
|
allKeys: schema.boolean().optional(),
|
||||||
|
candidate: schema.string().optional(),
|
||||||
|
candidateEnvVar: schema.string().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const matcher = buildMatcher(args.op ?? "is_equal", args);
|
||||||
|
|
||||||
|
const hasKey = typeof args.key === "string";
|
||||||
|
const hasKeys = Array.isArray(args.keys) && args.keys.length > 0;
|
||||||
|
const allKeys = args.allKeys === true;
|
||||||
|
if (Number(hasKey) + Number(hasKeys) + Number(allKeys) !== 1) {
|
||||||
|
throw new Error("Provide exactly one of: `key`, `keys`, or `allKeys: true`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
||||||
|
if (typeof args.key === "string") {
|
||||||
|
const match = await es.matchKey(args.key, matcher);
|
||||||
|
return { file: resolved.displayPath, key: args.key, op: matcher.op, match };
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = hasKeys && args.keys ? await es.matchKeyBulk(args.keys, matcher) : await es.matchKeyAll(matcher);
|
||||||
|
return { file: resolved.displayPath, op: matcher.op, matches };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_match_by_key",
|
||||||
|
description: "Bulk match candidates-by-key without printing values (returns booleans only).",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
candidatesByKey: schema.record(schema.string(), schema.string()).optional(),
|
||||||
|
candidatesByKeyJson: schema.string().optional(),
|
||||||
|
candidatesByKeyEnvVar: schema.string().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
|
||||||
|
const selectorCount =
|
||||||
|
Number(!!args.candidatesByKey) + Number(!!args.candidatesByKeyJson) + Number(!!args.candidatesByKeyEnvVar);
|
||||||
|
if (selectorCount !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Provide exactly one of: `candidatesByKey`, `candidatesByKeyJson`, or `candidatesByKeyEnvVar`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidatesByKey: Record<string, string>;
|
||||||
|
if (args.candidatesByKey) {
|
||||||
|
candidatesByKey = args.candidatesByKey;
|
||||||
|
} else if (args.candidatesByKeyJson) {
|
||||||
|
candidatesByKey = parseCandidatesJson(args.candidatesByKeyJson);
|
||||||
|
} else {
|
||||||
|
const envVarName = args.candidatesByKeyEnvVar ?? "";
|
||||||
|
const value = process.env[envVarName];
|
||||||
|
if (typeof value !== "string") throw new Error(`Env var \`${envVarName}\` was not set.`);
|
||||||
|
candidatesByKey = parseCandidatesJson(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
||||||
|
const matches = await es.matchCandidatesByKey(candidatesByKey);
|
||||||
|
return { file: resolved.displayPath, matches };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_scan",
|
||||||
|
description: "Scan value shapes (jwt/url/base64) without printing values.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
detect: schema.array(schema.enum(SCAN_DETECTIONS)).optional(),
|
||||||
|
keysFilterRegex: schema.string().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const es = EnvSitter.fromDotenvFile(resolved.absolutePath);
|
||||||
|
const findings = await es.scan({
|
||||||
|
...(args.detect ? { detect: args.detect } : {}),
|
||||||
|
...(args.keysFilterRegex ? { keysFilter: parseUserRegExp(args.keysFilterRegex) } : {}),
|
||||||
|
});
|
||||||
|
return { file: resolved.displayPath, findings };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_validate",
|
||||||
|
description: "Validate dotenv syntax (never returns values).",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await validateEnvFile(resolved.absolutePath);
|
||||||
|
return { file: resolved.displayPath, ok: result.ok, issues: result.issues };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_copy",
|
||||||
|
description: "Copy keys between dotenv files safely (no values in output). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
from: schema.string(),
|
||||||
|
to: schema.string(),
|
||||||
|
keys: schema.array(schema.string()).optional(),
|
||||||
|
includeRegex: schema.string().optional(),
|
||||||
|
excludeRegex: schema.string().optional(),
|
||||||
|
rename: schema.string().optional(),
|
||||||
|
onConflict: schema.enum(["error", "skip", "overwrite"] as const).optional(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const from = resolveEnvFilePath(env, args.from);
|
||||||
|
const to = resolveEnvFilePath(env, args.to);
|
||||||
|
|
||||||
|
if (typeof args.rename === "string" && args.rename.trim().length === 0) {
|
||||||
|
throw new Error("Invalid `rename`; expected a non-empty string like `A=B,C=D`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await copyEnvFileKeys({
|
||||||
|
from: from.absolutePath,
|
||||||
|
to: to.absolutePath,
|
||||||
|
...(args.keys && args.keys.length > 0 ? { keys: args.keys } : {}),
|
||||||
|
...(args.includeRegex ? { include: parseUserRegExp(args.includeRegex) } : {}),
|
||||||
|
...(args.excludeRegex ? { exclude: parseUserRegExp(args.excludeRegex) } : {}),
|
||||||
|
...(args.rename ? { rename: args.rename } : {}),
|
||||||
|
...(args.onConflict ? { onConflict: args.onConflict } : {}),
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from.displayPath,
|
||||||
|
to: to.displayPath,
|
||||||
|
onConflict: result.onConflict,
|
||||||
|
willWrite: result.willWrite,
|
||||||
|
wrote: result.wrote,
|
||||||
|
hasChanges: result.hasChanges,
|
||||||
|
issues: result.issues,
|
||||||
|
plan: result.plan,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_format",
|
||||||
|
description: "Format/reorder a dotenv file (no values in output). Dry-run unless `write: true`.",
|
||||||
|
args: formatArgs,
|
||||||
|
run: runFormat,
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_reorder",
|
||||||
|
description: "Alias for envsitter_format.",
|
||||||
|
args: formatArgs,
|
||||||
|
run: runFormat,
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_annotate",
|
||||||
|
description: "Annotate a dotenv key with a comment (no values in output). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
key: schema.string(),
|
||||||
|
comment: schema.string(),
|
||||||
|
line: schema.number().int().optional(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
if (args.comment.trim().length === 0) {
|
||||||
|
throw new Error("Comment must be a non-empty string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await annotateEnvFile({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
key: args.key,
|
||||||
|
comment: args.comment,
|
||||||
|
...(args.line !== undefined ? { line: args.line } : {}),
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: resolved.displayPath,
|
||||||
|
key: result.key,
|
||||||
|
willWrite: result.willWrite,
|
||||||
|
wrote: result.wrote,
|
||||||
|
hasChanges: result.hasChanges,
|
||||||
|
issues: result.issues,
|
||||||
|
plan: result.plan,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_add",
|
||||||
|
description: "Add a new key to a dotenv file (fails if key already exists). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
key: schema.string(),
|
||||||
|
value: schema.string(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await addEnvFileKey({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
key: args.key,
|
||||||
|
value: args.value,
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
return { ...result, file: resolved.displayPath };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_set",
|
||||||
|
description:
|
||||||
|
"Set a key's value in a dotenv file (creates if missing, updates if exists). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
key: schema.string(),
|
||||||
|
value: schema.string(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await setEnvFileKey({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
key: args.key,
|
||||||
|
value: args.value,
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
return { ...result, file: resolved.displayPath };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_unset",
|
||||||
|
description:
|
||||||
|
"Unset a key's value in a dotenv file (sets to empty string, keeps the key). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
key: schema.string(),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await unsetEnvFileKey({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
key: args.key,
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
return { ...result, file: resolved.displayPath };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_delete",
|
||||||
|
description: "Delete key(s) from a dotenv file entirely (removes the line). Dry-run unless `write: true`.",
|
||||||
|
args: {
|
||||||
|
filePath: schema.string().optional(),
|
||||||
|
keys: schema.array(schema.string()),
|
||||||
|
write: schema.boolean().optional(),
|
||||||
|
},
|
||||||
|
async run(args, env) {
|
||||||
|
const resolved = resolveEnvFilePath(env, args.filePath);
|
||||||
|
const result = await deleteEnvFileKeys({
|
||||||
|
file: resolved.absolutePath,
|
||||||
|
keys: args.keys,
|
||||||
|
write: args.write === true,
|
||||||
|
});
|
||||||
|
return { ...result, file: resolved.displayPath };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defineTool({
|
||||||
|
name: "envsitter_help",
|
||||||
|
description:
|
||||||
|
"Get comprehensive help on all EnvSitter tools. Call this to understand how to safely work with .env files without exposing secrets.",
|
||||||
|
args: {
|
||||||
|
topic: schema.enum(HELP_TOPICS).optional(),
|
||||||
|
},
|
||||||
|
async run(args) {
|
||||||
|
return helpText(args.topic ?? "all");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Hooks, Plugin } from "@opencode-ai/plugin";
|
||||||
|
import { tool } from "@opencode-ai/plugin/tool";
|
||||||
|
|
||||||
|
import { blockedAccessMessage, findBlockedAccess, redactGrepOutput } from "./policy.js";
|
||||||
|
import { createScrubLoader } from "./scrub.js";
|
||||||
|
import { guardTools, type ToolEnvironment } from "./tools.js";
|
||||||
|
|
||||||
|
export const serverV1: Plugin = async ({ directory, worktree }) => {
|
||||||
|
const env: ToolEnvironment = { directory, root: worktree };
|
||||||
|
const loadScrub = createScrubLoader(worktree);
|
||||||
|
|
||||||
|
const tools: NonNullable<Hooks["tool"]> = {};
|
||||||
|
for (const guardTool of guardTools) {
|
||||||
|
tools[guardTool.name] = tool({
|
||||||
|
description: guardTool.description,
|
||||||
|
args: guardTool.args,
|
||||||
|
execute: (args) => guardTool.run(args, env),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tool: tools,
|
||||||
|
"tool.execute.before": async (input, output) => {
|
||||||
|
const blocked = findBlockedAccess(input.tool, output.args);
|
||||||
|
if (blocked) throw new Error(blockedAccessMessage(blocked));
|
||||||
|
},
|
||||||
|
"tool.execute.after": async (input, output) => {
|
||||||
|
if (input.tool.startsWith("envsitter_") || typeof output.output !== "string") return;
|
||||||
|
const text = input.tool === "grep" ? redactGrepOutput(output.output).output : output.output;
|
||||||
|
output.output = (await loadScrub())(text);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Plugin } from "@opencode/plugin";
|
||||||
|
import type { JsonSchema } from "effect";
|
||||||
|
|
||||||
|
import { blockedAccessMessage, findBlockedAccess, permissionDenial, redactGrepResult } from "./policy.js";
|
||||||
|
import { createScrubLoader, scrubToolResult } from "./scrub.js";
|
||||||
|
import { guardTools, type ToolEnvironment } from "./tools.js";
|
||||||
|
|
||||||
|
export async function setupV2(ctx: Plugin.Context): Promise<void> {
|
||||||
|
const env: ToolEnvironment = {
|
||||||
|
directory: ctx.location.directory,
|
||||||
|
root: ctx.location.project.directory,
|
||||||
|
};
|
||||||
|
const loadScrub = createScrubLoader(env.root);
|
||||||
|
|
||||||
|
await ctx.tool.transform((editor) => {
|
||||||
|
for (const guardTool of guardTools) {
|
||||||
|
editor.add({
|
||||||
|
name: guardTool.name,
|
||||||
|
description: guardTool.description,
|
||||||
|
input: guardTool.inputSchema as JsonSchema.JsonSchema,
|
||||||
|
options: { codemode: false },
|
||||||
|
execute: async (input) => ({ content: await guardTool.run(input, env) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.tool.hook("execute.before", (event) => {
|
||||||
|
const blocked = findBlockedAccess(event.tool, event.input);
|
||||||
|
if (blocked) throw new Error(blockedAccessMessage(blocked));
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.tool.hook("execute.after", async (event) => {
|
||||||
|
if (event.status !== "completed" || event.tool.startsWith("envsitter_")) return;
|
||||||
|
const result = event.tool === "grep" ? redactGrepResult(event.result) : event.result;
|
||||||
|
event.result = scrubToolResult(result, await loadScrub());
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.permission.hook("evaluate", (event) => {
|
||||||
|
const message = permissionDenial(event.action, event.resources);
|
||||||
|
if (!message) return;
|
||||||
|
event.effect = "deny";
|
||||||
|
event.message = message;
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+4168
-28
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter-guard",
|
"name": "envsitter-guard",
|
||||||
"version": "0.0.4",
|
"version": "0.1.0",
|
||||||
"description": "OpenCode plugin that prevents agents/tools from reading or editing sensitive .env* files, while still allowing safe inspection via EnvSitter.",
|
"description": "OpenCode plugin (OpenCode 1 and 2) that prevents agents/tools from reading or editing sensitive .env files, while still allowing safe inspection via EnvSitter.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"homepage": "https://boxpositron.github.io/envsitter/guard/",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
"opencode-plugin",
|
"opencode-plugin",
|
||||||
@@ -40,17 +41,27 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"build:test": "tsc -p tsconfig.test.json",
|
"build:test": "tsc -p tsconfig.test.json",
|
||||||
"test": "npm run build:test && node --test dist-test/test/*.test.js",
|
"test": "npm run build:test && node --test dist-test/test/*.test.js",
|
||||||
|
"test:e2e": "npm run build && node scripts/e2e-opencode.mjs",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.1.14",
|
"@opencode-ai/plugin": "^1.19.3",
|
||||||
"envsitter": "^0.0.4"
|
"envsitter": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opencode/plugin": ">=2.0.32"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opencode/plugin": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "*",
|
"@opencode/plugin": "^2.1.0",
|
||||||
|
"@types/node": "^22.20.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"typescript": "*"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Cross-version end-to-end check: loads the built plugin (dist/) into the real OpenCode 1 and OpenCode 2 CLIs,
|
||||||
|
// drives them with a scripted OpenAI-compatible model, and asserts what the model receives back from each tool.
|
||||||
|
// Everything runs in a temporary HOME with fake secrets, so real config, credentials, and databases are never touched.
|
||||||
|
//
|
||||||
|
// Usage: npm run build && node scripts/e2e-opencode.mjs
|
||||||
|
// Env: OPENCODE_V1_BIN (default "opencode"), OPENCODE_V2_BIN (default "opencode2"), KEEP_E2E=1 to keep the temp dir,
|
||||||
|
// E2E_REQUIRE_ALL=1 to fail (instead of skip) when a CLI is missing, as CI does.
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pluginEntry = path.join(repoRoot, "dist", "index.js");
|
||||||
|
const FAKE_SECRET = "sk-fake-guard-e2e-123";
|
||||||
|
const RUN_TIMEOUT_MS = 240_000;
|
||||||
|
|
||||||
|
if (!fs.existsSync(pluginEntry)) {
|
||||||
|
console.error("dist/index.js is missing. Run `npm run build` first.");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = [
|
||||||
|
{
|
||||||
|
name: "OpenCode 1",
|
||||||
|
major: 1,
|
||||||
|
bin: process.env.OPENCODE_V1_BIN ?? "opencode",
|
||||||
|
args: (model, prompt) => ["run", "--model", model, prompt],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenCode 2",
|
||||||
|
major: 2,
|
||||||
|
bin: process.env.OPENCODE_V2_BIN ?? "opencode2",
|
||||||
|
args: (model, prompt) => ["run", "--standalone", "--auto", "--model", model, prompt],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const textOf = (content) =>
|
||||||
|
typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => part?.text ?? "").join("") : "";
|
||||||
|
const toolNames = (tools) => tools.map((tool) => tool.function?.name).filter(Boolean);
|
||||||
|
const toolProps = (tools, name) => tools.find((tool) => tool.function?.name === name)?.function?.parameters?.properties ?? {};
|
||||||
|
const pathArg = (tools, name, value) => ("filePath" in toolProps(tools, name) ? { filePath: value } : { path: value });
|
||||||
|
const firstTool = (tools, names) => names.find((name) => toolNames(tools).includes(name));
|
||||||
|
|
||||||
|
function scenarios(project) {
|
||||||
|
const envFile = path.join(project, ".env");
|
||||||
|
const envBefore = fs.readFileSync(envFile, "utf8");
|
||||||
|
const noLeak = (result) => !result.includes(FAKE_SECRET);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "envsitter_keys lists keys without values",
|
||||||
|
plan: () => ["envsitter_keys", { filePath: ".env" }],
|
||||||
|
pass: (result) => result.includes("API_TOKEN") && result.includes("DB_PASSWORD") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read .env is blocked",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", envFile)],
|
||||||
|
pass: (result) => result.includes("Reading `.env*` is blocked") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read .env.example is allowed",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", path.join(project, ".env.example"))],
|
||||||
|
pass: (result) => result.includes("replace-me"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write .env is blocked",
|
||||||
|
plan: (tools) => ["write", { ...pathArg(tools, "write", envFile), content: "API_TOKEN=overwritten\n" }],
|
||||||
|
pass: (result) => result.includes("Editing `.env*`") && fs.readFileSync(envFile, "utf8") === envBefore,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shell `cat .env` is blocked",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["bash", "shell"]);
|
||||||
|
if (!name) return undefined;
|
||||||
|
const args = { command: "cat .env" };
|
||||||
|
if ("description" in toolProps(tools, name)) args.description = "Print env file";
|
||||||
|
return [name, args];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Shell commands that reference") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shell output reaching .env values without naming the file is scrubbed",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["bash", "shell"]);
|
||||||
|
if (!name) return undefined;
|
||||||
|
const args = { command: "cat logs/app.log" };
|
||||||
|
if ("description" in toolProps(tools, name)) args.description = "Print app log";
|
||||||
|
return [name, args];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Bearer [envsitter: API_TOKEN redacted]") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read of a non-env file containing a secret is scrubbed",
|
||||||
|
plan: (tools) => ["read", pathArg(tools, "read", path.join(project, "logs", "app.log"))],
|
||||||
|
pass: (result) => result.includes("Bearer [envsitter: API_TOKEN redacted]") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grep hides matches inside .env",
|
||||||
|
plan: () => ["grep", { pattern: "API_TOKEN" }],
|
||||||
|
pass: (result) => result.includes("app.ts") && noLeak(result),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "patch adding .env.local is blocked",
|
||||||
|
model: "mock/gpt-5-scripted",
|
||||||
|
plan: (tools) => {
|
||||||
|
const name = firstTool(tools, ["apply_patch", "patch"]);
|
||||||
|
return name && [name, { patchText: "*** Begin Patch\n*** Add File: .env.local\n+X=1\n*** End Patch" }];
|
||||||
|
},
|
||||||
|
pass: (result) => result.includes("Editing `.env*`") && !fs.existsSync(path.join(project, ".env.local")),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModel(state) {
|
||||||
|
const send = (res, chunks) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||||
|
for (const chunk of chunks) res.write(`data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
const base = { id: "chatcmpl-e2e", object: "chat.completion.chunk", created: 0, model: "scripted" };
|
||||||
|
const finish = (reason) => ({ ...base, choices: [{ index: 0, delta: {}, finish_reason: reason }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
|
||||||
|
const text = (res, content) =>
|
||||||
|
send(res, [{ ...base, choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }] }, finish("stop"), "[DONE]"]);
|
||||||
|
const toolCall = (res, name, args) =>
|
||||||
|
send(res, [
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_e2e_1", type: "function", function: { name, arguments: JSON.stringify(args) } }] },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish("tool_calls"),
|
||||||
|
"[DONE]",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => (raw += chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!req.url?.endsWith("/chat/completions")) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
const tools = body.tools ?? [];
|
||||||
|
const marker = messages.map((m) => (m.role === "user" ? /E2E-SCENARIO:(\d+)/.exec(textOf(m.content))?.[1] : undefined)).find(Boolean);
|
||||||
|
const scenario = marker === undefined ? undefined : state.scenarios[Number(marker)];
|
||||||
|
if (!scenario || tools.length === 0) return text(res, "Guard e2e");
|
||||||
|
|
||||||
|
state.toolNames = toolNames(tools);
|
||||||
|
const last = messages.at(-1);
|
||||||
|
if (last?.role === "tool") {
|
||||||
|
state.result = textOf(last.content);
|
||||||
|
return text(res, "DONE");
|
||||||
|
}
|
||||||
|
const planned = scenario.plan(tools);
|
||||||
|
if (!planned) {
|
||||||
|
state.result = "<<tool unavailable>>";
|
||||||
|
return text(res, "UNAVAILABLE");
|
||||||
|
}
|
||||||
|
state.call = planned;
|
||||||
|
return toolCall(res, planned[0], planned[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(root, baseURL) {
|
||||||
|
const project = path.join(root, "project");
|
||||||
|
fs.mkdirSync(path.join(project, "src"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(project, ".opencode", "plugin"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(project, ".env"), `API_TOKEN=${FAKE_SECRET}\nDB_PASSWORD=fake-db-pass-456\n`);
|
||||||
|
fs.writeFileSync(path.join(project, ".env.example"), "API_TOKEN=replace-me\n");
|
||||||
|
fs.writeFileSync(path.join(project, "src", "app.ts"), "export const token = process.env.API_TOKEN;\n");
|
||||||
|
fs.mkdirSync(path.join(project, "logs"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(project, "logs", "app.log"), `request ok\nauth header: Bearer ${FAKE_SECRET}\n`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(project, ".opencode", "plugin", "envsitter-guard.ts"),
|
||||||
|
`export { default, EnvSitterGuard } from ${JSON.stringify(pluginEntry)};\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(project, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
model: "mock/scripted",
|
||||||
|
small_model: "mock/scripted",
|
||||||
|
provider: {
|
||||||
|
mock: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
name: "Mock",
|
||||||
|
options: { baseURL, apiKey: "e2e" },
|
||||||
|
models: { scripted: { name: "Scripted", tool_call: true }, "gpt-5-scripted": { name: "GPT 5 Scripted", tool_call: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: { edit: "allow", bash: "allow", webfetch: "allow" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
spawnSync("git", ["init", "-q"], { cwd: project });
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolatedEnv(home, project) {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
PWD: project,
|
||||||
|
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||||
|
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||||
|
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
||||||
|
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||||
|
ENVSITTER_PEPPER: "e2e-pepper",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(bin, args, options) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(bin, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||||
|
child.stdout.resume();
|
||||||
|
const timer = setTimeout(() => child.kill("SIGKILL"), RUN_TIMEOUT_MS);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code, stderr });
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: -1, stderr: String(error) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "envsitter-guard-e2e-")));
|
||||||
|
const state = { scenarios: [] };
|
||||||
|
const server = await startModel(state);
|
||||||
|
const baseURL = `http://127.0.0.1:${server.address().port}/v1`;
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
for (const version of versions) {
|
||||||
|
const probe = spawnSync(version.bin, ["--version"], { encoding: "utf8" });
|
||||||
|
const reported = `${probe.stdout ?? ""}`.trim();
|
||||||
|
const major = Number(/(\d+)\.\d+\.\d+/.exec(reported)?.[1]);
|
||||||
|
if (probe.status !== 0 || major !== version.major) {
|
||||||
|
const required = process.env.E2E_REQUIRE_ALL === "1";
|
||||||
|
if (required) failures += 1;
|
||||||
|
console.log(`\n${version.name}: ${required ? "FAIL, missing" : "skipped"} (\`${version.bin} --version\` reported ${JSON.stringify(reported || probe.error?.message || "nothing")})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionRoot = path.join(tempRoot, `v${version.major}`);
|
||||||
|
const project = createProject(versionRoot, baseURL);
|
||||||
|
const env = isolatedEnv(path.join(versionRoot, "home"), project);
|
||||||
|
state.scenarios = scenarios(project);
|
||||||
|
console.log(`\n${version.name} (${reported})`);
|
||||||
|
|
||||||
|
for (const [index, scenario] of state.scenarios.entries()) {
|
||||||
|
state.result = undefined;
|
||||||
|
state.call = undefined;
|
||||||
|
const outcome = await run(version.bin, version.args(scenario.model ?? "mock/scripted", `E2E-SCENARIO:${index}`), { cwd: project, env });
|
||||||
|
const result = state.result ?? "<<no tool result>>";
|
||||||
|
const ok = outcome.code === 0 && result !== "<<tool unavailable>>" && scenario.pass(result);
|
||||||
|
if (!ok) failures += 1;
|
||||||
|
const via = state.call ? ` via ${state.call[0]}` : "";
|
||||||
|
console.log(` ${ok ? "PASS" : "FAIL"} ${scenario.name}${via}`);
|
||||||
|
if (!ok) console.log(` exit=${outcome.code} result=${JSON.stringify(result.slice(0, 300))} stderr=${JSON.stringify(outcome.stderr.slice(-300))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = (state.toolNames ?? []).filter((name) => name.startsWith("envsitter_")).length;
|
||||||
|
const toolsOk = visible === 15;
|
||||||
|
if (!toolsOk) failures += 1;
|
||||||
|
console.log(` ${toolsOk ? "PASS" : "FAIL"} model sees all 15 envsitter_* tools directly (${visible})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
if (process.env.KEEP_E2E === "1") console.log(`\nKept ${tempRoot}`);
|
||||||
|
else fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
|
||||||
|
console.log(failures === 0 ? "\nAll checks passed." : `\n${failures} check(s) failed.`);
|
||||||
|
process.exitCode = failures === 0 ? 0 : 1;
|
||||||
@@ -9,6 +9,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|||||||
import EnvSitterGuard from "../index.js";
|
import EnvSitterGuard from "../index.js";
|
||||||
|
|
||||||
type ToolExecuteBeforeHook = NonNullable<Hooks["tool.execute.before"]>;
|
type ToolExecuteBeforeHook = NonNullable<Hooks["tool.execute.before"]>;
|
||||||
|
type ToolExecuteAfterHook = NonNullable<Hooks["tool.execute.after"]>;
|
||||||
|
|
||||||
async function createTmpDir(): Promise<string> {
|
async function createTmpDir(): Promise<string> {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-"));
|
||||||
@@ -37,7 +38,7 @@ function createClientSpy(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBeforeHook(params: { directory: string; worktree: string }) {
|
async function getHooks(params: { directory: string; worktree: string }) {
|
||||||
const { client, calls } = createClientSpy();
|
const { client, calls } = createClientSpy();
|
||||||
|
|
||||||
const pluginInput: PluginInput = {
|
const pluginInput: PluginInput = {
|
||||||
@@ -45,72 +46,142 @@ async function getBeforeHook(params: { directory: string; worktree: string }) {
|
|||||||
project: {} as unknown as PluginInput["project"],
|
project: {} as unknown as PluginInput["project"],
|
||||||
directory: params.directory,
|
directory: params.directory,
|
||||||
worktree: params.worktree,
|
worktree: params.worktree,
|
||||||
|
experimental_workspace: { register() {} },
|
||||||
serverUrl: new URL("http://localhost"),
|
serverUrl: new URL("http://localhost"),
|
||||||
$: (() => {
|
$: (() => {
|
||||||
throw new Error("not used in tests");
|
throw new Error("not used in tests");
|
||||||
}) as unknown as PluginInput["$"],
|
}) as unknown as PluginInput["$"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const hooks = (await EnvSitterGuard(pluginInput)) as {
|
const hooks = (await EnvSitterGuard.server(pluginInput)) as {
|
||||||
"tool.execute.before": ToolExecuteBeforeHook;
|
"tool.execute.before": ToolExecuteBeforeHook;
|
||||||
|
"tool.execute.after": ToolExecuteAfterHook;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { hook: hooks["tool.execute.before"], calls };
|
return { before: hooks["tool.execute.before"], after: hooks["tool.execute.after"], calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBeforeHook() {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
return getHooks({ directory: worktree, worktree });
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(tool: string) {
|
||||||
|
return { tool, sessionID: "s", callID: "c" };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("blocks reading .env", async () => {
|
test("blocks reading .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
() => before(call("read"), { args: { filePath: ".env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows reading .env.example", async () => {
|
test("allows reading example and template env files", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env.example" } });
|
for (const filePath of [".env.example", ".env.sample", ".env.template", "api.env.example"]) {
|
||||||
|
await before(call("read"), { args: { filePath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks reading named env files such as api.env and config.env.local", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
for (const filePath of ["api.env", "config/config.env.local", "/abs/project/.env.production"]) {
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("read"), { args: { filePath } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
|
filePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks editing .env", async () => {
|
test("blocks editing .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "edit", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }),
|
() => before(call("edit"), { args: { filePath: ".env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
(err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks .envsitter/pepper", async () => {
|
test("blocks apply_patch when the patch touches .env", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
const patchText = ["*** Begin Patch", "*** Update File: src/app.ts", "@@", "*** Add File: .env.local", "+TOKEN=x", "*** End Patch"].join("\n");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".envsitter/pepper" } }),
|
() => before(call("apply_patch"), { args: { patchText } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("`.env.local`"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await before(call("apply_patch"), { args: { patchText: "*** Begin Patch\n*** Update File: src/app.ts\n*** End Patch" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks shell commands that name .env files but allows the envsitter CLI", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("bash"), { args: { command: "cat .env | head -5" } }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Shell commands that reference"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await before(call("bash"), { args: { command: "npx envsitter keys --file .env --json" } });
|
||||||
|
await before(call("bash"), { args: { command: 'git commit -m "document .env usage"' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks .envsitter/pepper", async () => {
|
||||||
|
const { before } = await getBeforeHook();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => before(call("read"), { args: { filePath: ".envsitter/pepper" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips @ prefix in filePath", async () => {
|
test("strips @ prefix in filePath", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before } = await getBeforeHook();
|
||||||
const { hook } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: "@.env" } }),
|
() => before(call("read"), { args: { filePath: "@.env" } }),
|
||||||
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
(err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocking is silent (no toasts)", async () => {
|
test("blocking is silent (no toasts)", async () => {
|
||||||
const worktree = await createTmpDir();
|
const { before, calls } = await getBeforeHook();
|
||||||
const { hook, calls } = await getBeforeHook({ directory: worktree, worktree });
|
|
||||||
|
|
||||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||||
await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }));
|
await assert.rejects(() => before(call("read"), { args: { filePath: ".env" } }));
|
||||||
|
|
||||||
assert.equal(calls.showToast, 0, "should not show toasts, only throw errors");
|
assert.equal(calls.showToast, 0, "should not show toasts, only throw errors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hides grep matches from protected env files", async () => {
|
||||||
|
const { after } = await getBeforeHook();
|
||||||
|
const output = {
|
||||||
|
title: "TOKEN",
|
||||||
|
metadata: {},
|
||||||
|
output: ["Found 3 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 4: process.env.TOKEN", " Line 9: TOKEN"].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
await after({ ...call("grep"), args: { pattern: "TOKEN" } }, output);
|
||||||
|
|
||||||
|
assert.ok(!output.output.includes("super-secret"));
|
||||||
|
assert.ok(output.output.startsWith("Found 2 matches"));
|
||||||
|
assert.ok(output.output.includes("/repo/src/app.ts:"));
|
||||||
|
assert.ok(output.output.includes("1 match in protected .env files hidden"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrubs secret values from any tool output, such as shell commands that never name .env", async () => {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
await fs.writeFile(path.join(worktree, ".env"), "API_TOKEN=tok_live_9f8a7b6c5d4e\nNODE_ENV=development\n");
|
||||||
|
const { after } = await getHooks({ directory: worktree, worktree });
|
||||||
|
|
||||||
|
const output = { title: "cat logs/app.log", metadata: {}, output: "env=development\ncalling api with tok_live_9f8a7b6c5d4e\n" };
|
||||||
|
await after({ ...call("bash"), args: { command: "cat logs/app.log" } }, output);
|
||||||
|
|
||||||
|
assert.equal(output.output, "env=development\ncalling api with [envsitter: API_TOKEN redacted]\n");
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,13 +125,14 @@ async function getTools(params: { directory: string; worktree: string }): Promis
|
|||||||
project: {} as unknown as PluginInput["project"],
|
project: {} as unknown as PluginInput["project"],
|
||||||
directory: params.directory,
|
directory: params.directory,
|
||||||
worktree: params.worktree,
|
worktree: params.worktree,
|
||||||
|
experimental_workspace: { register() {} },
|
||||||
serverUrl: new URL("http://localhost"),
|
serverUrl: new URL("http://localhost"),
|
||||||
$: (() => {
|
$: (() => {
|
||||||
throw new Error("not used in tests");
|
throw new Error("not used in tests");
|
||||||
}) as unknown as PluginInput["$"],
|
}) as unknown as PluginInput["$"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const hooks = (await EnvSitterGuard(pluginInput)) as unknown as {
|
const hooks = (await EnvSitterGuard.server(pluginInput)) as unknown as {
|
||||||
tool: ToolApi;
|
tool: ToolApi;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +306,24 @@ test("tool execution rejects paths outside worktree", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("envsitter tools accept named env files such as api.env", async () => {
|
||||||
|
await withPepper(async () => {
|
||||||
|
const worktree = await createTmpDir();
|
||||||
|
await fs.writeFile(path.join(worktree, "api.env"), "API_TOKEN=secret-token\n");
|
||||||
|
|
||||||
|
const tools = await getTools({ directory: worktree, worktree });
|
||||||
|
const out = await tools.envsitter_keys.execute({ filePath: "api.env" });
|
||||||
|
|
||||||
|
assert.ok(!out.includes("secret-token"));
|
||||||
|
assert.deepEqual((JSON.parse(out) as { keys: string[] }).keys, ["API_TOKEN"]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => tools.envsitter_keys.execute({ filePath: "src/index.ts" }),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("Only dotenv files are allowed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("tool execution blocks .envsitter/pepper", async () => {
|
test("tool execution blocks .envsitter/pepper", async () => {
|
||||||
const worktree = await createTmpDir();
|
const worktree = await createTmpDir();
|
||||||
const tools = await getTools({ directory: worktree, worktree });
|
const tools = await getTools({ directory: worktree, worktree });
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { Plugin } from "@opencode/plugin";
|
||||||
|
|
||||||
|
import EnvSitterGuard from "../index.js";
|
||||||
|
|
||||||
|
type RegisteredTool = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
options?: { codemode?: boolean };
|
||||||
|
execute: (input: unknown, context: unknown) => Promise<{ content?: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HookCallback = (event: Record<string, unknown>) => unknown;
|
||||||
|
|
||||||
|
const registration = { dispose: async () => {} };
|
||||||
|
|
||||||
|
async function setupV2(directory: string, root = directory) {
|
||||||
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const toolHooks = new Map<string, HookCallback>();
|
||||||
|
const permissionHooks = new Map<string, HookCallback>();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
location: { directory, project: { id: "project", directory: root, canonical: root } },
|
||||||
|
options: {},
|
||||||
|
tool: {
|
||||||
|
async transform(callback: (editor: { add(tool: RegisteredTool): void }) => void) {
|
||||||
|
callback({ add: (tool) => tools.set(tool.name, tool) });
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
async hook(name: string, callback: HookCallback) {
|
||||||
|
toolHooks.set(name, callback);
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
async hook(name: string, callback: HookCallback) {
|
||||||
|
permissionHooks.set(name, callback);
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Plugin.Context;
|
||||||
|
|
||||||
|
await EnvSitterGuard.setup(ctx);
|
||||||
|
|
||||||
|
const hook = (map: Map<string, HookCallback>, name: string): HookCallback => {
|
||||||
|
const callback = map.get(name);
|
||||||
|
assert.ok(callback, `missing hook ${name}`);
|
||||||
|
return callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools,
|
||||||
|
before: hook(toolHooks, "execute.before"),
|
||||||
|
after: hook(toolHooks, "execute.after"),
|
||||||
|
evaluate: hook(permissionHooks, "evaluate"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(files: Record<string, string>): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-v2-"));
|
||||||
|
for (const [name, contents] of Object.entries(files)) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, name), contents);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEvent(tool: string, input: Record<string, unknown>) {
|
||||||
|
return { tool, sessionID: "ses_1", agent: "build", messageID: "msg_1", id: "call_1", input };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("V2 default export carries a stable id plus both entrypoints", () => {
|
||||||
|
assert.equal(EnvSitterGuard.id, "envsitter-guard");
|
||||||
|
assert.equal(typeof EnvSitterGuard.setup, "function");
|
||||||
|
assert.equal(typeof EnvSitterGuard.server, "function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 registers every envsitter tool directly (not behind Code Mode) with JSON Schema inputs", async () => {
|
||||||
|
const { tools } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
assert.equal(tools.size, 15);
|
||||||
|
for (const tool of tools.values()) {
|
||||||
|
assert.ok(tool.name.startsWith("envsitter_"), tool.name);
|
||||||
|
assert.equal(tool.options?.codemode, false, tool.name);
|
||||||
|
assert.equal(tool.input.type, "object", tool.name);
|
||||||
|
assert.equal("$schema" in tool.input, false, tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = tools.get("envsitter_fingerprint");
|
||||||
|
assert.deepEqual(fingerprint?.input.required, ["key"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 tools return structured content without values and validate input", async () => {
|
||||||
|
const dir = await createProject({ ".env": "API_TOKEN=very-secret\nPORT=3000\n" });
|
||||||
|
const { tools } = await setupV2(dir);
|
||||||
|
|
||||||
|
const keys = tools.get("envsitter_keys");
|
||||||
|
assert.ok(keys);
|
||||||
|
const result = await keys.execute({ filePath: ".env" }, {});
|
||||||
|
assert.equal(typeof result.content, "string");
|
||||||
|
assert.ok(!String(result.content).includes("very-secret"));
|
||||||
|
assert.deepEqual((JSON.parse(String(result.content)) as { keys: string[] }).keys, ["API_TOKEN", "PORT"]);
|
||||||
|
|
||||||
|
const fingerprint = tools.get("envsitter_fingerprint");
|
||||||
|
assert.ok(fingerprint);
|
||||||
|
await assert.rejects(() => fingerprint.execute({ filePath: ".env" }, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 tools resolve paths from the plugin location and stay inside the project", async () => {
|
||||||
|
const root = await createProject({ "apps/api/.env": "A=1\n" });
|
||||||
|
const { tools } = await setupV2(path.join(root, "apps", "api"), root);
|
||||||
|
|
||||||
|
const keys = tools.get("envsitter_keys");
|
||||||
|
assert.ok(keys);
|
||||||
|
const result = await keys.execute({}, {});
|
||||||
|
assert.equal((JSON.parse(String(result.content)) as { file: string }).file, path.join("apps", "api", ".env"));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => keys.execute({ filePath: "../../../outside/.env" }, {}),
|
||||||
|
(err: unknown) => err instanceof Error && err.message.includes("inside the current project"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.before blocks built-in tools on protected files, including nested Code Mode calls", async () => {
|
||||||
|
const { before } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
assert.throws(() => before(toolEvent("read", { path: ".env" })), /Reading `\.env\*` is blocked/);
|
||||||
|
assert.throws(() => before(toolEvent("write", { path: "api.env", content: "X=1" })), /Editing `\.env\*`/);
|
||||||
|
assert.throws(
|
||||||
|
() => before(toolEvent("patch", { patchText: "*** Begin Patch\n*** Delete File: .env.production\n*** End Patch" })),
|
||||||
|
/Editing `\.env\*`/,
|
||||||
|
);
|
||||||
|
assert.throws(() => before(toolEvent("shell", { command: "source .env && env" })), /Shell commands that reference/);
|
||||||
|
assert.throws(() => before(toolEvent("grep", { pattern: "TOKEN", path: ".env.local" })), /Reading `\.env\*` is blocked/);
|
||||||
|
assert.throws(() => before(toolEvent("filesystem_read_file", { path: "/abs/.env" })), /through `filesystem_read_file`/);
|
||||||
|
|
||||||
|
before(toolEvent("read", { path: ".env.example" }));
|
||||||
|
before(toolEvent("read", { path: "src/index.ts" }));
|
||||||
|
before(toolEvent("shell", { command: "npm test" }));
|
||||||
|
before(toolEvent("envsitter_keys", { filePath: ".env" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.after hides grep matches from protected files in content and structured output", async () => {
|
||||||
|
const { after } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
...toolEvent("grep", { pattern: "TOKEN" }),
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
content: ["Found 2 matches", "/repo/.env:", " Line 1: TOKEN=super-secret", "", "/repo/src/app.ts:", " Line 3: TOKEN"].join("\n"),
|
||||||
|
output: [
|
||||||
|
{ entry: { path: ".env" }, line: 1, text: "TOKEN=super-secret" },
|
||||||
|
{ entry: { path: "src/app.ts" }, line: 3, text: "TOKEN" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await after(event);
|
||||||
|
|
||||||
|
const result = event.result as { content: string; output: Array<{ entry: { path: string } }> };
|
||||||
|
assert.ok(!result.content.includes("super-secret"));
|
||||||
|
assert.ok(result.content.startsWith("Found 1 matches"));
|
||||||
|
assert.deepEqual(
|
||||||
|
result.output.map((match) => match.entry.path),
|
||||||
|
["src/app.ts"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 execute.after scrubs secret values from shell results that never name .env", async () => {
|
||||||
|
const { after } = await setupV2(await createProject({ ".env": "API_TOKEN=tok_live_9f8a7b6c5d4e\n" }));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
...toolEvent("shell", { command: "grep -r api ." }),
|
||||||
|
status: "completed",
|
||||||
|
result: { content: [{ type: "text", text: "./logs/app.log: api key tok_live_9f8a7b6c5d4e" }] },
|
||||||
|
};
|
||||||
|
await after(event);
|
||||||
|
|
||||||
|
const result = event.result as { content: Array<{ text: string }> };
|
||||||
|
assert.equal(result.content[0]?.text, "./logs/app.log: api key [envsitter: API_TOKEN redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("V2 permission evaluate denies protected read, edit, and shell resources only", async () => {
|
||||||
|
const { evaluate } = await setupV2(await createProject({}));
|
||||||
|
|
||||||
|
const deniedRead = { sessionID: "ses_1", action: "read", resources: ["/repo/.env"], effect: "allow" as string, message: undefined as string | undefined };
|
||||||
|
await evaluate(deniedRead);
|
||||||
|
assert.equal(deniedRead.effect, "deny");
|
||||||
|
assert.match(deniedRead.message ?? "", /Reading `\.env\*` is blocked/);
|
||||||
|
|
||||||
|
const deniedShell = { sessionID: "ses_1", action: "shell", resources: ["cat .env.local"], effect: "ask" as string };
|
||||||
|
await evaluate(deniedShell);
|
||||||
|
assert.equal(deniedShell.effect, "deny");
|
||||||
|
|
||||||
|
const allowed = { sessionID: "ses_1", action: "edit", resources: ["/repo/src/app.ts", "/repo/.env.example"], effect: "allow" as string };
|
||||||
|
await evaluate(allowed);
|
||||||
|
assert.equal(allowed.effect, "allow");
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { extractPatchPaths, findBlockedAccess, findProtectedPathsInCommand, redactGrepOutput } from "../lib/policy.js";
|
||||||
|
|
||||||
|
test("shell scan flags commands that name protected files", () => {
|
||||||
|
const blocked = [
|
||||||
|
"cat .env",
|
||||||
|
"source .env",
|
||||||
|
". .env.local",
|
||||||
|
"echo TOKEN=x >> .env",
|
||||||
|
"cat<.env",
|
||||||
|
"node --env-file=.env app.js",
|
||||||
|
'echo "$(cat .env)"',
|
||||||
|
"echo `cat api.env`",
|
||||||
|
"cat .env*",
|
||||||
|
"cat .envsitter/pepper",
|
||||||
|
"ls && cat config/app.env",
|
||||||
|
"envsitter keys --file .env; cat .env",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const command of blocked) {
|
||||||
|
assert.ok(findProtectedPathsInCommand(command).length > 0, command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shell scan allows envsitter CLI calls, example files, and prose mentions", () => {
|
||||||
|
const allowed = [
|
||||||
|
"npx envsitter keys --file .env",
|
||||||
|
"npx -y envsitter@latest match --file .env --key A --op exists",
|
||||||
|
"envsitter set --file .env.local --key A --value-stdin --write",
|
||||||
|
"pnpm dlx envsitter validate --file .env",
|
||||||
|
'git commit -m "document .env usage"',
|
||||||
|
"cat .env.example",
|
||||||
|
"cp .env.example .env.sample",
|
||||||
|
"ls -la",
|
||||||
|
"npm test",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const command of allowed) {
|
||||||
|
assert.deepEqual(findProtectedPathsInCommand(command), [], command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patch path extraction covers patch envelopes and unified diffs", () => {
|
||||||
|
const envelope = ["*** Begin Patch", "*** Update File: src/a.ts", "*** Move to: src/b.ts", "*** Add File: .env", "*** Delete File: old.env", "*** End Patch"].join("\n");
|
||||||
|
assert.deepEqual(extractPatchPaths(envelope), ["src/a.ts", "src/b.ts", ".env", "old.env"]);
|
||||||
|
|
||||||
|
const unified = ["--- a/.env.local", "+++ b/.env.local", "@@ -1 +1 @@", "-A=1", "+A=2"].join("\n");
|
||||||
|
assert.deepEqual(extractPatchPaths(unified), [".env.local", ".env.local"]);
|
||||||
|
assert.deepEqual(extractPatchPaths("--- /dev/null\n+++ b/src/new.ts"), ["src/new.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findBlockedAccess reads path arguments from V1 and V2 tool shapes", () => {
|
||||||
|
assert.equal(findBlockedAccess("read", { filePath: ".env" })?.kind, "read");
|
||||||
|
assert.equal(findBlockedAccess("read", { path: ".env" })?.kind, "read");
|
||||||
|
assert.equal(findBlockedAccess("multi_read", { paths: ["a.ts", ".env.production"] })?.kind, "access");
|
||||||
|
assert.equal(findBlockedAccess("envsitter_keys", { filePath: ".env" }), undefined);
|
||||||
|
assert.equal(findBlockedAccess("read", "not-an-object"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grep redaction is a no-op when nothing is protected", () => {
|
||||||
|
const output = "Found 1 matches\n/repo/src/a.ts:\n Line 1: TOKEN";
|
||||||
|
assert.deepEqual(redactGrepOutput(output), { output, hidden: 0 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { createScrubLoader, findSensitiveEnvFiles, scrubToolResult } from "../lib/scrub.js";
|
||||||
|
|
||||||
|
const TOKEN = "tok_live_9f8a7b6c5d4e";
|
||||||
|
const DB_PASSWORD = "s3cretPassw0rd";
|
||||||
|
|
||||||
|
async function createProject(files: Record<string, string>): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-scrub-"));
|
||||||
|
for (const [name, contents] of Object.entries(files)) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, name), contents);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("finds sensitive env files in the project, skipping examples and dependency folders", async () => {
|
||||||
|
const root = await createProject({
|
||||||
|
".env": `API_TOKEN=${TOKEN}\n`,
|
||||||
|
".env.example": "API_TOKEN=replace-me\n",
|
||||||
|
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||||
|
"node_modules/pkg/.env": "X=1\n",
|
||||||
|
"a/b/c/d/.env": "TOO_DEEP=1\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = (await findSensitiveEnvFiles(root)).map((file) => path.relative(root, file));
|
||||||
|
assert.deepEqual(files, [".env", path.join("apps", "web", ".env.local")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrub replaces secret values from every env file and leaves other text alone", async () => {
|
||||||
|
const root = await createProject({
|
||||||
|
".env": `API_TOKEN=${TOKEN}\nNODE_ENV=development\n`,
|
||||||
|
"apps/web/.env.local": `DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app\n`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrub = await createScrubLoader(root)();
|
||||||
|
const output = scrub(`Bearer ${TOKEN}\npassword=${DB_PASSWORD}\nNODE_ENV=development`);
|
||||||
|
assert.equal(output, "Bearer [envsitter: API_TOKEN redacted]\npassword=[envsitter: DATABASE_URL redacted]\nNODE_ENV=development");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrub picks up env file changes", async () => {
|
||||||
|
const root = await createProject({ ".env": `API_TOKEN=${TOKEN}\n` });
|
||||||
|
const loadScrub = createScrubLoader(root);
|
||||||
|
assert.ok(!(await loadScrub())(TOKEN).includes(TOKEN));
|
||||||
|
|
||||||
|
const rotated = "tok_live_0a1b2c3d4e5f6a7b8c";
|
||||||
|
await fs.writeFile(path.join(root, ".env"), `API_TOKEN=${rotated}\n`);
|
||||||
|
const scrub = await loadScrub();
|
||||||
|
assert.equal(scrub(rotated), "[envsitter: API_TOKEN redacted]");
|
||||||
|
assert.equal(scrub(TOKEN), TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrubToolResult scrubs text content and structured output, and keeps untouched results identical", () => {
|
||||||
|
const scrub = (text: string) => text.replaceAll(TOKEN, "[x]");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "text", text: `token ${TOKEN}` }, { type: "image", data: TOKEN }],
|
||||||
|
output: { stdout: `ok ${TOKEN}`, exitCode: 0, lines: [TOKEN, "fine"] },
|
||||||
|
metadata: { title: "t" },
|
||||||
|
};
|
||||||
|
const scrubbed = scrubToolResult(result, scrub);
|
||||||
|
assert.deepEqual(scrubbed.content, [{ type: "text", text: "token [x]" }, { type: "image", data: TOKEN }]);
|
||||||
|
assert.deepEqual(scrubbed.output, { stdout: "ok [x]", exitCode: 0, lines: ["[x]", "fine"] });
|
||||||
|
assert.equal(scrubbed.metadata, result.metadata);
|
||||||
|
|
||||||
|
const clean = { content: "nothing secret", output: { exitCode: 0 } };
|
||||||
|
assert.equal(scrubToolResult(clean, scrub), clean);
|
||||||
|
});
|
||||||
+1
-1
@@ -8,5 +8,5 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["index.ts", ".opencode/plugin/**/*.ts"]
|
"include": ["index.ts", ".opencode/plugin/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user