claude-bulkhead 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const crypto = require('crypto')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { stateDir } = require('./hook')
|
|
6
|
+
|
|
7
|
+
// Allow once: a block gets a short code; the user runs `bulkhead allow <code>` in their own
|
|
8
|
+
// terminal, and the next identical tool call (same command text, same folder) passes the guard
|
|
9
|
+
// that blocked it. One JSON file per (code, guard, rule) in <state dir>/allow, so two guards
|
|
10
|
+
// blocking the same command in parallel never overwrite each other's write.
|
|
11
|
+
|
|
12
|
+
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // base32 without I, O, 0, 1
|
|
13
|
+
const CODE_LENGTH = 5
|
|
14
|
+
const DEFAULT_TTL_MINUTES = 10
|
|
15
|
+
|
|
16
|
+
const dirOf = (base = stateDir()) => path.join(base, 'allow')
|
|
17
|
+
|
|
18
|
+
function ttlMs(minutes) {
|
|
19
|
+
let m = minutes
|
|
20
|
+
if (m === undefined) {
|
|
21
|
+
try { m = require('./config').get('allowOnce.ttlMinutes') } catch {}
|
|
22
|
+
}
|
|
23
|
+
return (Number.isFinite(m) && m > 0 ? m : DEFAULT_TTL_MINUTES) * 60000
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const normalizeCommand = command => String(command || '').replace(/\r\n/g, '\n').trim()
|
|
27
|
+
const normalizeCwd = cwd => {
|
|
28
|
+
const s = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
29
|
+
return process.platform === 'win32' ? s.toLowerCase() : s
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The identity of a tool call as far as allowing it goes: the command text and the folder.
|
|
33
|
+
function commandKey(command, cwd) {
|
|
34
|
+
return crypto.createHash('sha256').update(`${normalizeCommand(command)}\0${normalizeCwd(cwd)}`).digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function newCode(taken = new Set()) {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const bytes = crypto.randomBytes(CODE_LENGTH)
|
|
40
|
+
let code = ''
|
|
41
|
+
for (const b of bytes) code += ALPHABET[b % 32]
|
|
42
|
+
if (!taken.has(code)) return code
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cleanCode = s => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
|
|
47
|
+
|
|
48
|
+
function writeAtomic(file, data) {
|
|
49
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(data))
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(tmp, file)
|
|
53
|
+
} catch (e) {
|
|
54
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
55
|
+
throw e
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const valid = e => e && typeof e === 'object' && typeof e.code === 'string' && typeof e.guard === 'string' &&
|
|
60
|
+
typeof e.rule === 'string' && typeof e.key === 'string' && Number.isFinite(e.expires) && Number.isFinite(e.created)
|
|
61
|
+
|
|
62
|
+
// Every readable entry as { file, entry }. Expired and unreadable files are deleted on the way.
|
|
63
|
+
function readAll(base, now) {
|
|
64
|
+
const dir = dirOf(base)
|
|
65
|
+
let names
|
|
66
|
+
try { names = fs.readdirSync(dir) } catch { return [] }
|
|
67
|
+
const out = []
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const file = path.join(dir, name)
|
|
70
|
+
if (!name.endsWith('.json')) {
|
|
71
|
+
// a temp file left by a crash
|
|
72
|
+
try { if (now - fs.statSync(file).mtimeMs > 60000) fs.unlinkSync(file) } catch {}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
let entry = null
|
|
76
|
+
try { entry = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {}
|
|
77
|
+
if (!valid(entry) || entry.expires <= now) {
|
|
78
|
+
try { fs.unlinkSync(file) } catch {}
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
out.push({ file, entry })
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const safe = s => String(s).replace(/[^\w.-]/g, '_')
|
|
87
|
+
|
|
88
|
+
// The code a block shows. A second guard blocking the same command in the same minute reuses
|
|
89
|
+
// the code of the first, so the person types one code. Returns null when nothing can be stored.
|
|
90
|
+
function issue({ guard, rule, command, cwd, session }, { base, now = Date.now(), ttl } = {}) {
|
|
91
|
+
try {
|
|
92
|
+
const key = commandKey(command, cwd)
|
|
93
|
+
const all = readAll(base, now)
|
|
94
|
+
const same = all.find(x => x.entry.key === key && !x.entry.approved)
|
|
95
|
+
const code = same ? same.entry.code : newCode(new Set(all.map(x => x.entry.code)))
|
|
96
|
+
const dir = dirOf(base)
|
|
97
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
98
|
+
const entry = {
|
|
99
|
+
code, guard, rule, key, cmd: normalizeCommand(command).replace(/\s+/g, ' ').slice(0, 160),
|
|
100
|
+
cwd: cwd || '', session: session || null, created: now, expires: now + ttlMs(ttl), approved: null,
|
|
101
|
+
}
|
|
102
|
+
writeAtomic(path.join(dir, `${code}-${safe(guard)}-${safe(rule)}.json`), entry)
|
|
103
|
+
return code
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Marks every pending entry of that command as allowed once. { ok, entries } or { ok: false, reason }.
|
|
110
|
+
function approve(input, { base, now = Date.now(), ttl } = {}) {
|
|
111
|
+
const code = cleanCode(input)
|
|
112
|
+
if (!code) return { ok: false, reason: 'no-code' }
|
|
113
|
+
let all
|
|
114
|
+
try { all = readAll(base, now) } catch { return { ok: false, reason: 'unreadable' } }
|
|
115
|
+
const hit = all.find(x => x.entry.code === code)
|
|
116
|
+
if (!hit) return { ok: false, reason: 'unknown' }
|
|
117
|
+
if (hit.entry.approved) return { ok: false, reason: 'used', entries: [hit.entry] }
|
|
118
|
+
const approved = []
|
|
119
|
+
for (const x of all) {
|
|
120
|
+
if (x.entry.key !== hit.entry.key || x.entry.approved) continue
|
|
121
|
+
const e = { ...x.entry, approved: now, expires: now + ttlMs(ttl) }
|
|
122
|
+
try { writeAtomic(x.file, e) } catch { return { ok: false, reason: 'unwritable' } }
|
|
123
|
+
approved.push(e)
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, entries: approved }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Called by a guard that is about to block. True when the user approved this exact command for
|
|
129
|
+
// this guard and rule; the approval is deleted, so it works once. Any trouble means false.
|
|
130
|
+
function consume(guard, rule, command, cwd, { base, now = Date.now() } = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const key = commandKey(command, cwd)
|
|
133
|
+
for (const x of readAll(base, now)) {
|
|
134
|
+
const e = x.entry
|
|
135
|
+
if (!e.approved || e.key !== key || e.guard !== guard || e.rule !== rule) continue
|
|
136
|
+
try { fs.unlinkSync(x.file) } catch { continue } // somebody else used it first
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pending and approved entries, newest first, for `bulkhead allow --list`.
|
|
144
|
+
function list({ base, now = Date.now() } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
return readAll(base, now).map(x => x.entry).sort((a, b) => b.created - a.created)
|
|
147
|
+
} catch {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { issue, approve, consume, list, commandKey, normalizeCommand, newCode, dirOf, ttlMs, ALPHABET, DEFAULT_TTL_MINUTES }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const shell = require('./shell')
|
|
5
|
+
|
|
6
|
+
// Rewrites a heavy command so it runs through bulkhead-slot, which queues it against the
|
|
7
|
+
// machine-wide RAM budget. Pure functions: the hook (memory-guard.js) supplies the settings.
|
|
8
|
+
|
|
9
|
+
// Typical peak RAM per tool, in GB. memoryGuard.estimatesGb overrides single entries.
|
|
10
|
+
const ESTIMATES_GB = {
|
|
11
|
+
vitest: 3, jest: 3, karma: 2, playwright: 4, cypress: 4, gradle: 4, gradlew: 4, mvn: 3, mvnw: 3,
|
|
12
|
+
dotnet: 2, next: 3, vite: 2, tsc: 2, webpack: 3, ffmpeg: 2, cargo: 4, go: 3, pytest: 3,
|
|
13
|
+
docker: 3, 'docker-compose': 3, bazel: 6, turbo: 4, nx: 4, xcodebuild: 6, swift: 4, fastlane: 4,
|
|
14
|
+
npm: 3, pnpm: 3, yarn: 3, bun: 3, browser: 3,
|
|
15
|
+
}
|
|
16
|
+
const DEFAULT_GB = 3
|
|
17
|
+
|
|
18
|
+
function estimateGb(name, overrides) {
|
|
19
|
+
const n = overrides && overrides[name]
|
|
20
|
+
if (typeof n === 'number' && n > 0 && n <= 256) return n
|
|
21
|
+
return ESTIMATES_GB[name] || DEFAULT_GB
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A short name for bulkhead status: "npm test", "cargo build". Only characters that need no
|
|
25
|
+
// quoting inside the label, so a command's own quotes never reach the wrapper.
|
|
26
|
+
function labelFor(argv) {
|
|
27
|
+
const sub = argv.slice(1).find(a => !a.startsWith('-')) || ''
|
|
28
|
+
const raw = `${shell.bin(argv[0])} ${sub}`.trim().slice(0, 40)
|
|
29
|
+
return raw.replace(/[^\w .:/@=+-]/g, '_')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One word for each shell. bash: single quotes, a quote inside becomes '\''. PowerShell: single
|
|
33
|
+
// quotes, a quote inside is doubled. cmd: double quotes, a quote inside is doubled.
|
|
34
|
+
const SAFE = /^[\w@%+=:,./-]+$/
|
|
35
|
+
const SAFE_WIN = /^[\w@%+=:,./\\-]+$/
|
|
36
|
+
const quoters = {
|
|
37
|
+
sh: a => (SAFE.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`),
|
|
38
|
+
ps: a => (SAFE_WIN.test(a) ? a : `'${a.replace(/'/g, "''")}'`),
|
|
39
|
+
cmd: a => (SAFE_WIN.test(a) ? a : `"${a.replace(/"/g, '""')}"`),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function which(name, env = process.env, exists = fs.existsSync, platform = process.platform) {
|
|
43
|
+
const dirs = String(env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)
|
|
44
|
+
const file = platform === 'win32' ? `${name}.exe` : name
|
|
45
|
+
return dirs.some(d => exists(path.join(d, file))) ? name : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The slot script of this package, from the plugin root when Claude Code sets it. null when the
|
|
49
|
+
// file is missing: the caller then leaves the command alone.
|
|
50
|
+
function slotScript({ env = process.env, exists = fs.existsSync, dir = __dirname } = {}) {
|
|
51
|
+
const roots = [env.CLAUDE_PLUGIN_ROOT, path.join(dir, '..')].filter(Boolean)
|
|
52
|
+
for (const r of roots) {
|
|
53
|
+
const f = path.join(r, 'bin', 'bulkhead-slot')
|
|
54
|
+
if (exists(f)) return f
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The command text with `-- <command>` handed to bulkhead-slot, or null when this shell cannot
|
|
60
|
+
// carry it (a newline in a cmd line is a second command). The original runs in the same kind
|
|
61
|
+
// of shell as before: bash -c for the Bash tool, an encoded script for PowerShell.
|
|
62
|
+
function wrap(command, { shell: sh = 'sh', node = process.execPath, script, gb, label, wait, env = process.env, exists, platform } = {}) {
|
|
63
|
+
const q = quoters[sh]
|
|
64
|
+
if (!q || !script) return null
|
|
65
|
+
const head = [node, script].map(q)
|
|
66
|
+
const opts = ['run', `--gb=${gb}`, q(`--label=${label}`)]
|
|
67
|
+
if (wait) opts.push(`--wait=${wait}`)
|
|
68
|
+
let tail
|
|
69
|
+
if (sh === 'sh') tail = `bash -c ${q(command)}`
|
|
70
|
+
else if (sh === 'ps') {
|
|
71
|
+
const exe = which('pwsh', env, exists, platform) || 'powershell'
|
|
72
|
+
tail = `${exe} -NoProfile -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`
|
|
73
|
+
} else {
|
|
74
|
+
if (/[\r\n]/.test(command)) return null
|
|
75
|
+
tail = q(command)
|
|
76
|
+
}
|
|
77
|
+
return `${sh === 'ps' ? '& ' : ''}${head.join(' ')} ${opts.join(' ')} -- ${tail}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A single & backgrounds a command, so the slot would be released at once and hold nothing.
|
|
81
|
+
function backgrounded(command, mode = 'sh') {
|
|
82
|
+
const text = String(command)
|
|
83
|
+
if (mode === 'ps') return /\bStart-(?:Job|Process)\b|\bsaps\b/i.test(text)
|
|
84
|
+
const bare = text.replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, ' ').replace(/&&|\d?>&\d?|&>>?|\|&/g, ' ')
|
|
85
|
+
return bare.includes('&') || /\b(?:nohup|setsid|disown)\b/.test(bare)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Already queued, or this is the wrapper we wrote.
|
|
89
|
+
function wrapped(command) {
|
|
90
|
+
return /bulkhead(?:-|\s+)slot/.test(String(command))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { ESTIMATES_GB, estimateGb, labelFor, wrap, slotScript, backgrounded, wrapped, quoters, which }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { runHook, enabled } = require('./hook')
|
|
3
|
+
|
|
4
|
+
// Every Bash and PowerShell guard in one process. Seven separate hooks meant seven Node starts
|
|
5
|
+
// per tool call, which adds up when ten sessions share a machine. The guards run in order of
|
|
6
|
+
// cost, text-only checks first and the ones that read git, memory, processes or ports last, and
|
|
7
|
+
// the first block stops the rest. A guard that throws is skipped, so one bug never silences the
|
|
8
|
+
// others; the whole hook still fails open on anything unexpected.
|
|
9
|
+
const ORDER = [
|
|
10
|
+
['secrets-guard', './secrets-guard'],
|
|
11
|
+
['wait-guard', './wait-guard'],
|
|
12
|
+
['disk-guard', './disk-guard'],
|
|
13
|
+
['kill-guard', './kill-guard'],
|
|
14
|
+
['basics', './basics'],
|
|
15
|
+
['checkout-guard', './checkout-guard'],
|
|
16
|
+
['memory-guard', './memory-guard'],
|
|
17
|
+
['port-guard', './port-guard'],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// { block } from the first guard that blocks, else the first non-blocking output (memory-guard's
|
|
21
|
+
// queued rewrite), else nothing. `rewrite: false` drops rewrites for callers that cannot use them.
|
|
22
|
+
async function judge(input, { rewrite = true, order = ORDER } = {}) {
|
|
23
|
+
let output = null
|
|
24
|
+
for (const [name, file] of order) {
|
|
25
|
+
if (!enabled(name)) continue
|
|
26
|
+
let result = null
|
|
27
|
+
try { result = await (typeof file === 'string' ? require(file) : file).handle(input) } catch {}
|
|
28
|
+
if (result && result.block) return { block: result.block }
|
|
29
|
+
if (rewrite && result && result.output && !output) output = result.output
|
|
30
|
+
}
|
|
31
|
+
return output ? { output } : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (require.main === module) runHook('bash-guards', input => judge(input))
|
|
35
|
+
|
|
36
|
+
module.exports = { judge, ORDER }
|
package/src/basics.js
CHANGED
|
@@ -5,9 +5,10 @@ const { execFileSync } = require('child_process')
|
|
|
5
5
|
const { runHook, enabled, setting, blocked, oneLine, ASK_USER } = require('./hook')
|
|
6
6
|
const shell = require('./shell')
|
|
7
7
|
const { realPath } = require('./paths')
|
|
8
|
+
const { gitCall, gitEnv, parseArgs } = require('./gitargs')
|
|
8
9
|
|
|
9
10
|
// Narrow rules for mistakes that cannot be undone. Each can be turned off on its own:
|
|
10
|
-
// rm-root, force-push-main, discard-changes, agent-author.
|
|
11
|
+
// rm-root, force-push-main, discard-changes, agent-author, self-allow.
|
|
11
12
|
|
|
12
13
|
// ~, $HOME and %USERPROFILE% become the home directory; /c/... becomes C:/... on Windows.
|
|
13
14
|
// Returns null for anything with a variable we cannot expand.
|
|
@@ -94,20 +95,6 @@ function git(args, cwd) {
|
|
|
94
95
|
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function gitCwd(t, cwd) {
|
|
98
|
-
const i = t.indexOf('-C')
|
|
99
|
-
return i > 0 && t[i + 1] ? path.resolve(cwd, t[i + 1]) : cwd
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function gitSub(t) {
|
|
103
|
-
const rest = []
|
|
104
|
-
for (let i = 1; i < t.length; i++) {
|
|
105
|
-
if (t[i] === '-C' || t[i] === '-c') i++
|
|
106
|
-
else rest.push(t[i])
|
|
107
|
-
}
|
|
108
|
-
return rest
|
|
109
|
-
}
|
|
110
|
-
|
|
111
98
|
function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
|
|
112
99
|
if (rest[0] !== 'push') return false
|
|
113
100
|
const args = rest.slice(1)
|
|
@@ -119,6 +106,26 @@ function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
|
|
|
119
106
|
return targets.some(r => protectedBranches.includes(String(r || '').replace(/^refs\/heads\//, '')))
|
|
120
107
|
}
|
|
121
108
|
|
|
109
|
+
// git push origin :main, git push --delete origin main, git push -d origin main, and
|
|
110
|
+
// git branch -D main (or -d -f, --delete --force): removing a protected branch. A plain
|
|
111
|
+
// git branch -d refuses unmerged work and is left alone.
|
|
112
|
+
function deletesProtected(rest, protectedBranches = ['main', 'master']) {
|
|
113
|
+
const name = r => String(r || '').replace(/^\+/, '').replace(/^refs\/heads\//, '')
|
|
114
|
+
const isProtected = r => protectedBranches.includes(name(r))
|
|
115
|
+
if (rest[0] === 'push') {
|
|
116
|
+
const o = parseArgs(rest.slice(1), { valuedShort: 'o', valuedLong: ['repo', 'push-option', 'receive-pack', 'exec'] })
|
|
117
|
+
const del = o.short.has('d') || [...o.long].some(n => n.length >= 3 && 'delete'.startsWith(n))
|
|
118
|
+
return o.pos.slice(1).map(r => r.replace(/^\+/, '')).some(r => (r.startsWith(':') ? isProtected(r.slice(1)) : del && isProtected(r)))
|
|
119
|
+
}
|
|
120
|
+
if (rest[0] === 'branch') {
|
|
121
|
+
const o = parseArgs(rest.slice(1), { valuedShort: 'uts', valuedLong: ['set-upstream-to', 'track', 'sort', 'format', 'contains', 'merged', 'no-merged', 'points-at'] })
|
|
122
|
+
const del = o.short.has('D') || o.short.has('d') || o.long.has('delete')
|
|
123
|
+
const force = o.short.has('D') || o.short.has('f') || o.long.has('force')
|
|
124
|
+
return del && force && o.pos.some(isProtected)
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
122
129
|
// An identity that is the agent, not the person: Claude <noreply@anthropic.com> and the like.
|
|
123
130
|
const AGENT_ID = /\bclaude\b|anthropic/i
|
|
124
131
|
|
|
@@ -164,7 +171,12 @@ function check(cmd, {
|
|
|
164
171
|
const rmRoot = once(() => enabled('rm-root'))
|
|
165
172
|
const forcePush = once(() => enabled('force-push-main'))
|
|
166
173
|
const discard = once(() => enabled('discard-changes'))
|
|
174
|
+
const protectedDelete = once(() => enabled('protected-delete'))
|
|
167
175
|
const author = once(() => enabled('agent-author'))
|
|
176
|
+
if (/allow/i.test(String(cmd || '')) && enabled('self-allow')) {
|
|
177
|
+
const self = require('./self-allow').check(cmd, { mode })
|
|
178
|
+
if (self) return self
|
|
179
|
+
}
|
|
168
180
|
const prepared = shell.prepare(String(cmd || ''), mode)
|
|
169
181
|
const cmds = shell.commands(prepared, mode).filter(x => !x.remote)
|
|
170
182
|
if (author() && /GIT_(?:AUTHOR|COMMITTER)_/i.test(prepared)) {
|
|
@@ -192,45 +204,52 @@ function check(cmd, {
|
|
|
192
204
|
}
|
|
193
205
|
}
|
|
194
206
|
if (path.basename(t[0]).replace(/\.exe$/i, '') !== 'git') continue
|
|
195
|
-
const
|
|
196
|
-
const
|
|
207
|
+
const call = gitCall(t, cwd, c.depth === 0 ? gitEnv(prepared.slice(c.s, c.e)) : {})
|
|
208
|
+
const dir = call.dir
|
|
209
|
+
const rest = call.sub ? [call.sub, ...call.args] : []
|
|
197
210
|
const who = author() && agentAuthor(t, rest)
|
|
198
211
|
if (who) return agentMessage(who)
|
|
199
212
|
if (rest[0] === 'push' && forcePush() && forcePushToMain(rest, () => { try { return run(['rev-parse', '--abbrev-ref', 'HEAD'], dir) } catch { return '' } }, protectedBranches)) {
|
|
200
213
|
return `[bulkhead] basics: force-pushing to ${protectedBranches.join(' or ')} rewrites history other people and sessions have pulled.\n` +
|
|
201
214
|
'Push to a branch, or use --force-with-lease if you are sure.'
|
|
202
215
|
}
|
|
216
|
+
if (rest[0] && protectedDelete() && deletesProtected(rest, protectedBranches)) {
|
|
217
|
+
return `[bulkhead] basics: \`${oneLine(seg, 120)}\` deletes the protected branch (${protectedBranches.join(', ')}), and the work on it goes with it.\n` +
|
|
218
|
+
'Delete a feature branch instead, or turn the rule off for this project if you really mean it.'
|
|
219
|
+
}
|
|
203
220
|
const reset = rest[0] === 'reset' && rest.includes('--hard')
|
|
204
221
|
const clean = rest[0] === 'clean' && rest.some(a => /^-[a-z]*f/.test(a) || a === '--force') && rest.some(a => /^-[a-z]*[dx]/.test(a))
|
|
205
222
|
if ((reset || clean) && discard()) {
|
|
206
223
|
let status
|
|
207
|
-
try { status = run(['status', '--porcelain'], dir) } catch { continue }
|
|
224
|
+
try { status = run([...call.opts, 'status', '--porcelain'], dir) } catch { continue }
|
|
208
225
|
const lines = status.split('\n').filter(Boolean)
|
|
209
226
|
const lost = reset ? lines.filter(l => !l.startsWith('??')) : lines.filter(l => l.startsWith('??'))
|
|
210
227
|
if (lost.length) {
|
|
211
228
|
return `[bulkhead] basics: \`${oneLine(seg, 120)}\` would throw away ${lost.length} ${reset ? 'uncommitted change' : 'untracked file'}${lost.length === 1 ? '' : 's'}` +
|
|
212
229
|
` (${lost.slice(0, 3).map(l => l.slice(3)).join(', ')}${lost.length > 3 ? ', ...' : ''}).\n` +
|
|
213
|
-
'Commit them or
|
|
230
|
+
'Commit them, or move the files somewhere else, first. `git clean -n` and `git status` show what would go.'
|
|
214
231
|
}
|
|
215
232
|
}
|
|
216
233
|
}
|
|
217
234
|
return null
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return blocked('basics', ruleOf(why), input, `${why}\n${ASK_USER}`)
|
|
226
|
-
})
|
|
237
|
+
function handle(input) {
|
|
238
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
239
|
+
const why = typeof cmd === 'string' && cmd && check(cmd, { cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
|
|
240
|
+
if (!why) return
|
|
241
|
+
return blocked('basics', ruleOf(why), input, ruleOf(why) === 'self-allow' ? why : `${why}\n${ASK_USER}`)
|
|
227
242
|
}
|
|
228
243
|
|
|
244
|
+
if (require.main === module) runHook('basics', handle)
|
|
245
|
+
|
|
229
246
|
function ruleOf(message) {
|
|
247
|
+
if (/cannot approve its own blocks/.test(message)) return 'self-allow'
|
|
230
248
|
if (/makes the agent the author/.test(message)) return 'agent-author'
|
|
231
249
|
if (/force-pushing/.test(message)) return 'force-push-main'
|
|
250
|
+
if (/deletes the protected branch/.test(message)) return 'protected-delete'
|
|
232
251
|
if (/would throw away/.test(message)) return 'discard-changes'
|
|
233
252
|
return 'rm-root'
|
|
234
253
|
}
|
|
235
254
|
|
|
236
|
-
module.exports = { check, rmTargets, dangerousTarget, forcePushToMain, ruleOf, agentAuthor }
|
|
255
|
+
module.exports = { handle, check, rmTargets, dangerousTarget, forcePushToMain, deletesProtected, ruleOf, agentAuthor }
|