Skip to content

Commit cc8dd86

Browse files
committed
chore(wheelhouse): cascade template@28b9c737
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-addon-57801. 9 file(s) touched: - .claude/hooks/fleet/claude-segmentation-guard/README.md - .claude/hooks/fleet/claude-segmentation-guard/index.mts - .claude/hooks/fleet/claude-segmentation-guard/package.json - .claude/hooks/fleet/claude-segmentation-guard/test/index.test.mts - .claude/hooks/fleet/claude-segmentation-guard/tsconfig.json - .claude/hooks/fleet/setup-security-tools/external-tools.json - .claude/settings.json - .config/socket-registry-pins.json - scripts/check-claude-segmentation.mts
1 parent ee4f389 commit cc8dd86

9 files changed

Lines changed: 471 additions & 60 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# claude-segmentation-guard
2+
3+
PreToolUse Edit/Write hook that blocks new files/directories at dangling top-level paths under `.claude/{agents,commands,hooks,skills}/`.
4+
5+
## Why
6+
7+
Every entry under those four directories must live under one of:
8+
9+
- `<kind>/fleet/<name>/` — wheelhouse-canonical entries (the wheelhouse template ships an entry with this name).
10+
- `<kind>/repo/<name>/` — repo-only entries (everything else).
11+
- `<kind>/_<name>/` — internals folder (`_shared` and friends).
12+
13+
Top-level dangling entries like `.claude/skills/foo/SKILL.md` shadow the canonical `.claude/skills/fleet/foo/SKILL.md` copy and break skill resolution in unpredictable ways.
14+
15+
Past incident: 2026-06-01 fleet-wide audit found ~200 dangling entries across 10 repos — every fleet repo had at least 18 duplicate top-level skill directories shadowing their `fleet/<name>/` counterparts. The cleanup script (`node scripts/check-claude-segmentation.mts --fix`) resolved them in bulk; this hook prevents the regression at edit time.
16+
17+
## What it blocks
18+
19+
Edit/Write on any path matching `.claude/<kind>/<name>/...` where `<kind>` is `agents | commands | hooks | skills` and `<name>` is NOT one of `fleet | repo | _*`.
20+
21+
| Path | Result |
22+
| ------------------------------------------------------- | ------ |
23+
| `.claude/skills/foo/SKILL.md` | block |
24+
| `.claude/agents/foo.md` | block |
25+
| `.claude/hooks/foo/index.mts` | block |
26+
| `.claude/skills/fleet/foo/SKILL.md` | pass |
27+
| `.claude/skills/repo/foo/SKILL.md` | pass |
28+
| `.claude/skills/_shared/util.mts` | pass |
29+
| `.claude/skills/_internal/x.mts` | pass |
30+
| `template/.claude/skills/foo/SKILL.md` (wheelhouse) | block |
31+
32+
## How
33+
34+
The hook reads the Claude Code PreToolUse JSON payload from stdin, runs the regex `\.claude/(?<kind>agents|commands|hooks|skills)/(?<entry>[^/]+)` on `tool_input.file_path`, and exits 2 if the captured `entry` segment is not `fleet`, `repo`, or `_`-prefixed.
35+
36+
The stderr message names the offending path and the two allowed destinations (`fleet/<name>/` or `repo/<name>/`), plus the autofix command for cleaning up entries already on disk.
37+
38+
Fails open on malformed payloads or unknown errors (exit 0).
39+
40+
## Bypass
41+
42+
None. The autofix is always available: `node scripts/check-claude-segmentation.mts --fix` moves dangling entries into the right subdir based on the wheelhouse-canonical fleet/ set.
43+
44+
## Test
45+
46+
```sh
47+
pnpm test
48+
```
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — claude-segmentation-guard.
3+
//
4+
// Blocks Edit/Write tool calls that create or modify entries directly
5+
// under `.claude/{agents,commands,hooks,skills}/<name>/` (instead of
6+
// `fleet/<name>/` or `repo/<name>/`). Pre-segmentation top-level
7+
// dangling entries shadow the canonical `fleet/<name>/` copy and break
8+
// skill resolution.
9+
//
10+
// Past incident: 2026-06-01 fleet-wide audit found ~200 dangling
11+
// entries across 10 repos — every fleet repo had at least 18
12+
// duplicate top-level skill directories shadowing their `fleet/<name>/`
13+
// counterparts. The cleanup script
14+
// (`scripts/check-claude-segmentation.mts --fix`) resolved them in
15+
// bulk; this hook prevents the regression at edit time.
16+
//
17+
// Allowed paths:
18+
// .claude/agents/fleet/<name>/...
19+
// .claude/agents/repo/<name>/...
20+
// .claude/agents/_*/... (internals folder exception)
21+
// .claude/commands/fleet/<name>.md
22+
// .claude/commands/repo/<name>.md
23+
// .claude/hooks/_shared/... (and any _-prefixed name)
24+
// .claude/hooks/fleet/<name>/...
25+
// .claude/hooks/repo/<name>/...
26+
// .claude/skills/_shared/...
27+
// .claude/skills/fleet/<name>/...
28+
// .claude/skills/repo/<name>/...
29+
//
30+
// Blocked:
31+
// .claude/agents/<name>.md (not under fleet/ or repo/)
32+
// .claude/commands/<name>.md (same)
33+
// .claude/hooks/<name>/... (same)
34+
// .claude/skills/<name>/... (same)
35+
//
36+
// Wheelhouse-template paths under `template/.claude/<kind>/` follow
37+
// the same rule — the template ships canonical entries, and the
38+
// cascade keeps the layout consistent fleet-wide.
39+
//
40+
// Reads a Claude Code PreToolUse JSON payload from stdin:
41+
// { "tool_name": "Edit" | "Write",
42+
// "tool_input": { "file_path": "..." },
43+
// ... }
44+
//
45+
// Exit codes:
46+
// 0 — pass (not Edit/Write, or path is in an allowed location).
47+
// 2 — block (path is a dangling top-level entry).
48+
//
49+
// Fails open on malformed payloads (exit 0 + stderr log).
50+
51+
import process from 'node:process'
52+
53+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
54+
55+
const logger = getDefaultLogger()
56+
57+
interface ToolInput {
58+
readonly tool_input?:
59+
| { readonly file_path?: string | undefined }
60+
| undefined
61+
readonly tool_name?: string | undefined
62+
}
63+
64+
const KINDS: readonly string[] = ['agents', 'commands', 'hooks', 'skills']
65+
66+
// Match `.claude/<kind>/<entry>` at the root of the captured path. The
67+
// regex is rooted on `.claude/` and consumes the kind segment; the
68+
// post-kind segment is what we validate.
69+
//
70+
// Examples:
71+
// path=/.../template/.claude/skills/foo/SKILL.md → kind=skills entry=foo
72+
// path=/.../.claude/skills/fleet/foo/SKILL.md → kind=skills entry=fleet (OK)
73+
// path=/.../.claude/skills/repo/bar/SKILL.md → kind=skills entry=repo (OK)
74+
// path=/.../.claude/skills/_shared/util.mts → kind=skills entry=_shared (OK)
75+
// path=/.../.claude/agents/foo.md → kind=agents entry=foo.md (block)
76+
const SEGMENT_RE = new RegExp(
77+
String.raw`\.claude/(?<kind>${KINDS.join('|')})/(?<entry>[^/]+)`,
78+
)
79+
80+
interface SegmentMatch {
81+
kind: string
82+
entry: string
83+
}
84+
85+
export function findDanglingSegment(filePath: string): SegmentMatch | undefined {
86+
const m = SEGMENT_RE.exec(filePath)
87+
if (!m?.groups) {
88+
return undefined
89+
}
90+
const kind = m.groups['kind']!
91+
const entry = m.groups['entry']!
92+
// `_`-prefixed internals folder, `fleet/`, and `repo/` are the
93+
// allowed second-level segments. Anything else is a dangling
94+
// top-level entry that should be moved.
95+
if (entry.startsWith('_') || entry === 'fleet' || entry === 'repo') {
96+
return undefined
97+
}
98+
return { kind, entry }
99+
}
100+
101+
let payloadRaw = ''
102+
process.stdin.setEncoding('utf8')
103+
process.stdin.on('data', chunk => {
104+
payloadRaw += chunk
105+
})
106+
process.stdin.on('end', () => {
107+
// Fail OPEN on any internal bug. The JSON.parse below has its own
108+
// try/catch (bad payloads exit 0), but unexpected throws elsewhere
109+
// would otherwise crash the hook → exit 1 → block. Hooks must not
110+
// brick the session on their own crash.
111+
try {
112+
let payload: ToolInput
113+
try {
114+
payload = JSON.parse(payloadRaw) as ToolInput
115+
} catch {
116+
process.exit(0)
117+
}
118+
119+
if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') {
120+
process.exit(0)
121+
}
122+
const filePath = payload.tool_input?.file_path ?? ''
123+
if (!filePath) {
124+
process.exit(0)
125+
}
126+
127+
const hit = findDanglingSegment(filePath)
128+
if (!hit) {
129+
process.exit(0)
130+
}
131+
132+
const targetForCanonical = `.claude/${hit.kind}/fleet/${hit.entry}`
133+
const targetForRepo = `.claude/${hit.kind}/repo/${hit.entry}`
134+
135+
logger.error(
136+
[
137+
'[claude-segmentation-guard] Blocked: dangling top-level entry.',
138+
'',
139+
` Attempted path: \`.claude/${hit.kind}/${hit.entry}\``,
140+
'',
141+
' `.claude/{agents,commands,hooks,skills}/<name>/` must segment as',
142+
' `fleet/<name>/` (wheelhouse-canonical) or `repo/<name>/` (everything',
143+
' else). Top-level entries shadow the canonical `fleet/<name>/`',
144+
' copy and break skill resolution.',
145+
'',
146+
` Fix: pick the right subdir for \`${hit.entry}\`:`,
147+
'',
148+
` Wheelhouse-canonical (look in socket-wheelhouse/template/.claude/${hit.kind}/fleet/ for the set):`,
149+
` ${targetForCanonical}`,
150+
'',
151+
' Repo-only:',
152+
` ${targetForRepo}`,
153+
'',
154+
' Or run `node scripts/check-claude-segmentation.mts --fix` from the',
155+
' repo root to auto-resolve any dangling entries already on disk.',
156+
'',
157+
].join('\n'),
158+
)
159+
process.exit(2)
160+
} catch (e) {
161+
logger.error(
162+
`[claude-segmentation-guard] hook error (allowing): ${e}`,
163+
)
164+
process.exit(0)
165+
}
166+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "hook-claude-segmentation-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@socketsecurity/lib-stable": "catalog:",
14+
"@types/node": "catalog:"
15+
}
16+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// node --test specs for the claude-segmentation-guard hook.
2+
3+
// prefer-async-spawn: streaming-stdio-required — test spawns child
4+
// subprocess and pipes stdin/stdout/stderr; Node spawn returns the
5+
// ChildProcess streaming surface the lib promise wrapper does not.
6+
import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'
7+
import path from 'node:path'
8+
import { fileURLToPath } from 'node:url'
9+
import test from 'node:test'
10+
import assert from 'node:assert/strict'
11+
12+
const here = path.dirname(fileURLToPath(import.meta.url))
13+
const HOOK = path.join(here, '..', 'index.mts')
14+
15+
interface Result {
16+
readonly code: number
17+
readonly stderr: string
18+
}
19+
20+
async function runHook(payload: Record<string, unknown>): Promise<Result> {
21+
const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' })
22+
void child.catch(() => undefined)
23+
child.stdin!.end(JSON.stringify(payload))
24+
let stderr = ''
25+
child.process.stderr!.on('data', chunk => {
26+
stderr += chunk.toString('utf8')
27+
})
28+
return new Promise(resolve => {
29+
child.process.on('exit', code => {
30+
resolve({ code: code ?? 0, stderr })
31+
})
32+
})
33+
}
34+
35+
function edit(filePath: string): Record<string, unknown> {
36+
return { tool_input: { file_path: filePath }, tool_name: 'Edit' }
37+
}
38+
39+
function write(filePath: string): Record<string, unknown> {
40+
return { tool_input: { file_path: filePath }, tool_name: 'Write' }
41+
}
42+
43+
test('non-Edit/Write tool calls pass through', async () => {
44+
const r = await runHook({
45+
tool_input: { command: 'ls' },
46+
tool_name: 'Bash',
47+
})
48+
assert.strictEqual(r.code, 0)
49+
assert.strictEqual(r.stderr, '')
50+
})
51+
52+
test('empty / unparseable payload passes through', async () => {
53+
assert.strictEqual((await runHook({})).code, 0)
54+
})
55+
56+
test('paths outside .claude/ pass through', async () => {
57+
for (const p of [
58+
'src/index.ts',
59+
'docs/claude.md/fleet/topic.md',
60+
'README.md',
61+
'package.json',
62+
]) {
63+
assert.strictEqual((await runHook(edit(p))).code, 0, `expected pass for ${p}`)
64+
}
65+
})
66+
67+
test('blocks dangling skill at .claude/skills/<name>/SKILL.md', async () => {
68+
const r = await runHook(edit('.claude/skills/foo/SKILL.md'))
69+
assert.strictEqual(r.code, 2)
70+
assert.match(r.stderr, /claude-segmentation-guard/)
71+
assert.match(r.stderr, /skills\/foo/)
72+
assert.match(r.stderr, /fleet\/foo/)
73+
assert.match(r.stderr, /repo\/foo/)
74+
assert.match(r.stderr, /--fix/)
75+
})
76+
77+
test('blocks dangling agent at .claude/agents/<name>.md', async () => {
78+
const r = await runHook(edit('.claude/agents/code-reviewer.md'))
79+
assert.strictEqual(r.code, 2)
80+
assert.match(r.stderr, /agents\/code-reviewer/)
81+
})
82+
83+
test('blocks dangling hook at .claude/hooks/<name>/index.mts', async () => {
84+
const r = await runHook(write('.claude/hooks/my-guard/index.mts'))
85+
assert.strictEqual(r.code, 2)
86+
assert.match(r.stderr, /hooks\/my-guard/)
87+
})
88+
89+
test('blocks dangling command at .claude/commands/<name>.md', async () => {
90+
const r = await runHook(write('.claude/commands/foo.md'))
91+
assert.strictEqual(r.code, 2)
92+
assert.match(r.stderr, /commands\/foo/)
93+
})
94+
95+
test('passes .claude/<kind>/fleet/<name>/ paths', async () => {
96+
for (const p of [
97+
'.claude/skills/fleet/foo/SKILL.md',
98+
'.claude/agents/fleet/security-reviewer.md',
99+
'.claude/commands/fleet/quality-loop.md',
100+
'.claude/hooks/fleet/my-guard/index.mts',
101+
]) {
102+
const r = await runHook(edit(p))
103+
assert.strictEqual(r.code, 0, `expected pass for ${p}, got stderr: ${r.stderr}`)
104+
}
105+
})
106+
107+
test('passes .claude/<kind>/repo/<name>/ paths', async () => {
108+
for (const p of [
109+
'.claude/skills/repo/foo/SKILL.md',
110+
'.claude/agents/repo/code-reviewer.md',
111+
'.claude/commands/repo/update-something.md',
112+
'.claude/hooks/repo/local-only/index.mts',
113+
]) {
114+
const r = await runHook(edit(p))
115+
assert.strictEqual(r.code, 0, `expected pass for ${p}, got stderr: ${r.stderr}`)
116+
}
117+
})
118+
119+
test('passes _-prefixed internals folder paths', async () => {
120+
for (const p of [
121+
'.claude/skills/_shared/util.mts',
122+
'.claude/skills/_internal/x.mts',
123+
'.claude/hooks/_shared/foreign-paths.mts',
124+
'.claude/hooks/_shared/test/foo.test.mts',
125+
]) {
126+
const r = await runHook(edit(p))
127+
assert.strictEqual(r.code, 0, `expected pass for ${p}, got stderr: ${r.stderr}`)
128+
}
129+
})
130+
131+
test('blocks under wheelhouse template/.claude/<kind>/<name>/ too', async () => {
132+
// The cascade ships everything under template/.claude/<kind>/fleet/
133+
// so a dangling template entry breaks every downstream repo. Same
134+
// rule applies there.
135+
const r = await runHook(edit('template/.claude/skills/foo/SKILL.md'))
136+
assert.strictEqual(r.code, 2)
137+
assert.match(r.stderr, /skills\/foo/)
138+
})
139+
140+
test('passes paths that mention .claude/ but not as a directory prefix', async () => {
141+
// The regex anchors on `.claude/<kind>/`, so a string-literal mention
142+
// inside an unrelated file doesn't match.
143+
const r = await runHook(edit('docs/notes.md'))
144+
assert.strictEqual(r.code, 0)
145+
})
146+
147+
test('passes when tool_input has no file_path', async () => {
148+
const r = await runHook({ tool_input: {}, tool_name: 'Edit' })
149+
assert.strictEqual(r.code, 0)
150+
})
151+
152+
test('passes for absolute paths under fleet/', async () => {
153+
const r = await runHook(edit('/tmp/fake-repo/.claude/skills/fleet/bar/SKILL.md'))
154+
assert.strictEqual(r.code, 0)
155+
})
156+
157+
test('blocks absolute paths to a dangling top-level entry', async () => {
158+
const r = await runHook(edit('/tmp/fake-repo/.claude/skills/bar/SKILL.md'))
159+
assert.strictEqual(r.code, 2)
160+
assert.match(r.stderr, /skills\/bar/)
161+
})

0 commit comments

Comments
 (0)