S2 of the 2.1.181 upgrade. Schemas verified verbatim against the official
slash-commands and hooks docs before editing (a first-pass camelCase
'disallowedTools' claim was caught and corrected to kebab-case against the doc).
- CC-14 (SHIP): migrate all 7 hooks in hooks/hooks.json to exec-form
{command:"node", args:["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/X.mjs"]}. Doc
recommends exec-form whenever a hook references a path placeholder; protects
consumers installing under a path with spaces. ${CLAUDE_PLUGIN_ROOT}
interpolates inside args (verified). hooks-json-stop-wired test made
form-agnostic (normalizes command+args to one invocation string).
- CC-11 (SHIP): add `disallowed-tools: Agent, TeamCreate` to trekexecute
frontmatter, enforcing its documented "No Agent tool, no TeamCreate" rule.
allowed-tools grants auto-approval but does NOT remove tools from the pool,
so the prior omission left Agent callable; disallowed-tools removes it.
trekexecute is the only command with a documented exclusion.
- CC-15 (DECIDE: keep universal): re-affirm F2 deferral. pre-bash/pre-write
executors stay universal -- session-agnostic safety (rm -rf /, ~/.ssh, .env)
that narrowing to execute-only would only weaken. Header comments corrected.
- CC-10 (DECIDE: design note, no code): no blanket Agent(model:opus) deny rule
-- would break balanced/economy profiles; any model-enforcement must be
profile-aware, deferred into W2. Folded into open question #3.
Matrix updated with S2 resolutions section. Tests 578 pass / 0 fail / 2 skip;
claude plugin validate passes (only pre-existing root-CLAUDE.md warning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
129 lines
4.1 KiB
JavaScript
129 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Hook: pre-write-executor.mjs
|
|
// Event: PreToolUse (Write)
|
|
// Purpose: Block writes to security-sensitive paths. Wired universally (every
|
|
// Voyage session, not just execution) by deliberate decision — protecting
|
|
// ~/.ssh, ~/.aws, .git/hooks, .env, shell configs benefits every session.
|
|
// The CC if: path-scoping mechanism now works, but narrowing to execute-only
|
|
// would only weaken protection with no benefit. See cc-upgrade matrix CC-15/F2.
|
|
//
|
|
// Protocol:
|
|
// - Read JSON from stdin: { tool_name, tool_input }
|
|
// - tool_input.file_path — the target path for Write tool
|
|
// - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets
|
|
// - Allow (exit 0): everything else
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BLOCK rules — path patterns that must never be written during execution.
|
|
// ---------------------------------------------------------------------------
|
|
const BLOCK_RULES = [
|
|
{
|
|
name: 'Git hook injection (.git/hooks/)',
|
|
test: (p) => /\/\.git\/hooks\//.test(p),
|
|
description:
|
|
'Writing to .git/hooks/ could inject malicious git hooks that execute ' +
|
|
'on every commit, push, or checkout. Blocked.',
|
|
},
|
|
{
|
|
name: 'Claude settings self-modification',
|
|
test: (p) => /\/\.claude\/settings[^/]*\.json$/.test(p),
|
|
description:
|
|
'Writing to .claude/settings.json could disable security hooks or ' +
|
|
'change permission modes. Blocked.',
|
|
},
|
|
{
|
|
name: 'Claude hooks self-modification',
|
|
test: (p) => /\/\.claude\/hooks\//.test(p) || /\/\.claude-plugin\//.test(p),
|
|
description:
|
|
'Writing to .claude/hooks/ or .claude-plugin/ could modify security ' +
|
|
'hook configuration. Blocked.',
|
|
},
|
|
{
|
|
name: 'Shell configuration files',
|
|
test: (p) => {
|
|
const sensitive = [
|
|
`${HOME}/.zshrc`,
|
|
`${HOME}/.bashrc`,
|
|
`${HOME}/.bash_profile`,
|
|
`${HOME}/.profile`,
|
|
`${HOME}/.zshenv`,
|
|
`${HOME}/.zprofile`,
|
|
];
|
|
const resolved = resolve(p);
|
|
return sensitive.some((s) => resolved === s || resolved.startsWith(s + '.'));
|
|
},
|
|
description:
|
|
'Writing to shell config files (~/.zshrc, ~/.bashrc, etc.) could inject ' +
|
|
'persistent commands. Blocked.',
|
|
},
|
|
{
|
|
name: 'SSH directory',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.ssh/`) || resolved === `${HOME}/.ssh`;
|
|
},
|
|
description: 'Writing to ~/.ssh/ could compromise SSH keys or config. Blocked.',
|
|
},
|
|
{
|
|
name: 'AWS credentials',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.aws/`) || resolved === `${HOME}/.aws`;
|
|
},
|
|
description: 'Writing to ~/.aws/ could compromise cloud credentials. Blocked.',
|
|
},
|
|
{
|
|
name: 'GnuPG directory',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.gnupg/`) || resolved === `${HOME}/.gnupg`;
|
|
},
|
|
description: 'Writing to ~/.gnupg/ could compromise GPG keys. Blocked.',
|
|
},
|
|
{
|
|
name: 'Environment files (.env)',
|
|
test: (p) => /\/\.env(?:\.[a-zA-Z0-9]+)?$/.test(p),
|
|
description:
|
|
'Writing to .env files could expose or modify secrets. Blocked. ' +
|
|
'Use .env.template instead.',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
let input;
|
|
try {
|
|
const raw = readFileSync(0, 'utf-8');
|
|
input = JSON.parse(raw);
|
|
} catch {
|
|
// Cannot parse stdin — fail open.
|
|
process.exit(0);
|
|
}
|
|
|
|
const filePath = input?.tool_input?.file_path;
|
|
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
process.exit(0);
|
|
}
|
|
|
|
const resolved = resolve(filePath);
|
|
|
|
for (const rule of BLOCK_RULES) {
|
|
if (rule.test(resolved)) {
|
|
process.stderr.write(
|
|
`[voyage] BLOCKED: ${rule.name}\n` +
|
|
` Path: ${resolved}\n` +
|
|
` ${rule.description}\n`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Allow
|
|
process.exit(0);
|