diff --git a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs index 15f0d3a..205d2e2 100644 --- a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs +++ b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs @@ -17,11 +17,15 @@ import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // Sensitive path patterns — 8 categories // --------------------------------------------------------------------------- -/** Category 1: Environment files */ +/** Category 1: Environment files + * Matches `.env` and any multi-segment suffix after it, e.g. + * `.env.local`, `.env.production.local.backup`, `.env.stage-1.local`, + * `.env.CI.secret`. Does NOT match `.envrc` (direnv) — no dot separator. + * v7.1.0 B1 fix: previous regex `/[\\/]\.env\.[a-z]+$/` only matched a + * single lowercase segment after `.env`. + */ const ENV_PATTERNS = [ - /[\\/]\.env$/, - /[\\/]\.env\.[a-z]+$/, // .env.local, .env.production, etc. - /[\\/]\.env\.local$/, + /[\\/]\.env(\.[A-Za-z0-9._-]+)*$/, ]; /** Category 2: SSH directory */ diff --git a/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs b/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs index 7f9b716..aec4bd9 100644 --- a/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs +++ b/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs @@ -36,6 +36,43 @@ describe('pre-write-pathguard — BLOCK cases', () => { 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); @@ -117,6 +154,12 @@ describe('pre-write-pathguard — ALLOW cases', () => { 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);