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,301 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { runHook, blocked, setting, ASK_USER } = require('./hook')
|
|
6
|
+
const shell = require('./shell')
|
|
7
|
+
|
|
8
|
+
// Opt-in (secretsGuard.enabled). Stops the agent from reading secret files into the conversation:
|
|
9
|
+
// the Read and Grep tools on them, and shell commands that print, encode or upload them or dump the
|
|
10
|
+
// whole environment. Writing a secret file is fine, and so are ls, stat, test -f and git check-ignore.
|
|
11
|
+
// This is a guard against careless reads, not a sandbox: a program that opens the file itself is not seen.
|
|
12
|
+
|
|
13
|
+
const SAFE_ENV = /^\.env\.(?:example|sample|template|dist|defaults|tpl|schema)$/i
|
|
14
|
+
const SSH_SAFE = /^(?:config|known_hosts(?:\.old)?|authorized_keys2?|allowed_signers|environment|rc)$|\.pub$/i
|
|
15
|
+
const PUBLIC_PEM = /-----BEGIN (?:CERTIFICATE|TRUSTED CERTIFICATE|PUBLIC KEY|X509 CRL|CERTIFICATE REQUEST)-----/
|
|
16
|
+
const NPM_TOKEN = /_authToken|_auth\b|_password|:_password/i
|
|
17
|
+
// Files that hold a secret only some of the time: read, and block when they do.
|
|
18
|
+
const CONTENT_RULES = [
|
|
19
|
+
[/(?:^|\/)\.npmrc$/i, NPM_TOKEN, 'an npm auth token'],
|
|
20
|
+
[/(?:^|\/)\.yarnrc\.yml$/i, /npmAuthToken|npmAuthIdent/i, 'an npm auth token'],
|
|
21
|
+
[/(?:^|\/)\.pypirc$/i, /password|token/i, 'a PyPI password or token'],
|
|
22
|
+
[/(?:^|\/)\.docker\/config\.json$/i, /"auth"\s*:|identitytoken|registrytoken/i, 'Docker registry credentials'],
|
|
23
|
+
]
|
|
24
|
+
// Names a shell glob such as .env* or ~/.ssh/* could expand to.
|
|
25
|
+
const SAMPLES = ['.env', '.env.local', '.env.production', 'id_rsa', 'id_ed25519', 'server.pem', 'server.key', 'credentials',
|
|
26
|
+
'hosts.yml', '.netrc', 'config', 'config.json', 'credentials.db', 'access_tokens.db', 'application_default_credentials.json', '.npmrc']
|
|
27
|
+
|
|
28
|
+
const SECRET_DIRS = /\/(?:\.ssh|\.aws|\.kube|\.azure|\.config\/gh|\.config\/gcloud)$/i
|
|
29
|
+
const READERS = new Set(['cat', 'tac', 'less', 'more', 'most', 'head', 'tail', 'nl', 'bat', 'batcat', 'cut', 'sort', 'uniq', 'od', 'xxd',
|
|
30
|
+
'hexdump', 'strings', 'base64', 'base32', 'basenc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'nawk',
|
|
31
|
+
'jq', 'yq', 'diff', 'cmp', 'comm', 'paste', 'column', 'fold', 'rev', 'get-content', 'gc', 'type', 'select-string', 'sls',
|
|
32
|
+
'cp', 'scp', 'rsync', 'curl', 'wget'])
|
|
33
|
+
// the first plain argument of these is a pattern or a program, not a file
|
|
34
|
+
const PATTERN_FIRST = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'nawk', 'jq', 'yq', 'select-string', 'sls'])
|
|
35
|
+
const COPIERS = new Set(['cp', 'scp', 'rsync'])
|
|
36
|
+
// options of the copy tools that take a value which is not a file to copy (scp -i ~/.ssh/id_rsa names a key it uses)
|
|
37
|
+
const VALUE_FLAGS = { cp: ['-t', '-S'], scp: ['-i', '-o', '-P', '-F', '-J', '-l', '-c', '-S'], rsync: ['-e', '--rsh', '--port', '--rsync-path', '--exclude', '--include'] }
|
|
38
|
+
const UPLOAD_FLAG = /^(?:-T|--upload-file|--post-file|--body-file|-d|--data|--data-binary|--data-urlencode|-F|--form|--json)$/
|
|
39
|
+
const CLI_PRINTERS = [
|
|
40
|
+
[/^gh auth token\b|^gh auth status\b.*(?:\s-t\b|--show-token)/, 'the GitHub CLI token'],
|
|
41
|
+
[/^gcloud auth (?:application-default )?print-(?:access|identity)-token\b/, 'a Google Cloud access token'],
|
|
42
|
+
[/^aws configure get\s+\S*(?:secret|token|key)/i, 'an AWS secret'],
|
|
43
|
+
[/^npm (?:config|c) get\s+\S*(?:_auth|token|password)/i, 'an npm auth token'],
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
function globRegex(pattern, { shellGlob = false } = {}) {
|
|
47
|
+
let re = ''
|
|
48
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
49
|
+
const c = pattern[i]
|
|
50
|
+
if (c === '*' && pattern[i + 1] === '*') { re += '.*'; i++ }
|
|
51
|
+
else if (c === '*') re += '[^/]*'
|
|
52
|
+
else if (c === '?') re += '[^/]'
|
|
53
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
}
|
|
55
|
+
return new RegExp((shellGlob && !pattern.startsWith('.') ? '^(?!\\.)' : '^') + re + '$', 'i')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expand(arg, cwd, home) {
|
|
59
|
+
let p = String(arg).replace(/\\/g, '/')
|
|
60
|
+
p = p.replace(/^~(?=\/|$)/, home).replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=\/|$)/i, home)
|
|
61
|
+
p = p.replace(/^(?:\$PWD|\$\{PWD\})(?=\/|$)/, cwd)
|
|
62
|
+
return path.posix.normalize(/^(?:\/|[a-zA-Z]:\/)/.test(p) ? p : `${cwd.replace(/\\/g, '/')}/${p}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Whether `p` (absolute, forward slashes) matches a user pattern: a plain file name, or a path
|
|
66
|
+
// pattern that may start with ~ and use * and **.
|
|
67
|
+
function userMatch(p, patterns, home) {
|
|
68
|
+
return patterns.some(raw => {
|
|
69
|
+
const pat = raw.replace(/\\/g, '/').replace(/^~(?=\/|$)/, home)
|
|
70
|
+
if (!pat.includes('/')) return globRegex(pat).test(p.slice(p.lastIndexOf('/') + 1))
|
|
71
|
+
const re = globRegex(pat.replace(/^\*\*\//, ''))
|
|
72
|
+
return pat.startsWith('/') || /^[a-zA-Z]:/.test(pat) ? globRegex(pat).test(p) : new RegExp(`(?:^|/)${re.source.slice(1)}`, 'i').test(p)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function peek(file) {
|
|
77
|
+
try {
|
|
78
|
+
const fd = fs.openSync(file, 'r')
|
|
79
|
+
try {
|
|
80
|
+
const buf = Buffer.alloc(32768)
|
|
81
|
+
return buf.toString('utf8', 0, fs.readSync(fd, buf, 0, buf.length, 0))
|
|
82
|
+
} finally {
|
|
83
|
+
fs.closeSync(fd)
|
|
84
|
+
}
|
|
85
|
+
} catch { return null }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The kind of secret the file is, or null. `p` is absolute with forward slashes.
|
|
89
|
+
function classifyPath(p, { home = os.homedir().replace(/\\/g, '/'), extra = [], allow = [], read = peek } = {}) {
|
|
90
|
+
if (allow.length && userMatch(p, allow, home)) return null
|
|
91
|
+
const base = p.slice(p.lastIndexOf('/') + 1)
|
|
92
|
+
const lower = p.toLowerCase()
|
|
93
|
+
const parent = lower.split('/').slice(-2, -1)[0]
|
|
94
|
+
if (/^\.env(?:\..+)?$/i.test(base) && !SAFE_ENV.test(base)) return 'an environment file'
|
|
95
|
+
if (/\.(?:pem|key|p12|pfx)$/i.test(base)) {
|
|
96
|
+
const text = /\.(?:pem|key)$/i.test(base) ? read(p) : null
|
|
97
|
+
return text && PUBLIC_PEM.test(text) && !/PRIVATE KEY/.test(text) ? null : 'a private key file'
|
|
98
|
+
}
|
|
99
|
+
if (/^id_(?:rsa|dsa|ecdsa|ed25519)/i.test(base) && !/\.pub$/i.test(base)) return 'an SSH private key'
|
|
100
|
+
if (parent === '.ssh' && !SSH_SAFE.test(base)) return 'an SSH private key'
|
|
101
|
+
if (/^(?:\.netrc|_netrc)$/i.test(base)) return 'netrc credentials'
|
|
102
|
+
if (/^(?:\.git-credentials|\.pgpass|\.vault-token)$/i.test(base)) return 'stored credentials'
|
|
103
|
+
if (/\/\.aws\/credentials$/.test(lower) || /\/\.aws\/sso\/cache\//.test(lower)) return 'AWS credentials'
|
|
104
|
+
if (/\/\.config\/gh\/hosts\.yml$/.test(lower)) return 'the GitHub CLI token'
|
|
105
|
+
if (/\/\.kube\/config$/.test(lower)) return 'Kubernetes credentials'
|
|
106
|
+
if (/\/\.azure\/accesstokens\.json$/.test(lower)) return 'Azure tokens'
|
|
107
|
+
if (/\/gcloud\/(?:credentials\.db|access_tokens\.db|application_default_credentials\.json|legacy_credentials\/)/.test(lower)) return 'Google Cloud credentials'
|
|
108
|
+
if (/^\/proc\/[^/]+\/environ$/.test(lower)) return 'a process environment'
|
|
109
|
+
for (const [name, content, label] of CONTENT_RULES) {
|
|
110
|
+
if (!name.test(lower)) continue
|
|
111
|
+
const text = read(p)
|
|
112
|
+
return text && content.test(text) ? label : null
|
|
113
|
+
}
|
|
114
|
+
if (extra.length && userMatch(p, extra, home)) return 'a file you listed in secretsGuard.extraPatterns'
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// classifyPath for an argument: a literal path, or a glob that could expand to a secret.
|
|
119
|
+
function classifyArg(arg, ctx, opts) {
|
|
120
|
+
if (!arg || typeof arg !== 'string') return null
|
|
121
|
+
const p = expand(arg, ctx.cwd, ctx.home)
|
|
122
|
+
const name = p.slice(p.lastIndexOf('/') + 1)
|
|
123
|
+
if (!/[*?[]/.test(name)) return classifyPath(p, opts)
|
|
124
|
+
const dir = p.slice(0, p.lastIndexOf('/'))
|
|
125
|
+
const likely = `${dir}/${name.replace(/[*?[\]]/g, 'x')}`
|
|
126
|
+
if (opts.extra && opts.extra.length && !(opts.allow && opts.allow.length && userMatch(likely, opts.allow, opts.home)) && userMatch(likely, opts.extra, opts.home)) {
|
|
127
|
+
return 'a file you listed in secretsGuard.extraPatterns'
|
|
128
|
+
}
|
|
129
|
+
// a bare * says nothing about which names it expands to; only a folder of secrets makes it a hit
|
|
130
|
+
if (/^[*?]+$/.test(p.slice(p.lastIndexOf('/') + 1)) && !SECRET_DIRS.test(dir)) return null
|
|
131
|
+
const re = globRegex(p.slice(p.lastIndexOf('/') + 1), { shellGlob: true })
|
|
132
|
+
for (const s of SAMPLES) {
|
|
133
|
+
if (!re.test(s)) continue
|
|
134
|
+
const found = classifyPath(`${dir}/${s}`, opts)
|
|
135
|
+
if (found) return found
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A folder that holds secrets (~/.ssh, ~/.aws), for commands that read everything below it.
|
|
141
|
+
function classifyDir(arg, ctx, opts) {
|
|
142
|
+
const p = expand(arg, ctx.cwd, ctx.home).replace(/\/+$/, '')
|
|
143
|
+
return SECRET_DIRS.test(p) ? classifyArg(`${p}/*`, ctx, opts) : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The files a reader command names, in the places a file can be named.
|
|
147
|
+
function fileArgs(argv, b) {
|
|
148
|
+
const args = argv.slice(1)
|
|
149
|
+
if (b === 'sed' && args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place'))) return []
|
|
150
|
+
const out = []
|
|
151
|
+
const takesPattern = PATTERN_FIRST.has(b) && !args.some(a => /^(?:-e|-f|--regexp|--file|--expression|-Pattern)(?:=|$)/i.test(a))
|
|
152
|
+
let seen = !takesPattern
|
|
153
|
+
const upload = b === 'curl' || b === 'wget'
|
|
154
|
+
for (let i = 0; i < args.length; i++) {
|
|
155
|
+
const a = args[i]
|
|
156
|
+
if (/^(?:\d*>>?|&>>?|>\||\d*>&.*)$/.test(a) || a === '<<<' || a === '<<') { i++; continue }
|
|
157
|
+
if (/^\d*>/.test(a)) continue
|
|
158
|
+
if (a === '<') { if (args[i + 1]) out.push(args[++i]); continue }
|
|
159
|
+
if (/^<[^<(]/.test(a)) { out.push(a.slice(1)); continue }
|
|
160
|
+
if ((VALUE_FLAGS[b] || []).includes(a)) { i++; continue }
|
|
161
|
+
if (upload) {
|
|
162
|
+
const m = /^(-T|--upload-file|--post-file|--body-file|-d|--data|--data-binary|--data-urlencode|-F|--form|--json)(?:=(.*))?$/.exec(a) || /^(-[dFT])(.+)$/.exec(a)
|
|
163
|
+
if (m && UPLOAD_FLAG.test(m[1])) {
|
|
164
|
+
const v = m[2] !== undefined ? m[2] : args[++i]
|
|
165
|
+
if (v == null) continue
|
|
166
|
+
if (/^(?:-T|--upload-file|--post-file|--body-file)$/.test(m[1])) out.push(v)
|
|
167
|
+
else {
|
|
168
|
+
const f = /(?:^|=)[@<](.+)$/.exec(v) || /^[\w-]+@(.+)$/.exec(v) || /^@(.+)$/.exec(v)
|
|
169
|
+
if (f) out.push(f[1].split(';')[0])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (a.startsWith('-') && a !== '-') {
|
|
175
|
+
const eq = a.indexOf('=')
|
|
176
|
+
if (a.startsWith('--') && eq > 0) out.push(a.slice(eq + 1))
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (!seen) { seen = true; continue }
|
|
180
|
+
out.push(a)
|
|
181
|
+
}
|
|
182
|
+
if (COPIERS.has(b)) out.pop()
|
|
183
|
+
return b === 'scp' || b === 'rsync' ? out.filter(a => !/^[^/\\]{2,}:/.test(a)) : out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A pipe straight into something that only counts or tests keeps the values out of the output.
|
|
187
|
+
function harmlessPipe(text, end) {
|
|
188
|
+
const m = /^\s*\|&?\s*([\w./-]+)((?:\s+-[\w-]+)*)/.exec(text.slice(end))
|
|
189
|
+
if (!m) return false
|
|
190
|
+
const b = shell.bin(m[1])
|
|
191
|
+
return b === 'wc' || (/^e?grep$/.test(b) && /(?:^|\s)-[a-zA-Z]*[qcl]/.test(m[2]))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function withoutRedirects(words) {
|
|
195
|
+
const out = []
|
|
196
|
+
for (let i = 0; i < words.length; i++) {
|
|
197
|
+
if (/^(?:\d*>>?|&>>?|>\||<)$/.test(words[i])) i++
|
|
198
|
+
else if (!/^\d*[<>]/.test(words[i])) out.push(words[i])
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `env`, `printenv`, `export -p`, a bare `set`: the whole environment.
|
|
204
|
+
function dumpsEnvironment(text, mode) {
|
|
205
|
+
const bare = rest => rest.every(a => /^-[a-zA-Z0-9-]*$/.test(a) && a !== '-S')
|
|
206
|
+
let unwrapped = text
|
|
207
|
+
try { unwrapped = shell.unwrap(text, mode) } catch {}
|
|
208
|
+
for (const c of shell.lex(unwrapped, mode).cmds) {
|
|
209
|
+
const i = c.words.findIndex(w => !/^(?:sudo|doas|command|exec|nohup|time|builtin)$/.test(shell.bin(w.v)) && !(w.v.startsWith('-') && !shell.bin(w.v)))
|
|
210
|
+
if (i < 0) continue
|
|
211
|
+
const b = shell.bin(c.words[i].v)
|
|
212
|
+
const rest = withoutRedirects(c.words.slice(i + 1).map(w => w.v))
|
|
213
|
+
const dump = ((b === 'env' || b === 'printenv') && bare(rest)) ||
|
|
214
|
+
((b === 'export' || b === 'declare' || b === 'typeset') && rest.length && rest.every(a => /^-[pxf]+$/.test(a)) && !rest.includes('-f')) ||
|
|
215
|
+
((b === 'export' || b === 'set') && !rest.length) ||
|
|
216
|
+
(b === 'compgen' && rest.join(' ') === '-e') ||
|
|
217
|
+
(/^(?:get-childitem|gci|dir|ls)$/.test(b) && rest.some(a => /^env:\\?\*?$/i.test(a)) && !rest.some(a => /^env:\\?\w/i.test(a)))
|
|
218
|
+
if (dump && !harmlessPipe(unwrapped, c.e)) return true
|
|
219
|
+
}
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function findHit(argv, ctx, opts) {
|
|
224
|
+
const args = argv.slice(1)
|
|
225
|
+
const exec = args.findIndex(a => /^-(?:exec|execdir|ok|okdir)$/.test(a))
|
|
226
|
+
if (exec < 0 || !READERS.has(shell.bin(args[exec + 1] || ''))) return null
|
|
227
|
+
const starts = []
|
|
228
|
+
for (const a of args) { if (a.startsWith('-') || a === '(' || a === '!') break; starts.push(a) }
|
|
229
|
+
for (const s of starts) {
|
|
230
|
+
const hit = classifyDir(s, ctx, opts)
|
|
231
|
+
if (hit) return { label: hit, what: s }
|
|
232
|
+
}
|
|
233
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
234
|
+
if (!/^-(?:i?name|i?path|i?wholename)$/.test(args[i])) continue
|
|
235
|
+
const hit = classifyArg(args[i + 1], { ...ctx, cwd: '/x' }, opts)
|
|
236
|
+
if (hit) return { label: hit, what: args[i + 1] }
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// { rule, label, what } for the first secret the command would show, or null.
|
|
242
|
+
function classifyCommand(cmd, { cwd = process.cwd(), home = os.homedir().replace(/\\/g, '/'), extra = [], allow = [], read = peek, mode = 'sh' } = {}) {
|
|
243
|
+
const ctx = { cwd: cwd.replace(/\\/g, '/'), home }
|
|
244
|
+
const opts = { home, extra, allow, read }
|
|
245
|
+
const text = shell.prepare(String(cmd || ''), mode)
|
|
246
|
+
for (const c of shell.commands(text, mode)) {
|
|
247
|
+
if (c.remote) continue
|
|
248
|
+
const b = shell.bin(c.argv[0])
|
|
249
|
+
const joined = c.argv.join(' ')
|
|
250
|
+
if (b === 'find') {
|
|
251
|
+
const hit = findHit(c.argv, ctx, opts)
|
|
252
|
+
if (hit) return { rule: 'print-file', label: hit.label, what: hit.what }
|
|
253
|
+
}
|
|
254
|
+
for (const [re, label] of CLI_PRINTERS) if (re.test(joined)) return { rule: 'print-token', label, what: joined }
|
|
255
|
+
if (!READERS.has(b)) continue
|
|
256
|
+
for (const a of fileArgs(c.argv, b)) {
|
|
257
|
+
const label = classifyArg(a, ctx, opts) || (/^(?:grep|egrep|fgrep|rg|ag|ack|get-content|gc|type|cat|cp|scp|rsync)$/.test(b) ? classifyDir(a, ctx, opts) : null)
|
|
258
|
+
if (label) return { rule: b === 'cp' || b === 'scp' || b === 'rsync' || b === 'curl' || b === 'wget' ? 'copy-file' : 'print-file', label, what: a }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (/\benv\b|printenv|export|declare|typeset|\bset\b|compgen|get-childitem|gci|\bdir\b|\bls\b/i.test(text) && dumpsEnvironment(text, mode)) {
|
|
262
|
+
return { rule: 'dump-env', label: 'every environment variable', what: 'the whole environment' }
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function classifyTool(input, opts) {
|
|
268
|
+
const t = input.tool_input || {}
|
|
269
|
+
const cwd = (input.cwd || process.cwd()).replace(/\\/g, '/')
|
|
270
|
+
const ctx = { cwd, home: opts.home }
|
|
271
|
+
if (input.tool_name === 'Read') {
|
|
272
|
+
const label = classifyArg(t.file_path, ctx, opts)
|
|
273
|
+
return label && { rule: 'read-file', label, what: t.file_path }
|
|
274
|
+
}
|
|
275
|
+
if (input.tool_name === 'Grep') {
|
|
276
|
+
const label = classifyArg(t.path, ctx, opts) || (t.glob ? classifyArg(`${t.path || '.'}/${t.glob}`, ctx, opts) : null)
|
|
277
|
+
return label && { rule: 'read-file', label, what: t.path || t.glob }
|
|
278
|
+
}
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function message(hit) {
|
|
283
|
+
return `[bulkhead] secrets-guard: this would show ${hit.label} (${hit.what}) to the model.\n` +
|
|
284
|
+
'Ask the user for the specific value you need, or use it by name (for example $API_KEY or --env-file .env) without printing it.\n' +
|
|
285
|
+
ASK_USER
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function handle(input) {
|
|
289
|
+
const home = os.homedir().replace(/\\/g, '/')
|
|
290
|
+
const opts = { home, extra: setting('secretsGuard.extraPatterns', []), allow: setting('secretsGuard.allow', []) }
|
|
291
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
292
|
+
const hit = cmd
|
|
293
|
+
? classifyCommand(cmd, { ...opts, cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
|
|
294
|
+
: classifyTool(input, opts)
|
|
295
|
+
if (!hit) return
|
|
296
|
+
return blocked('secrets-guard', hit.rule, input, message(hit), cmd || hit.what)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (require.main === module) runHook('secrets-guard', handle)
|
|
300
|
+
|
|
301
|
+
module.exports = { handle, classifyCommand, classifyTool, classifyPath, classifyArg, message, globRegex }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const shell = require('./shell')
|
|
4
|
+
|
|
5
|
+
// The agent must not approve its own blocks. `bulkhead allow <code>` is for the user, in their
|
|
6
|
+
// own terminal, so a Bash tool call that runs it, or that writes the allow state files by hand,
|
|
7
|
+
// is blocked (basics, rule self-allow).
|
|
8
|
+
|
|
9
|
+
const NAMES = /^(?:bulkhead|claude-bulkhead|bulkhead-cli)(?:\.(?:js|cjs|mjs|cmd|ps1|exe))?$/i
|
|
10
|
+
const SCRIPT = /(?:^|[\\/])(?:bin[\\/](?:bulkhead|claude-bulkhead|bulkhead-cli)|src[\\/](?:cli|allow-cli|allow-once)\.js)$/i
|
|
11
|
+
const NODE = /^(?:node|nodejs|bun|deno|tsx|ts-node)$/i
|
|
12
|
+
const RUNNER = /^(?:npx|bunx|pnpx|pnpm|npm|yarn|bun|dlx|exec|x)$/i
|
|
13
|
+
|
|
14
|
+
const base = a => path.basename(String(a).replace(/\\/g, '/')).replace(/@[\w.^~-]+$/, '')
|
|
15
|
+
const isAllow = a => /^allow$/i.test(a)
|
|
16
|
+
|
|
17
|
+
// Whether one simple command is a run of `bulkhead allow`.
|
|
18
|
+
function runsAllow(argv) {
|
|
19
|
+
if (!argv.length) return false
|
|
20
|
+
const b = shell.bin(argv[0])
|
|
21
|
+
const rest = argv.slice(1)
|
|
22
|
+
if (NAMES.test(b)) return rest.slice(0, 4).some(isAllow)
|
|
23
|
+
if (NODE.test(b)) {
|
|
24
|
+
const i = rest.findIndex(a => SCRIPT.test(String(a).replace(/\\/g, '/')) || NAMES.test(base(a)))
|
|
25
|
+
return i >= 0 && rest.slice(i + 1, i + 4).some(isAllow)
|
|
26
|
+
}
|
|
27
|
+
if (RUNNER.test(b)) {
|
|
28
|
+
const i = rest.findIndex(a => NAMES.test(base(a)) || /^claude-bulkhead(?:@|$)/i.test(a))
|
|
29
|
+
return i >= 0 && rest.slice(i + 1, i + 4).some(isAllow)
|
|
30
|
+
}
|
|
31
|
+
// $BULKHEAD allow 7F3K, `which bulkhead` allow: a name that is only known at run time
|
|
32
|
+
return /^[$`]|^\$\(/.test(argv[0]) && rest.some(isAllow)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const READERS = /^(?:cat|ls|dir|type|head|tail|less|more|grep|egrep|rg|wc|stat|file|find|test|\[|get-content|gc|get-childitem|select-string|echo|printf)$/i
|
|
36
|
+
const REDIRECT = />/
|
|
37
|
+
|
|
38
|
+
// The folders that hold the allow state: the default one and the one BULKHEAD_STATE_DIR names.
|
|
39
|
+
function stateRe(stateDir) {
|
|
40
|
+
const esc = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/[\\/]+/g, '[\\\\/]+')
|
|
41
|
+
const parts = ['bulkhead[\\\\/]+allow(?:[\\\\/]|\\b)']
|
|
42
|
+
if (stateDir) parts.push(`${esc(stateDir)}[\\\\/]+allow(?:[\\\\/]|\\b)`)
|
|
43
|
+
return new RegExp(parts.join('|'), 'i')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whether the text writes into (or runs anything but a plain read on) the allow state folder.
|
|
47
|
+
function touchesAllowState(src, mode, stateDir) {
|
|
48
|
+
const re = stateRe(stateDir)
|
|
49
|
+
if (!re.test(src)) return false
|
|
50
|
+
const text = shell.prepare(src, mode)
|
|
51
|
+
for (const seg of text.split(/\n|;|&&|\|\|/)) {
|
|
52
|
+
if (!re.test(seg)) continue
|
|
53
|
+
const cmds = shell.commands(seg, mode)
|
|
54
|
+
const reads = cmds.length > 0 && cmds.every(c => READERS.test(shell.bin(c.argv[0])))
|
|
55
|
+
if (!reads || REDIRECT.test(seg.replace(/\d?>&\d|\d?>\s*(?:\/dev\/null|\$null|nul)\b/gi, ' '))) return true
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A message when the command approves a block itself, or null.
|
|
61
|
+
function check(cmd, { mode = 'sh', stateDir = process.env.BULKHEAD_STATE_DIR } = {}) {
|
|
62
|
+
const src = String(cmd || '')
|
|
63
|
+
if (!/allow/i.test(src)) return null
|
|
64
|
+
const prepared = shell.prepare(src, mode)
|
|
65
|
+
const hit = shell.commands(prepared, mode).find(c => runsAllow(c.argv))
|
|
66
|
+
if (hit || touchesAllowState(src, mode, stateDir)) {
|
|
67
|
+
return '[bulkhead] basics: the agent cannot approve its own blocks. `bulkhead allow <code>` and the allow state files are for the user.\n' +
|
|
68
|
+
'Do not run it or edit the files. Tell the user what was blocked and the code from the block message; they run `bulkhead allow <code>` in their own terminal, then you retry the same command.'
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { check, runsAllow, touchesAllowState }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const { execFileSync } = require('child_process')
|
|
4
|
+
const { gitInfo } = require('./git-info')
|
|
5
|
+
const { assignOwners } = require('./sessions')
|
|
6
|
+
const { findMcp } = require('./mcp')
|
|
7
|
+
const { readStates, stateOf } = require('./session-state')
|
|
8
|
+
|
|
9
|
+
function homeShort(p, home = os.homedir()) {
|
|
10
|
+
if (!p) return null
|
|
11
|
+
const s = String(p)
|
|
12
|
+
return home && s.toLowerCase().startsWith(home.toLowerCase()) ? '~' + s.slice(home.length).replace(/\\/g, '/') : s.replace(/\\/g, '/')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Cut from the start so the useful tail stays: "…/dev/api". Starts at a folder boundary when it can.
|
|
16
|
+
function fitPath(s, width) {
|
|
17
|
+
s = String(s == null ? '' : s)
|
|
18
|
+
if (width <= 0) return ''
|
|
19
|
+
if (s.length <= width) return s
|
|
20
|
+
if (width === 1) return '…'
|
|
21
|
+
const tail = s.slice(s.length - (width - 1))
|
|
22
|
+
const cut = tail.indexOf('/')
|
|
23
|
+
return '…' + (cut > 0 && cut < tail.length - 1 ? tail.slice(cut) : tail)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// "session 83183 in ~/dev/api on branch feat-x": how guards name another session in their messages.
|
|
27
|
+
// `s` needs pid and cwd; the branch is read from the folder unless the session already has one.
|
|
28
|
+
function describeSession(s, { home = os.homedir(), git = gitInfo } = {}) {
|
|
29
|
+
let branch = s.branch
|
|
30
|
+
let worktree = s.worktree
|
|
31
|
+
if (branch === undefined && s.cwd) {
|
|
32
|
+
try {
|
|
33
|
+
const g = git(s.cwd)
|
|
34
|
+
branch = g ? g.branch : null
|
|
35
|
+
worktree = g ? g.worktree : false
|
|
36
|
+
} catch { branch = null }
|
|
37
|
+
}
|
|
38
|
+
return `session ${s.pid}` + (s.cwd ? ` in ${homeShort(s.cwd, home)}` : '') +
|
|
39
|
+
(branch ? ` on branch ${branch}${worktree ? ' (worktree)' : ''}` : '')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// pid -> cwd for sessions with no session file. macOS: one lsof for all of them; Linux reads
|
|
43
|
+
// /proc in findSessions already; Windows has no cheap way, so those stay unknown.
|
|
44
|
+
function lookupCwds(pids, { platform = process.platform, run = execFileSync } = {}) {
|
|
45
|
+
const out = new Map()
|
|
46
|
+
if (platform !== 'darwin' || !pids.length) return out
|
|
47
|
+
let text = ''
|
|
48
|
+
try {
|
|
49
|
+
text = run('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, LC_ALL: 'C' } })
|
|
50
|
+
} catch (e) { text = String((e && e.stdout) || '') }
|
|
51
|
+
let pid = 0
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
if (line[0] === 'p') pid = Number(line.slice(1))
|
|
54
|
+
else if (line[0] === 'n' && pid) out.set(pid, line.slice(1))
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Adds branch, worktree and the working/waiting state to each session, and the MCP report.
|
|
60
|
+
// Everything here reads small files; the only subprocess is the optional cwd lookup.
|
|
61
|
+
function enrich(sessions, snapshot, files, {
|
|
62
|
+
states = readStates(), git = gitInfo, cwds = lookupCwds,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const missing = sessions.filter(s => !s.cwd).map(s => s.pid)
|
|
65
|
+
const found = missing.length ? cwds(missing) : new Map()
|
|
66
|
+
const cache = new Map()
|
|
67
|
+
const gitOf = dir => {
|
|
68
|
+
if (!cache.has(dir)) {
|
|
69
|
+
let g = null
|
|
70
|
+
try { g = git(dir) } catch {}
|
|
71
|
+
cache.set(dir, g)
|
|
72
|
+
}
|
|
73
|
+
return cache.get(dir)
|
|
74
|
+
}
|
|
75
|
+
const out = sessions.map(s => {
|
|
76
|
+
const cwd = s.cwd || found.get(s.pid) || null
|
|
77
|
+
const g = cwd ? gitOf(cwd) : null
|
|
78
|
+
const rec = stateOf(states, s, files.get(s.pid))
|
|
79
|
+
return {
|
|
80
|
+
...s,
|
|
81
|
+
cwd,
|
|
82
|
+
branch: g ? g.branch : null,
|
|
83
|
+
worktree: g ? g.worktree : false,
|
|
84
|
+
state: rec ? rec.state : null,
|
|
85
|
+
stateReason: rec ? rec.reason : null,
|
|
86
|
+
stateSince: rec ? rec.since : null,
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
const owner = assignOwners(snapshot.procs, new Set(out.map(s => s.pid)))
|
|
90
|
+
const mcp = findMcp(snapshot.procs, owner, out)
|
|
91
|
+
for (const s of out) s.mcp = mcp.instances.filter(i => i.session === s.pid).map(i => ({ name: i.name, pid: i.pid, rss: i.rss }))
|
|
92
|
+
return { sessions: out, mcp: mcp.groups }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Human text for the STATE column.
|
|
96
|
+
function stateLabel(s, now) {
|
|
97
|
+
if (!s.state) return '-'
|
|
98
|
+
const mins = Math.max(0, Math.floor((now - s.stateSince) / 60000))
|
|
99
|
+
const age = mins >= 1 ? ` ${mins >= 60 ? `${Math.floor(mins / 60)}h` : `${mins}m`}` : ''
|
|
100
|
+
if (s.state === 'working') return 'working'
|
|
101
|
+
if (s.state === 'permission') return (s.stateReason === 'input' ? 'needs input' : 'needs permission') + age
|
|
102
|
+
return 'waiting' + age
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sessions that need you first (permission, then waiting the longest), then the rest in their order.
|
|
106
|
+
function needsUserFirst(sessions) {
|
|
107
|
+
const rank = s => (s.state === 'permission' ? 0 : s.state === 'waiting' ? 1 : 2)
|
|
108
|
+
return sessions.map((s, i) => [s, i]).sort((a, b) => rank(a[0]) - rank(b[0]) ||
|
|
109
|
+
(rank(a[0]) < 2 ? a[0].stateSince - b[0].stateSince : 0) || a[1] - b[1]).map(x => x[0])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function branchLabel(s) {
|
|
113
|
+
return s.branch ? s.branch + (s.worktree ? ' (wt)' : '') : s.worktree ? '(wt)' : '-'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// "3 copies of @playwright/mcp, 1.2 GB"
|
|
117
|
+
function mcpLine(g, size) {
|
|
118
|
+
return `${g.copies} copies of ${g.name}, ${size(g.rss)}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { homeShort, fitPath, describeSession, lookupCwds, enrich, stateLabel, needsUserFirst, branchLabel, mcpLine }
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { spawn } = require('child_process')
|
|
5
|
+
const { runHook, setting, stateDir } = require('./hook')
|
|
6
|
+
|
|
7
|
+
// What each session is doing: working, waiting for you, or waiting on a permission prompt.
|
|
8
|
+
// The hooks in hooks/hooks.json call this script; it writes one small JSON file per session in
|
|
9
|
+
// <state dir>/session-state/ and reads nothing else (no process table, no git). `bulkhead status`
|
|
10
|
+
// and `top` join those files to live sessions by session id, so a file left by a dead session
|
|
11
|
+
// is never shown.
|
|
12
|
+
//
|
|
13
|
+
// Events (https://code.claude.com/docs/en/hooks):
|
|
14
|
+
// UserPromptSubmit, PostToolUse, PostToolUseFailure the turn is running: working
|
|
15
|
+
// Stop Claude finished its reply: waiting
|
|
16
|
+
// Notification permission_prompt a tool needs your approval: permission
|
|
17
|
+
// Notification elicitation_dialog, elicitation_url_dialog Claude asks you a question: permission (reason input)
|
|
18
|
+
// Notification idle_prompt still waiting; keeps the state it has
|
|
19
|
+
// SessionStart (not source compact) a new prompt is open: waiting
|
|
20
|
+
// SessionEnd the file is removed
|
|
21
|
+
|
|
22
|
+
const WORKING = 'working'
|
|
23
|
+
const WAITING = 'waiting'
|
|
24
|
+
const PERMISSION = 'permission'
|
|
25
|
+
// A PostToolUse hook runs async, so the last one of a turn can finish after Stop wrote "waiting".
|
|
26
|
+
// It only takes a session out of "waiting" when that state is older than this.
|
|
27
|
+
const WAITING_GRACE_MS = 3000
|
|
28
|
+
const STALE_FILE_MS = 2 * 24 * 3600 * 1000
|
|
29
|
+
|
|
30
|
+
function dirFor(base = stateDir()) {
|
|
31
|
+
return path.join(base, 'session-state')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The session id becomes a file name, so only plain characters pass.
|
|
35
|
+
function safeId(id) {
|
|
36
|
+
return typeof id === 'string' && /^[\w.-]{1,128}$/.test(id) && !/^\.+$/.test(id) ? id : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What a hook payload means: { remove: true }, { state, reason, notify, soft } or null to ignore.
|
|
40
|
+
// `soft` means the event only confirms the state and must not replace a waiting one younger than the grace.
|
|
41
|
+
function classify(input) {
|
|
42
|
+
switch (input && input.hook_event_name) {
|
|
43
|
+
case 'UserPromptSubmit': return { state: WORKING, reason: null }
|
|
44
|
+
case 'PostToolUse':
|
|
45
|
+
case 'PostToolUseFailure': return { state: WORKING, reason: null, soft: true }
|
|
46
|
+
case 'Stop': return { state: WAITING, reason: 'reply', notify: true }
|
|
47
|
+
case 'SessionStart': return input.source === 'compact' ? null : { state: WAITING, reason: 'idle' }
|
|
48
|
+
case 'SessionEnd': return { remove: true }
|
|
49
|
+
case 'Notification':
|
|
50
|
+
switch (input.notification_type) {
|
|
51
|
+
case 'permission_prompt': return { state: PERMISSION, reason: 'permission', notify: true }
|
|
52
|
+
case 'elicitation_dialog':
|
|
53
|
+
case 'elicitation_url_dialog': return { state: PERMISSION, reason: 'input', notify: true }
|
|
54
|
+
case 'idle_prompt': return { state: WAITING, reason: 'idle', keep: true }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRecord(file) {
|
|
61
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeRecord(file, rec) {
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
66
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(rec))
|
|
68
|
+
fs.renameSync(tmp, file)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Applies one event. Returns { wrote, notify } where notify says a notification should go out now.
|
|
72
|
+
function apply(base, input, { now = Date.now(), minIntervalMs = 30000 } = {}) {
|
|
73
|
+
const id = safeId(input && input.session_id)
|
|
74
|
+
const c = classify(input)
|
|
75
|
+
if (!id || !c) return { wrote: false, notify: false }
|
|
76
|
+
const dir = dirFor(base)
|
|
77
|
+
const file = path.join(dir, `${id}.json`)
|
|
78
|
+
if (c.remove) {
|
|
79
|
+
try { fs.unlinkSync(file) } catch {}
|
|
80
|
+
return { wrote: false, notify: false }
|
|
81
|
+
}
|
|
82
|
+
const prev = readRecord(file)
|
|
83
|
+
const same = prev && prev.state === c.state
|
|
84
|
+
if (c.keep && prev && (prev.state === WAITING || prev.state === PERMISSION)) return { wrote: false, notify: false }
|
|
85
|
+
if (same && !c.notify && (prev.reason || null) === (c.reason || null)) return { wrote: false, notify: false }
|
|
86
|
+
if (c.soft && prev && prev.state === WAITING && now - prev.since < WAITING_GRACE_MS) return { wrote: false, notify: false }
|
|
87
|
+
if (same && c.notify && prev.reason === c.reason) return { wrote: false, notify: false }
|
|
88
|
+
const rec = {
|
|
89
|
+
v: 1,
|
|
90
|
+
session: id,
|
|
91
|
+
state: c.state,
|
|
92
|
+
reason: c.reason,
|
|
93
|
+
since: same ? prev.since : now,
|
|
94
|
+
at: now,
|
|
95
|
+
cwd: input.cwd || (prev && prev.cwd) || null,
|
|
96
|
+
ppid: process.ppid,
|
|
97
|
+
notifiedAt: (prev && prev.notifiedAt) || 0,
|
|
98
|
+
}
|
|
99
|
+
const notify = Boolean(c.notify) && now - rec.notifiedAt >= minIntervalMs
|
|
100
|
+
if (notify) rec.notifiedAt = now
|
|
101
|
+
writeRecord(file, rec)
|
|
102
|
+
return { wrote: true, notify, record: rec }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Files of sessions that ended without a SessionEnd (crash, kill) are removed after two days.
|
|
106
|
+
function prune(base, now = Date.now()) {
|
|
107
|
+
const dir = dirFor(base)
|
|
108
|
+
let names = []
|
|
109
|
+
try { names = fs.readdirSync(dir) } catch { return }
|
|
110
|
+
for (const n of names) {
|
|
111
|
+
try {
|
|
112
|
+
const f = path.join(dir, n)
|
|
113
|
+
if (now - fs.statSync(f).mtimeMs > STALE_FILE_MS) fs.unlinkSync(f)
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Map of session id -> record, for the join in status. Unreadable files are skipped.
|
|
119
|
+
function readStates(base = stateDir()) {
|
|
120
|
+
const dir = dirFor(base)
|
|
121
|
+
const out = new Map()
|
|
122
|
+
let names = []
|
|
123
|
+
try { names = fs.readdirSync(dir) } catch { return out }
|
|
124
|
+
for (const n of names.filter(x => x.endsWith('.json'))) {
|
|
125
|
+
const rec = readRecord(path.join(dir, n))
|
|
126
|
+
if (rec && rec.session && [WORKING, WAITING, PERMISSION].includes(rec.state) && Number(rec.since) > 0) out.set(rec.session, rec)
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The record of a live session: by session id, else by the parent pid the hook saw. A record
|
|
132
|
+
// older than the process (the same id resumed in a new process) is ignored.
|
|
133
|
+
function stateOf(states, session, file, slackMs = 5000) {
|
|
134
|
+
let rec = file && file.sessionId ? states.get(file.sessionId) : null
|
|
135
|
+
if (!rec) rec = [...states.values()].find(r => r.ppid === session.pid && r.at >= session.start) || null
|
|
136
|
+
if (!rec || (session.start && rec.at < session.start - slackMs)) return null
|
|
137
|
+
return rec
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Spawns the user's notify command detached; it never blocks or throws into the hook.
|
|
141
|
+
function runNotify(command, env, spawnFn = spawn) {
|
|
142
|
+
if (typeof command !== 'string' || !command.trim()) return false
|
|
143
|
+
try {
|
|
144
|
+
const child = spawnFn(command, { shell: true, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...env } })
|
|
145
|
+
if (child && child.unref) child.unref()
|
|
146
|
+
if (child && child.on) child.on('error', () => {})
|
|
147
|
+
return true
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sessionPid(sessionId) {
|
|
154
|
+
try {
|
|
155
|
+
for (const [pid, f] of require('./sessions').readSessionFiles()) if (f.sessionId === sessionId) return pid
|
|
156
|
+
} catch {}
|
|
157
|
+
return process.ppid
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handle(input) {
|
|
161
|
+
const minIntervalMs = Math.max(0, Number(setting('notify.minIntervalSeconds', 30))) * 1000
|
|
162
|
+
const r = apply(stateDir(), input, { minIntervalMs })
|
|
163
|
+
if (input.hook_event_name === 'SessionStart') prune(stateDir())
|
|
164
|
+
const command = setting('notify.command', null)
|
|
165
|
+
if (r.notify && command) {
|
|
166
|
+
runNotify(command, {
|
|
167
|
+
BULKHEAD_SESSION_PROJECT: input.cwd || '',
|
|
168
|
+
BULKHEAD_SESSION_STATE: r.record.state,
|
|
169
|
+
BULKHEAD_SESSION_PID: String(sessionPid(input.session_id)),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (require.main === module) runHook('session-state', handle)
|
|
175
|
+
|
|
176
|
+
module.exports = { classify, apply, readStates, stateOf, runNotify, prune, safeId, dirFor, WORKING, WAITING, PERMISSION, WAITING_GRACE_MS }
|