ktg-plugin-marketplace/plugins/ultraplan-local/tests/hooks/path-guard.test.mjs
Kjell Tore Guttormsen 67240f01f6 test(ultraplan-local): add path-guard + bash-guard baseline hook tests (SC8 baseline)
Pins existing BLOCK rules in the two pre-* executor hooks so a future
silent weakening of BLOCK_RULES surfaces as test failures instead of
slipping through code review.

50 new tests covering both hooks plus allow-list pins (lib/, tests/,
docs/, ls, git, npm) and fail-open on malformed input. Reuses
tests/helpers/hook-helper.mjs child-process spawner.

[skip-docs]
2026-05-04 08:55:49 +02:00

177 lines
8.2 KiB
JavaScript

// tests/hooks/path-guard.test.mjs
// Step 18 (plan-v2) — pins pre-write-executor.mjs BLOCK rules so a future
// silent weakening of the BLOCK_RULES list shows up as test failures
// instead of slipping through code review.
//
// Coverage: every BLOCK rule named in pre-write-executor.mjs gets at least
// one test. Allowlist examples (regular file paths, lib modules) confirm
// the hook does not over-block.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
function writeInput(file_path, content = 'x') {
return { tool_name: 'Write', tool_input: { file_path, content } };
}
// -----------------------------------------------------------------------
// BLOCK — Git hook injection (.git/hooks/)
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .git/hooks/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/tmp/repo/.git/hooks/pre-commit'));
assert.strictEqual(code, 2, 'BLOCK exit code 2 expected for .git/hooks/ writes');
assert.match(stderr, /Git hook injection/, 'BLOCK message should reference the rule name');
});
test('pre-write-executor BLOCKS deeper .git/hooks/ paths', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.git/hooks/post-receive'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Claude settings self-modification
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .claude/settings.json writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/settings.json'));
assert.strictEqual(code, 2);
assert.match(stderr, /Claude settings/);
});
test('pre-write-executor BLOCKS .claude/settings.local.json writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/settings.local.json'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Claude hooks self-modification
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .claude/hooks/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/hooks/some-hook.mjs'));
assert.strictEqual(code, 2);
assert.match(stderr, /Claude hooks/);
});
test('pre-write-executor BLOCKS .claude-plugin/ writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude-plugin/plugin.json'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Shell configuration files
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.zshrc writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.zshrc`));
assert.strictEqual(code, 2);
assert.match(stderr, /Shell configuration/);
});
test('pre-write-executor BLOCKS ~/.bashrc writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.bashrc`));
assert.strictEqual(code, 2);
});
test('pre-write-executor BLOCKS ~/.zshenv writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.zshenv`));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — SSH directory
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.ssh/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.ssh/id_rsa`));
assert.strictEqual(code, 2);
assert.match(stderr, /SSH/);
});
test('pre-write-executor BLOCKS ~/.ssh/config writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.ssh/config`));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — AWS credentials
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.aws/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.aws/credentials`));
assert.strictEqual(code, 2);
assert.match(stderr, /AWS/);
});
// -----------------------------------------------------------------------
// BLOCK — GnuPG directory
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.gnupg/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.gnupg/private-keys-v1.d/foo`));
assert.strictEqual(code, 2);
assert.match(stderr, /GnuPG/);
});
// -----------------------------------------------------------------------
// BLOCK — Environment files (.env)
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .env writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.env'));
assert.strictEqual(code, 2);
assert.match(stderr, /Environment files/);
});
test('pre-write-executor BLOCKS .env.production writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.production'));
assert.strictEqual(code, 2);
});
test('pre-write-executor BLOCKS .env.local writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.local'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// ALLOW — legitimate paths must not be blocked (over-block regression)
// -----------------------------------------------------------------------
test('pre-write-executor ALLOWS legitimate lib module writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/lib/util/foo.mjs'));
assert.strictEqual(code, 0, 'legitimate lib writes must not be blocked');
});
test('pre-write-executor ALLOWS test file writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/tests/lib/foo.test.mjs'));
assert.strictEqual(code, 0);
});
test('pre-write-executor ALLOWS docs writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/docs/architecture.md'));
assert.strictEqual(code, 0);
});
test('pre-write-executor BLOCKS .env.template writes (current over-block behavior — pin)', async () => {
// The current .env regex (/\/\.env(?:\.[a-zA-Z0-9]+)?$/) blocks .env.X for
// ALL alphanumeric X, including the safe `.template` convention. This test
// pins the over-block as a known limitation. Loosening the rule to permit
// `.env.template` (e.g. via an allowlist) is fine — but it should be a
// deliberate change, not a silent weakening of BLOCK_RULES. If this test
// starts failing, that is the trigger to revisit the regex intentionally.
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.template'));
assert.strictEqual(code, 2, 'current behavior pin: .env.template is blocked. If you intend to allow it, update both the hook and this test together.');
});
// -----------------------------------------------------------------------
// FAIL OPEN — malformed input must not crash the hook chain
// -----------------------------------------------------------------------
test('pre-write-executor fails open on missing file_path', async () => {
const { code } = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: {} });
assert.strictEqual(code, 0, 'missing file_path should fail open (exit 0)');
});
test('pre-write-executor fails open on malformed JSON', async () => {
const { code } = await runHook(PRE_WRITE, 'not-json');
assert.strictEqual(code, 0, 'malformed JSON should fail open (exit 0)');
});