ktg-plugin-marketplace/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs
Kjell Tore Guttormsen 8ec320f40c feat(governance): add policy-as-code — .llm-security/policy.json for distributable hook configuration
New policy-loader.mjs reads .llm-security/policy.json with deep-merge against
defaults that exactly match existing hardcoded values. Integrated into all 7 hooks:
- pre-prompt-inject-scan: injection.mode (env var still takes precedence)
- post-session-guard: trifecta.mode, window_size, long_horizon_window
- pre-edit-secrets: secrets.additional_patterns
- pre-bash-destructive: destructive.additional_blocked
- pre-write-pathguard: pathguard.additional_protected
- pre-install-supply-chain: supply_chain.additional_blocked_packages
- post-mcp-verify: mcp.volume_threshold_bytes, mcp.trusted_servers

Backward compatible: no policy file = identical behavior to v5.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:37:02 +02:00

192 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// Hook: pre-write-pathguard.mjs
// Event: PreToolUse (Write)
// Purpose: Block writes to sensitive paths (.env, .ssh/, .aws/, credentials, etc.)
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - tool_input.file_path — destination path
// - Block: stderr + exit 2
// - Allow: exit 0
import { readFileSync } from 'node:fs';
import { basename, normalize, resolve } from 'node:path';
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
// ---------------------------------------------------------------------------
// Sensitive path patterns — 8 categories
// ---------------------------------------------------------------------------
/** Category 1: Environment files */
const ENV_PATTERNS = [
/[\\/]\.env$/,
/[\\/]\.env\.[a-z]+$/, // .env.local, .env.production, etc.
/[\\/]\.env\.local$/,
];
/** Category 2: SSH directory */
const SSH_PATTERNS = [
/[\\/]\.ssh[\\/]/,
];
/** Category 3: AWS credentials */
const AWS_PATTERNS = [
/[\\/]\.aws[\\/]/,
];
/** Category 4: GPG directory */
const GPG_PATTERNS = [
/[\\/]\.gnupg[\\/]/,
];
/** Category 5: Credential files */
const CREDENTIAL_FILES = [
'.npmrc',
'.pypirc',
'.netrc',
'.docker/config.json',
'credentials.json',
'service-account.json',
'keyfile.json',
];
/** Category 6: Hook scripts (prevent hook tampering) */
const HOOK_PATTERNS = [
/[\\/]\.claude[\\/].*hooks.*\.json$/,
/[\\/]hooks[\\/]scripts[\\/].*\.mjs$/,
];
/** Category 7: System directories */
const SYSTEM_PATTERNS = [
/^\/etc[\\/]/,
/^\/usr[\\/]/,
/^\/var[\\/]/,
];
/** Category 8: Settings files */
const SETTINGS_FILES = [
'settings.json',
'settings.local.json',
];
/** Category 9: Policy-defined additional protected paths */
const POLICY_PATTERNS = getPolicyValue('pathguard', 'additional_protected', []).map(p => new RegExp(p));
// ---------------------------------------------------------------------------
// Path classification
// ---------------------------------------------------------------------------
/**
* Check if a file path targets a sensitive location.
* @param {string} filePath - The path to check
* @returns {{ blocked: boolean, category: string, reason: string }}
*/
function classifyPath(filePath) {
if (!filePath) return { blocked: false, category: '', reason: '' };
const norm = normalize(resolve(filePath));
const base = basename(norm);
// Category 1: Environment files
for (const pat of ENV_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'env', reason: `Environment file: ${base}` };
}
}
// Category 2: SSH
for (const pat of SSH_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'ssh', reason: `SSH directory: ${norm}` };
}
}
// Category 3: AWS
for (const pat of AWS_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'aws', reason: `AWS credentials directory: ${norm}` };
}
}
// Category 4: GPG
for (const pat of GPG_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'gnupg', reason: `GPG directory: ${norm}` };
}
}
// Category 5: Credential files
for (const name of CREDENTIAL_FILES) {
if (norm.endsWith(name) || base === name) {
return { blocked: true, category: 'credentials', reason: `Credential file: ${base}` };
}
}
// Category 6: Hook scripts
for (const pat of HOOK_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'hooks', reason: `Hook configuration: ${base}` };
}
}
// Category 7: System directories
for (const pat of SYSTEM_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'system', reason: `System directory: ${norm}` };
}
}
// Category 8: Settings files
for (const name of SETTINGS_FILES) {
if (base === name) {
// Only block settings.json in .claude/ directories
if (/[\\/]\.claude[\\/]/.test(norm) || /[\\/]\.vscode[\\/]/.test(norm)) {
return { blocked: true, category: 'settings', reason: `Settings file: ${norm}` };
}
}
}
// Category 9: Policy-defined additional protected paths
for (const pat of POLICY_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'policy', reason: `Policy-protected path: ${norm}` };
}
}
return { blocked: false, category: '', reason: '' };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
process.exit(0);
}
const toolInput = input?.tool_input ?? {};
const filePath = toolInput.file_path ?? '';
if (!filePath) {
process.exit(0);
}
const result = classifyPath(filePath);
if (result.blocked) {
process.stderr.write(
`\n[llm-security] PATH GUARD: Write blocked\n` +
` Category: ${result.category}\n` +
` Reason: ${result.reason}\n` +
` Path: ${filePath}\n\n` +
`This path is protected. If this write is intentional, ` +
`ask the user to perform it manually.\n`
);
process.exit(2);
}
process.exit(0);