The previous ENV regex `/[\\/]\.env\.[a-z]+$/` only matched a single lowercase segment after `.env`. Multi-segment and mixed-case variants such as `.env.production.local.backup`, `.env.stage-1.local`, and `.env.CI.secret` slipped past the hook. Replaced with `/[\\/]\.env(\.[A-Za-z0-9._-]+)*$/` which matches `.env` plus any number of dot-separated alphanumeric/dot/hyphen/underscore segments. `.envrc` (direnv config, no dot separator) is still allowed. Addresses critical review 2026-04-20 §2 B1 (HIGH). Tests: 7 added (6 new multi-segment BLOCK cases + 1 .envrc ALLOW). All 1494 tests pass.
172 lines
7 KiB
JavaScript
172 lines
7 KiB
JavaScript
// pre-write-pathguard.test.mjs — Tests for hooks/scripts/pre-write-pathguard.mjs
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve } from 'node:path';
|
|
import { runHook } from './hook-helper.mjs';
|
|
|
|
const SCRIPT = resolve(import.meta.dirname, '../../hooks/scripts/pre-write-pathguard.mjs');
|
|
|
|
function writePayload(filePath) {
|
|
return { tool_name: 'Write', tool_input: { file_path: filePath, content: 'data' } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BLOCK cases — exit code 2
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-write-pathguard — BLOCK cases', () => {
|
|
it('blocks a write to .env (environment file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /env/);
|
|
});
|
|
|
|
it('blocks a write to .env.local (environment file variant)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.local'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.production (environment file variant)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.production'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
// B1 regression — multi-segment .env.*.*.* must be blocked (v7.1.0)
|
|
it('blocks a write to .env.production.local.backup (multi-segment env)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.production.local.backup'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.dev.local.old (multi-segment env)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.dev.local.old'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.prod.local.bak (multi-segment env)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.prod.local.bak'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.stage-1.local (hyphen + digit in segment)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.stage-1.local'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.CI.secret (uppercase segment)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.CI.secret'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .env.A.B.C.D (many short uppercase segments)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.env.A.B.C.D'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .ssh/id_rsa (SSH directory)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/home/user/.ssh/id_rsa'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /ssh/i);
|
|
});
|
|
|
|
it('blocks a write to .aws/credentials (AWS credentials directory)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/home/user/.aws/credentials'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /aws/i);
|
|
});
|
|
|
|
it('blocks a write to .gnupg/private-keys-v1.d/key (GPG directory)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/home/user/.gnupg/private-keys-v1.d/key'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /gnupg/i);
|
|
});
|
|
|
|
it('blocks a write to .npmrc (credential file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/home/user/.npmrc'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to credentials.json (credential file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/credentials.json'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to .claude/settings.json (Claude settings file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/home/user/.claude/settings.json'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /settings/i);
|
|
});
|
|
|
|
it('blocks a write to .vscode/settings.json (VS Code settings file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.vscode/settings.json'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
});
|
|
|
|
it('blocks a write to /etc/passwd (system directory)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/etc/passwd'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /system/i);
|
|
});
|
|
|
|
it('blocks a write to a hooks/scripts/*.mjs path (hook script protection)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/hooks/scripts/my-hook.mjs'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /PATH GUARD/);
|
|
assert.match(result.stderr, /hooks/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ALLOW cases — exit code 0
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-write-pathguard — ALLOW cases', () => {
|
|
it('allows a write to a normal source file (src/app.js)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('src/app.js'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows a write to README.md', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('README.md'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows a write to settings.json at the project root (not inside .claude/ or .vscode/)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/settings.json'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
// B1 negative — direnv's .envrc (no dot-suffix) must not be blocked (v7.1.0)
|
|
it('allows a write to .envrc (direnv config, not a dotenv file)', async () => {
|
|
const result = await runHook(SCRIPT, writePayload('/project/.envrc'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows a write when file_path is empty', async () => {
|
|
const result = await runHook(SCRIPT, { tool_name: 'Write', tool_input: { file_path: '', content: 'x' } });
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('exits 0 gracefully when stdin is not valid JSON', async () => {
|
|
const result = await runHook(SCRIPT, 'not json {{{');
|
|
assert.equal(result.code, 0);
|
|
});
|
|
});
|