Skip to content

Commit a0ad70c

Browse files
committed
chore(sync): cascade no-fleet-fork-guard hook + sourceCode scope fix from socket-repo-template
1 parent a69682b commit a0ad70c

8 files changed

Lines changed: 728 additions & 1 deletion

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# no-fleet-fork-guard
2+
3+
PreToolUse Edit/Write hook that blocks edits to fleet-canonical files inside downstream fleet repos.
4+
5+
## What it enforces
6+
7+
The fleet rule "Never fork fleet-canonical files locally" (CLAUDE.md fleet block, full reference at [`docs/claude.md/no-local-fork-canonical.md`](../../../docs/claude.md/no-local-fork-canonical.md)).
8+
9+
Fleet-canonical surfaces (anything tracked by `socket-repo-template/scripts/sync-scaffolding/manifest.mts`):
10+
11+
- `.config/oxlint-plugin/` — oxlint plugin index + rules
12+
- `.git-hooks/` — commit-msg / pre-commit / pre-push hooks + helpers
13+
- `.claude/hooks/` — PreToolUse / PostToolUse hooks
14+
- `.claude/skills/_shared/` — shared skill helpers
15+
- `docs/claude.md/` — CLAUDE.md offshoot references
16+
- `.husky/` — Husky entry shims
17+
18+
When Claude tries to Edit/Write a file under one of these prefixes in a fleet member (any repo with `CLAUDE.md` containing the `BEGIN FLEET-CANONICAL` marker, except `socket-repo-template/template/`), the hook exits 2 with a stderr message that:
19+
20+
1. States the rule.
21+
2. Names the canonical file path inside `socket-repo-template/template/...`.
22+
3. Provides the exact `sync-scaffolding` command to cascade.
23+
4. Documents the bypass phrase.
24+
25+
Edits inside `socket-repo-template/template/` are ALLOWED — that IS the canonical home.
26+
27+
## Bypass
28+
29+
Reverting / overriding the block requires the user to type **`Allow fleet-fork bypass`** verbatim in a recent user turn. The phrase is scoped to the current conversation; it does NOT carry across sessions. Per the broader bypass-phrase contract enforced by `no-revert-guard` and the fleet CLAUDE.md "Hook bypasses" rule.
30+
31+
## Why a hook + a rule + a memory
32+
33+
- The CLAUDE.md fleet block documents the policy (visible at every prompt).
34+
- A user-memory entry keeps the assistant honest across sessions.
35+
- This hook is the actual enforcement at edit time.
36+
37+
The hook catches the failure mode where Claude reaches for a "quick fix" in a downstream repo's canonical file (typically because the local copy has a known bug and the user is in a hurry to land something else). The block flips the workflow back to "fix-in-template, cascade out" where it belongs.
38+
39+
## Detection
40+
41+
For each Edit/Write/MultiEdit call:
42+
43+
1. Resolve `tool_input.file_path` to an absolute path.
44+
2. Check if the path contains `/socket-repo-template/template/` — if yes, allow.
45+
3. Walk up directories looking for a fleet repo root: `package.json` AND `CLAUDE.md` containing the `BEGIN FLEET-CANONICAL` marker.
46+
4. If no fleet repo root is found (the file is outside any fleet repo), allow.
47+
5. Compute the file path relative to the repo root.
48+
6. If the relative path matches one of the canonical prefixes, check the bypass phrase.
49+
7. No bypass → exit 2 with the explanation.
50+
51+
## Failing open
52+
53+
The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. The CLAUDE.md rule + memory still document the policy as a backstop.
54+
55+
## Companion files
56+
57+
- `index.mts` — the hook itself.
58+
- `test/index.test.mts` — node:test specs.
59+
- `package.json` — workspace declaration so `taze` can see the hook's deps.
60+
- `tsconfig.json` — fleet-canonical TS config.
61+
62+
## Adding a new canonical surface
63+
64+
When a new directory becomes fleet-canonical (cascades via sync-scaffolding):
65+
66+
1. Add it to `CANONICAL_PREFIXES` in `index.mts`.
67+
2. Add it to the bullet list in this README.
68+
3. Add it to the bullet list in `docs/claude.md/no-local-fork-canonical.md`.
69+
4. Add the surface to the sync manifest.
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — no-fleet-fork-guard.
3+
//
4+
// Blocks Edit/Write tool calls that target a fleet-canonical file
5+
// path inside a downstream fleet repo. The fleet rule
6+
// ("Never fork fleet-canonical files locally") says these files
7+
// MUST be edited in socket-repo-template/template/... and cascaded
8+
// out via sync-scaffolding — never branched locally in a downstream
9+
// repo. Local forks turn into "drift to preserve" hacks that block
10+
// fleet-wide improvements from reaching the forked repo.
11+
//
12+
// The hook detects a fleet-canonical edit by:
13+
// 1. Resolving the absolute file path of the Edit/Write target.
14+
// 2. Checking if the path is INSIDE socket-repo-template/template/
15+
// → allow (this IS the canonical home).
16+
// 3. Otherwise, checking if the path matches a fleet-canonical
17+
// surface prefix:
18+
// - .config/oxlint-plugin/
19+
// - .git-hooks/
20+
// - .claude/hooks/
21+
// - .claude/skills/_shared/
22+
// - docs/claude.md/
23+
// - .husky/
24+
// → block.
25+
//
26+
// The bypass phrase: `Allow fleet-fork bypass`. Reading the recent
27+
// user turns from the transcript follows the same pattern as the
28+
// no-revert-guard hook.
29+
//
30+
// Why a hook on top of the CLAUDE.md rule + memory: the rule
31+
// documents the policy, the memory keeps the assistant honest across
32+
// sessions, the hook is the actual enforcement at edit time. Catches
33+
// the failure mode where Claude reaches for a "quick fix" in a
34+
// downstream repo's canonical file (typically because the local
35+
// version has a known bug and the user is in a hurry to land
36+
// something else). The block flips the workflow back to
37+
// "fix-in-template, cascade out" where it belongs.
38+
//
39+
// Reads a Claude Code PreToolUse JSON payload from stdin:
40+
// { "tool_name": "Edit" | "Write" | "MultiEdit",
41+
// "tool_input": { "file_path": "...", ... },
42+
// "transcript_path": "/.../session.jsonl" }
43+
//
44+
// Exits:
45+
// 0 — allowed (not a fleet-canonical edit, OR target is the template,
46+
// OR bypass phrase present).
47+
// 2 — blocked (with a stderr message that explains the rule + the
48+
// canonical fix path + the bypass phrase).
49+
// 0 (with stderr log) — fail-open on hook bugs so a bad deploy can't
50+
// brick the session.
51+
52+
import { existsSync, readFileSync } from 'node:fs'
53+
import path from 'node:path'
54+
import process from 'node:process'
55+
56+
type ToolInput = {
57+
tool_input?: { file_path?: string } | undefined
58+
tool_name?: string | undefined
59+
transcript_path?: string | undefined
60+
}
61+
62+
// Fleet-canonical directory prefixes. Matches relative-to-repo-root.
63+
// Order matters for nested prefixes (more-specific first), but these
64+
// are all leaves — no nesting between them.
65+
const CANONICAL_PREFIXES = [
66+
'.config/oxlint-plugin/',
67+
'.git-hooks/',
68+
'.claude/hooks/',
69+
'.claude/skills/_shared/',
70+
'docs/claude.md/',
71+
'.husky/',
72+
]
73+
74+
// Fleet-canonical individual files (not under one of the prefix
75+
// dirs). Matches relative-to-repo-root.
76+
const CANONICAL_FILES: string[] = [
77+
// Add specific files here when needed. Most canonical content lives
78+
// under the prefix dirs above.
79+
]
80+
81+
const BYPASS_PHRASE = 'Allow fleet-fork bypass'
82+
83+
// How many recent user turns to scan for the bypass phrase. Matches
84+
// the no-revert-guard hook's window.
85+
const BYPASS_LOOKBACK_USER_TURNS = 8
86+
87+
// File-path tokens that identify the socket-repo-template canonical
88+
// home. If the resolved absolute path contains one of these, we're
89+
// editing the source of truth — allow.
90+
//
91+
// `socket-repo-template/template/` covers the standard checkout shape
92+
// (e.g. /Users/<user>/projects/socket-repo-template/template/...).
93+
// `repo-template/template/` covers any rename / mirror / fork that
94+
// keeps the trailing component.
95+
const TEMPLATE_PATH_TOKENS = [
96+
'/socket-repo-template/template/',
97+
'/repo-template/template/',
98+
]
99+
100+
function readStdin(): Promise<string> {
101+
return new Promise(resolve => {
102+
let buf = ''
103+
process.stdin.setEncoding('utf8')
104+
process.stdin.on('data', chunk => {
105+
buf += chunk
106+
})
107+
process.stdin.on('end', () => resolve(buf))
108+
})
109+
}
110+
111+
/**
112+
* Walk the recent user turns in the transcript, looking for an exact
113+
* occurrence of the bypass phrase. Returns true if found within the
114+
* last BYPASS_LOOKBACK_USER_TURNS user-turn entries.
115+
*/
116+
function bypassPhrasePresent(transcriptPath: string | undefined): boolean {
117+
if (!transcriptPath || !existsSync(transcriptPath)) {
118+
return false
119+
}
120+
let raw: string
121+
try {
122+
raw = readFileSync(transcriptPath, 'utf8')
123+
} catch {
124+
return false
125+
}
126+
const lines = raw.split('\n').filter(Boolean)
127+
let userTurns = 0
128+
for (let i = lines.length - 1; i >= 0; i--) {
129+
const line = lines[i]!
130+
let entry: { type?: string; message?: { role?: string; content?: unknown } }
131+
try {
132+
entry = JSON.parse(line)
133+
} catch {
134+
continue
135+
}
136+
if (entry.type !== 'user' || entry.message?.role !== 'user') {
137+
continue
138+
}
139+
userTurns++
140+
const content = entry.message?.content
141+
const text =
142+
typeof content === 'string'
143+
? content
144+
: Array.isArray(content)
145+
? content
146+
.map(c =>
147+
typeof c === 'object' && c && 'text' in c
148+
? String((c as { text: unknown }).text)
149+
: '',
150+
)
151+
.join('\n')
152+
: ''
153+
if (text.includes(BYPASS_PHRASE)) {
154+
return true
155+
}
156+
if (userTurns >= BYPASS_LOOKBACK_USER_TURNS) {
157+
break
158+
}
159+
}
160+
return false
161+
}
162+
163+
/**
164+
* Find the fleet repo root for an absolute file path by walking up
165+
* until we hit a directory that has package.json AND a CLAUDE.md
166+
* containing the FLEET-CANONICAL marker. Returns the repo root path
167+
* or undefined if the file is outside a fleet repo.
168+
*/
169+
function findFleetRepoRoot(filePath: string): string | undefined {
170+
let cur = path.dirname(filePath)
171+
const root = path.parse(cur).root
172+
while (cur && cur !== root) {
173+
const pkgPath = path.join(cur, 'package.json')
174+
const claudePath = path.join(cur, 'CLAUDE.md')
175+
if (existsSync(pkgPath) && existsSync(claudePath)) {
176+
try {
177+
const claudeContent = readFileSync(claudePath, 'utf8')
178+
if (claudeContent.includes('BEGIN FLEET-CANONICAL')) {
179+
return cur
180+
}
181+
} catch {
182+
// unreadable — skip and continue walking up
183+
}
184+
}
185+
const parent = path.dirname(cur)
186+
if (parent === cur) {
187+
break
188+
}
189+
cur = parent
190+
}
191+
return undefined
192+
}
193+
194+
function isInsideTemplate(filePath: string): boolean {
195+
const normalized = filePath.replace(/\\/g, '/')
196+
return TEMPLATE_PATH_TOKENS.some(token => normalized.includes(token))
197+
}
198+
199+
function isCanonicalRelativePath(rel: string): boolean {
200+
const normalized = rel.replace(/\\/g, '/')
201+
for (const prefix of CANONICAL_PREFIXES) {
202+
if (normalized.startsWith(prefix)) {
203+
return true
204+
}
205+
}
206+
return CANONICAL_FILES.includes(normalized)
207+
}
208+
209+
async function main(): Promise<number> {
210+
const raw = await readStdin()
211+
if (!raw.trim()) {
212+
return 0
213+
}
214+
215+
let payload: ToolInput
216+
try {
217+
payload = JSON.parse(raw) as ToolInput
218+
} catch {
219+
process.stderr.write(
220+
'no-fleet-fork-guard: failed to parse stdin payload — fail-open\n',
221+
)
222+
return 0
223+
}
224+
225+
const tool = payload.tool_name
226+
if (tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') {
227+
return 0
228+
}
229+
230+
const filePath = payload.tool_input?.file_path
231+
if (!filePath) {
232+
return 0
233+
}
234+
235+
const absPath = path.resolve(filePath)
236+
237+
// The canonical home is allowed.
238+
if (isInsideTemplate(absPath)) {
239+
return 0
240+
}
241+
242+
// Walk up to find the fleet repo root. If the file isn't inside a
243+
// fleet repo at all, this hook doesn't apply — let it through.
244+
const repoRoot = findFleetRepoRoot(absPath)
245+
if (!repoRoot) {
246+
return 0
247+
}
248+
249+
const relToRepo = path.relative(repoRoot, absPath)
250+
251+
if (!isCanonicalRelativePath(relToRepo)) {
252+
return 0
253+
}
254+
255+
// Bypass-phrase check.
256+
if (bypassPhrasePresent(payload.transcript_path)) {
257+
return 0
258+
}
259+
260+
process.stderr.write(
261+
[
262+
`🚨 no-fleet-fork-guard: blocked Edit/Write to fleet-canonical path.`,
263+
``,
264+
`File: ${relToRepo}`,
265+
`Repo: ${path.basename(repoRoot)}`,
266+
``,
267+
`Fleet-canonical files (anything tracked by`,
268+
`socket-repo-template/scripts/sync-scaffolding/manifest.mts) MUST`,
269+
`be edited in socket-repo-template/template/${relToRepo} and`,
270+
`cascaded out — never branched locally in a downstream fleet repo.`,
271+
``,
272+
`Fix path:`,
273+
` 1. Edit socket-repo-template/template/${relToRepo}`,
274+
` 2. Commit + push template`,
275+
` 3. Cascade with: node scripts/sync-scaffolding/main.mts \\`,
276+
` --target ${repoRoot} --fix`,
277+
``,
278+
`If you genuinely need to bypass (e.g. emergency hotfix that`,
279+
`can't wait for cascade), the user must type \`${BYPASS_PHRASE}\``,
280+
`verbatim in a recent user turn. Reference:`,
281+
`docs/claude.md/no-local-fork-canonical.md`,
282+
``,
283+
].join('\n'),
284+
)
285+
return 2
286+
}
287+
288+
main().then(
289+
code => process.exit(code),
290+
e => {
291+
process.stderr.write(
292+
`no-fleet-fork-guard: hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`,
293+
)
294+
process.exit(0)
295+
},
296+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-no-fleet-fork-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"devDependencies": {
10+
"@types/node": "catalog:"
11+
},
12+
"scripts": {
13+
"test": "node --test test/*.test.mts"
14+
}
15+
}

0 commit comments

Comments
 (0)