Blockiert Aktionen mit Außenwirkung und gibt nichts frei; fängt Regel-Fallen ab, schwärzt Secrets in Tool-Ausgaben, erinnert an liegengebliebene Mail-Entwürfe.


You run several AI coding sessions at once. Claude Code in one terminal, Codex in another, three repositories, maybe a headless run in the background. It works, until it doesn't. Two sessions edit the same checkout. Each one asks you the same question, so you answer it five times. One merges while the pipeline is still red. And your evening goes to babysitting terminals instead of deciding the things only you can decide.
lead-session-orchestrator puts one lead session above the others. It keeps the overview so you don't have to.
navigator stop set and no starter launches a new run until you clear it.This tool grew out of a real setup: one person, many repositories, several AI sessions working in parallel, day and night. Every rule in it exists because something went wrong without it. A coordinator tried to hand out an approval through a peer message, and the sessions had to refuse it on their own. A counter showed zero because it could not look, and everyone read it as "nothing there". Two sessions shared one checkout and staged each other's files. The decision records in docs/adr keep the reasoning.
The goal is not more automation. The goal is that you can step away from the terminals and still know that nothing ships without your yes.
The CLI is called lead-session-orchestrator, with the short alias navigator. The examples below use navigator.
you
| sign authority files, answer the owner queue
v
+-------------------+
| lead session | lease, gates, ledger, owner queue
| (this tool) |
+-------------------+
| ^ |
constraints check-in starts headless runs (one worktree each)
v | v
+-------------+ +-------------+
| session A | | session B | Claude Code or Codex CLI
| session- | | session- | session-orchestrator runs
| orchestrator| | orchestrator| plan, waves, gates inside
+-------------+ +-------------+
Four pieces carry it:
ledger verify names the first broken one.Requires Node.js 24 or newer and git.
npm i -g lead-session-orchestrator
navigator --help
From source:
git clone https://github.com/Kanevry/lead-session-orchestrator.git
cd lead-session-orchestrator
pnpm install
pnpm build
node dist/cli.js --help
navigator situation # the current picture on one screen
navigator status # all Claude sessions on this host, open questions first
navigator authority query acme-api merge_auf_main # frei | einzeln | nie; without a trusted signer: einzeln
navigator slot check acme-api # busy gate: 0 no foreign signal, 5 busy, 6 not measurable
navigator stop set --reason "maintenance" # nothing new starts until `navigator stop clear --reason "<why>" --source <you>`
Better together, and each works alone. session-orchestrator runs one session well. This tool coordinates several of them. When both are installed, the lead writes a lease and per-session constraints, and each session writes a check-in at start. A session never blocks on the lead: without constraints it falls back to conservative defaults after 10 minutes.
Details: docs/session-orchestrator.md. Using it without session-orchestrator: docs/standalone.md.
Most commands share one exit code scheme. Each command's --help lists its own set.
| Exit | Meaning |
|---|---|
| 0 | done, or nothing found; never an approval |
| 1 | check failed (fail-closed) or write error |
| 2 | usage error |
| 4 | refused |
| 5 | busy |
| 6 | not measurable |
| 7 | boundary hit (remote on a blocked host) |
| 8 | stop is set (starters) |
Some commands use 3, 7, 8 or 9 for their own cases, for example ask (3 parked), mr-pipeline (3 started, 8 no merge request pipeline path) and costs (7 counter broken). Check --help before you script against them.
| Command | What it does | ||||
|---|---|---|---|---|---|
authority check <repo> | Validate an authority file: schema, signature by a trusted key, unchanged against HEAD. | ||||
authority query <repo> <action> | Look up one action: frei (free), einzeln (ask each time) or nie (never). Without a valid file always einzeln. | ||||
ask | Decision channel for headless sessions: answers from the authority file, or parks the question for you. | ||||
identity | Host id and repository id of this machine, never the hostname. | ||||
ledger append / ledger verify | Append-only, hash-chained ledger per host (RFC 8785 canonical JSON). | ||||
| `lease acquire\ | renew\ | handover\ | release\ | status` | One lead at a time. lease unlock-orphaned removes a lock left by a dead process. |
| `stop set\ | clear\ | status` | Global emergency stop that every starter respects. | ||
slot check <repo> | Busy gate: session-orchestrator lock, fresh Codex sessions, boundary gate for blocked remotes. | ||||
| `constraints write\ | show` | Per-session constraints in the session-orchestrator fleet contract. | |||
status / watch | All Claude sessions on this host and the queue of open questions. | ||||
situation | The current picture on one screen: lease, stop, queue, recent runs (5 items per group, 15 runs). | ||||
snapshot | Write the situation as a file for other tools. | ||||
| `owner-queue add\ | list\ | render\ | close` | Collect decisions only you can make, rendered as one round of structured questions. | |
handover | Draft a handover for the next lead session. | ||||
resume-draft <run> | Draft the next phase of a headless run that hit its time limit. | ||||
log | Append a timestamped line to a log file. Text that starts with a time is refused. | ||||
inbox <run> | File inbox per headless run; --read lists new lines. Each line is an instruction, never an approval. | ||||
| `run finish\ | verify <run>` | Check whether a headless run is still alive, then record its end. | |||
mr-pipeline | Make sure a merge request pipeline exists on the head commit; start at most one. GitLab. | ||||
note | Post a file as an issue or MR comment and read it back byte for byte. GitLab. | ||||
mr-diff | Read MR diffs through the API, never by fetching into someone else's checkout. GitLab. | ||||
pipeline-watch | Report red or cancelled pipelines on the default branch. GitLab. | ||||
merge-window | Check whether a merge request may merge now; merges only with --execute and a signed approval. GitLab. | ||||
costs | Cost pots for what the lead itself triggered; a cap is optional. | ||||
push-check <worktree> | Check that the effective pre-push hook exists. |
Headless runs start through two Bash starters, scripts/fleet-start.sh (Claude Code) and scripts/fleet-start-codex.sh (Codex CLI). Before a run starts they pass the boundary gate, the busy gate, a disk and memory gate, the stop gate and a claim. Given a worktree path, each run works in its own worktree, and teardown checks that the run is dead first.
Shared state lives in ~/.config/navigator (move it with NAVIGATOR_CONFIG_DIR). Host details such as your GitLab host, offload hosts, project roots and the authority directory go into one estate file. See docs/configuration.md.
MIT, see LICENSE. Security reports: SECURITY.md. Contributing: CONTRIBUTING.md.
hooks/register.tsx 318 lines1// freigabe blocks outward actions; only the operator can execute them.
2// Traps, the secret shield, draft reminders and the read-only log remain.
3
4import { atom, read, update } from 'claude-code'
5import type { Elements, EngineInterface, Register, RenderSurface } from 'claude-code'
6
7import type { FreigabeDraft } from '../types'
8import {
9 KIND_LABEL,
10 classifyAllOutward,
11 draftIdFrom,
12 findTrap,
13 hasSecret,
14 isOutwardMcp,
15 optionValue,
16 positionals,
17 redactDeep,
18 redactText,
19 tokenize,
20} from './rules'
21import type { Outward, SecretHit } from './rules'
22
23const DRAFT_WARN_MS = 20 * 60 * 1000
24const DRAFT_FORGET_MS = 24 * 60 * 60 * 1000
25const OWN_GITHUB_OWNERS = ['kanevry']
26const PROTECTED_BRANCHES = ['main', 'master']
27
28const drafts = atom({ plugin: 'freigabe', key: 'drafts' } as const, [])
29
30type Report = { skip?: boolean }
31type LogEntry = { at: number; kind: string; label: string; outcome: string }
32type Table = Elements[RenderSurface]
33type Ran = { exitCode: number; stdout: string; stderr: string }
34
35// Calls already passed downstream must never run again after a hook failure.
36const passed = new Set<string>()
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: 'freigaben',
42 description: 'Freigabe-Protokoll: Entscheidungen, Regel-Fallen, Secrets, offene Mail-Entwürfe',
43 })
44 const now = await $.clock.now()
45 const stored = ((await $.store.get('drafts')) as FreigabeDraft[] | undefined) ?? []
46 await update($, drafts, () => stored.filter(d => now - d.at < DRAFT_FORGET_MS))
47 // Draft ages change by the minute, and the band shows them.
48 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
49 return next(e)
50 })
51
52 // ---- Every tool call: guard before, track and shield after -----------------
53 on('tool.call', async ($, e, next) => {
54 if (passed.has(e.tool_use_id)) return { deny: '[freigabe] Dieser Aufruf wurde bereits weitergereicht und läuft nicht erneut.' }
55 const tool = String(e.tool)
56 if (e.tool === 'Bash') {
57 const refused = await guardShell($, e.command)
58 if (refused !== null) return refused
59 } else if (isOutwardMcp(tool)) {
60 const refused = await guardMcp($, tool)
61 if (refused !== null) return refused
62 }
63 passed.add(e.tool_use_id)
64 const ran = await next(e)
65 if (ran.deny !== undefined) return ran
66 await trackDraft($, tool, e.tool === 'Bash' ? e.command : '', e as unknown as Record<string, unknown>, ran.text ?? '')
67 if (ran.text === undefined || !hasSecret(ran.text)) return ran
68 const hits: SecretHit[] = []
69 if (ran.isError === true) {
70 const text = await redactText(ran.text, hits)
71 await noteSecrets($, tool, hits)
72 return { deny: `[freigabe] Fehlerausgabe enthielt ein Secret und ist geschwärzt:\n${text}` }
73 }
74 const result = (await redactDeep(ran.result, hits)) as typeof ran.result
75 await noteSecrets($, tool, hits)
76 return { result, context: [secretNote(hits)] }
77 }).catch(($, e) => {
78 if (passed.has(e.tool_use_id)) {
79 return { deny: '[freigabe] Die Ausgabe ließ sich nicht auf Secrets prüfen (Fehler im Schutz-Hook) und wird zurückgehalten. Den Operator fragen.' }
80 }
81 return { deny: '[freigabe] Schutz-Hook fehlgeschlagen; der Aufruf läuft nicht. Den Operator fragen.' }
82 })
83
84 // ---- /freigaben ------------------------------------------------------------
85 on('command.run', { command: 'freigaben' }, async $ => {
86 const log = ((await $.store.get('log')) as LogEntry[] | undefined) ?? []
87 const open = await read($, drafts)
88 const now = await $.clock.now()
89 const out: string[] = []
90 out.push(log.length === 0 ? 'Noch nichts protokolliert.' : `Protokoll, letzte ${Math.min(15, log.length)} von ${log.length}:`)
91 for (const entry of log.slice(-15)) {
92 out.push(` ${clock(entry.at)} ${entry.kind.padEnd(22)} ${entry.outcome.padEnd(26)} ${entry.label.slice(0, 90)}`)
93 }
94 out.push('')
95 out.push(open.length === 0 ? 'Keine offenen Mail-Entwürfe.' : 'Offene Mail-Entwürfe:')
96 for (const d of open) out.push(` Entwurf ${d.id} seit ${Math.round((now - d.at) / 60_000)} min ${d.label}`)
97 return { text: out.join('\n') }
98 })
99
100 // ---- Drawing -----------------------------------------------------------------
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 if (e.props.hasSurvey) return next(e)
103 const t = $.ui.resolve(e)
104 const now = await $.clock.now()
105 const stale = (await read($, drafts)).filter(d => now - d.at > DRAFT_WARN_MS)
106 const below = await next(e)
107 if (stale.length === 0) return below
108 const { Box } = t
109 const mine = drawDrafts(t, stale, now, () => void dismissDrafts($))
110 return below ? (
111 <Box flexDirection="column">
112 {mine}
113 {below}
114 </Box>
115 ) : (
116 mine
117 )
118 })
119}
120
121// ---- Guards -------------------------------------------------------------------
122
123type Refusal = { deny: string }
124
125/** A refusal for a shell command, or null to let it run. Traps first, then actions with an effect outside. */
126async function guardShell($: EngineInterface, command: string): Promise<Refusal | null> {
127 const trap = findTrap(command)
128 if (trap !== null) {
129 $.ui.toast(`Regel-Falle abgefangen: ${trap.title}`, { timeoutMs: 8000 })
130 await remember($, { kind: 'Regel-Falle', label: trap.title, outcome: 'abgefangen' })
131 return { deny: `[freigabe · Regel-Falle ${trap.id}] ${trap.why}\nStattdessen: ${trap.fix}` }
132 }
133 // Jeder Treffer zaehlt: nur wenn alle Treffer der Zeile skippen, laeuft sie (review R1).
134 for (const hit of classifyAllOutward(command)) {
135 const report = await measure($, hit)
136 if (report.skip !== true) return refuseOutward($, KIND_LABEL[hit.kind], command)
137 }
138 return null
139}
140
141/** Refuses outward MCP calls without executing them. */
142async function guardMcp($: EngineInterface, tool: string): Promise<Refusal> {
143 return refuseOutward($, KIND_LABEL.mcp, tool)
144}
145
146async function refuseOutward($: EngineInterface, kind: string, command: string): Promise<Refusal> {
147 // Geschwaerzt, bevor der Befehl in Protokoll ($.store) oder deny-Text gelangt (review R1).
148 const label = oneLine(await redactText(command, []))
149 await remember($, { kind, label, outcome: 'abgelehnt' })
150 return { deny: `[freigabe] Nicht ausgeführt: Aktion mit Außenwirkung (${kind}: ${label}). freigabe gibt nichts frei; der Operator führt sie selbst aus oder entlädt den Mod.` }
151}
152
153/** Remembers a draft that apple-mail draft/reply or the MCP draft tools opened in Mail.app. */
154async function trackDraft($: EngineInterface, tool: string, command: string, input: Record<string, unknown>, text: string): Promise<void> {
155 const viaShell = /\bapple-mail\s+(draft|reply)\b/.test(command)
156 const viaMcp = /^mcp__apple-mail__mail_(reply_)?draft$/.test(tool)
157 if (!viaShell && !viaMcp) return
158 const id = draftIdFrom(text)
159 if (id === undefined) return
160 const words = tokenize(command)
161 const subject = viaMcp ? String(input.subject ?? '') : (optionValue(words, '--subject') ?? '')
162 const to = viaMcp ? String(input.to ?? '') : (optionValue(words, '--to') ?? '')
163 const label = subject !== '' ? `„${subject.slice(0, 50)}“` : to !== '' ? `an ${to}` : `Antwort, Entwurf ${id}`
164 const now = await $.clock.now()
165 const list = [...(await read($, drafts)).filter(d => d.id !== id), { id, at: now, label }]
166 await update($, drafts, () => list)
167 await $.store.set('drafts', list)
168}
169
170// ---- Measure only the exceptions that may skip blocking -----------------------
171
172const PUSH_VALUED = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
173
174async function measure($: EngineInterface, hit: Outward): Promise<Report> {
175 if (hit.kind !== 'push' && hit.kind !== 'external-gh') return {}
176 try {
177 const cwd = await resolveCwd($, hit.dir)
178 if (cwd === null) return {}
179 return hit.kind === 'push' ? await measurePush($, hit, cwd) : await measureGithub($, hit, cwd)
180 } catch {
181 return {}
182 }
183}
184
185async function measurePush($: EngineInterface, hit: Outward, cwd: string): Promise<Report> {
186 const args = hit.args
187 const pos = positionals(args, PUSH_VALUED)
188 const isForce =
189 args.some(a => a === '--force' || a.startsWith('--force-with-lease') || a === '--force-if-includes' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a)) ||
190 pos.slice(1).some(p => p.startsWith('+'))
191 const withTags = args.some(a => a === '--tags' || a === '--follow-tags' || a === '--mirror' || a === '--all')
192 const isDelete = args.includes('--delete') || args.includes('-d') || pos.slice(1).some(p => p.startsWith(':'))
193 const currentRead = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
194 const upstreamRead = await run($, ['git', 'rev-parse', '--abbrev-ref', '@{u}'], cwd)
195 const current = currentRead.stdout.trim()
196 const upstream = upstreamRead.stdout.trim()
197 const specs = pos.slice(1)
198 if (specs.length === 0 && (currentRead.exitCode !== 0 || current === '' || current === 'HEAD' || upstreamRead.exitCode !== 0 || upstream === '')) return {}
199 const remote = pos[0] ?? optionValue(args, '--repo') ?? ((await run($, ['git', 'config', `branch.${current}.remote`], cwd)).stdout.trim() || 'origin')
200 const urlRead = await run($, ['git', 'remote', 'get-url', remote], cwd)
201 if (urlRead.exitCode !== 0 || urlRead.stdout.trim() === '') return {}
202
203 const pairs =
204 specs.length === 0
205 ? [{ src: 'HEAD', dst: upstream.startsWith(`${remote}/`) ? upstream.slice(remote.length + 1) : current }]
206 : specs.map(spec => {
207 const bare = spec.replace(/^\+/, '')
208 const [src, dst] = bare.includes(':') ? bare.split(':') : [bare, bare]
209 const target = (dst ?? '').replace(/^refs\/heads\//, '')
210 return { src: src === '' || src === undefined ? 'HEAD' : src, dst: target === '' || target === 'HEAD' ? current : target }
211 })
212 if (pairs.some(pair => pair.dst === '' || pair.dst === 'HEAD' || pair.dst.includes('*') || pair.dst.startsWith('refs/tags/'))) return {}
213 if (pairs.some(pair => pair.dst === current) && (currentRead.exitCode !== 0 || current === '')) return {}
214 let isTagSpec = false
215 for (const pair of pairs) {
216 if ((await run($, ['git', 'rev-parse', '--verify', '--quiet', `refs/tags/${pair.dst}`], cwd)).exitCode === 0) isTagSpec = true
217 }
218 const hitsProtected = pairs.some(pair => PROTECTED_BRANCHES.includes(pair.dst))
219 // `git switch main && git push`: the branch checked out now is not the one pushed.
220 const isUnsure = hit.switchedBefore === true && specs.length === 0
221 if (!isForce && !withTags && !isDelete && !hitsProtected && !isTagSpec && !isUnsure) {
222 return { skip: true }
223 }
224
225 return {}
226}
227
228async function measureGithub($: EngineInterface, hit: Outward, cwd: string): Promise<Report> {
229 let repo = optionValue(hit.args, '-R', '--repo')
230 if (repo === undefined) {
231 const shown = await run($, ['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], cwd, 15_000)
232 repo = shown.exitCode === 0 ? shown.stdout.trim() : undefined
233 }
234 const owner = repo?.split('/')[0]?.toLowerCase()
235 if (owner !== undefined && OWN_GITHUB_OWNERS.includes(owner)) return { skip: true }
236 return {}
237}
238
239// ---- Helpers that reach the host -------------------------------------------------
240
241async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 10_000): Promise<Ran> {
242 try {
243 return await $.process.run(argv, { cwd, timeoutMs })
244 } catch (error) {
245 return { exitCode: -1, stdout: '', stderr: String(error) }
246 }
247}
248
249// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
250// The target is passed as an argument, never as source.
251const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`
252
253async function resolveCwd($: EngineInterface, dir: string | null): Promise<string | null> {
254 const sessionCwd = await $.session.cwd()
255 if (dir === null) return sessionCwd
256 const ran = await run($, ['bash', '-c', CD_SCRIPT, 'freigabe', dir], sessionCwd, 5000)
257 const out = ran.stdout.trim()
258 return ran.exitCode === 0 && out !== '' ? out : null
259}
260
261async function remember($: EngineInterface, entry: Omit<LogEntry, 'at'>): Promise<void> {
262 const at = await $.clock.now()
263 const log = ((await $.store.get('log')) as LogEntry[] | undefined) ?? []
264 await $.store.set('log', [...log, { ...entry, at }].slice(-100))
265}
266
267async function dismissDrafts($: EngineInterface): Promise<void> {
268 const list = await read($, drafts)
269 await update($, drafts, () => [])
270 await $.store.set('drafts', [])
271 await remember($, { kind: 'Mail-Entwurf', label: list.map(d => d.label).join(', '), outcome: 'als erledigt markiert' })
272}
273
274async function noteSecrets($: EngineInterface, tool: string, hits: SecretHit[]): Promise<void> {
275 const names = [...new Set(hits.map(hit => hit.name))].join(', ')
276 $.ui.toast(`Secret in der Ausgabe von ${tool}: ${names}. Geschwärzt, trotzdem rotieren (SEC-008).`, { timeoutMs: 15_000 })
277 for (const hit of hits) {
278 await remember($, { kind: 'Secret im Output', label: `${hit.name} sha256:${hit.fp} (${tool})`, outcome: 'geschwärzt, rotieren' })
279 }
280}
281
282function secretNote(hits: SecretHit[]): string {
283 const names = [...new Set(hits.map(hit => hit.name))].join(', ')
284 return `[freigabe] Die Ausgabe enthielt ${hits.length} Secret(s) (${names}). Sie sind für dich und im Transkript durch Platzhalter ersetzt. Der Wert wurde trotzdem ausgegeben: den Operator auf Rotation hinweisen (SEC-008), den Wert nicht erneut ausgeben und keine Edits mit dem Platzhalter bauen.`
285}
286
287// ---- Drawing -----------------------------------------------------------------------
288
289function oneLine(command: string): string {
290 return command.replace(/\s+/g, ' ').trim().slice(0, 300)
291}
292
293function clock(ms: number): string {
294 const d = new Date(ms)
295 const pad = (n: number) => String(n).padStart(2, '0')
296 return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}. ${pad(d.getHours())}:${pad(d.getMinutes())}`
297}
298
299function drawDrafts(t: Table, stale: readonly FreigabeDraft[], now: number, onDone: () => void) {
300 const { Box, Text, Button } = t
301 const oldest = stale.reduce((a, b) => (a.at <= b.at ? a : b))
302 const minutes = Math.round((now - oldest.at) / 60_000)
303 const what = stale.length === 1 ? 'Ein Mail-Entwurf' : `${stale.length} Mail-Entwürfe`
304 return (
305 <Box flexDirection="column">
306 <Box gap={2}>
307 <Text color="yellow" wrap="truncate-end">
308 ✉ {what} offen, ältester seit {minutes} min: {oldest.label}
309 </Text>
310 <Button key="drafts-done" label="Erledigt" hotkey="e" plain onPress={onDone} />
311 </Box>
312 <Text dimColor wrap="truncate-end">
313 Ein offenes Verfassen-Fenster ist einen Tastendruck vom Versand entfernt (G2): schließen oder selbst versenden.
314 </Text>
315 </Box>
316 )
317}
318hooks/rules.ts 439 lines1// The pure half of the freigabe mod: reading a shell command and a tool's
2// output. Nothing here touches `$`, so every rule is testable on its own.
3
4// ---- Reading a shell command ------------------------------------------------
5
6/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
7export function tokenize(text: string): string[] {
8 const words: string[] = []
9 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
10 let m: RegExpExecArray | null
11 while ((m = re.exec(text)) !== null) {
12 words.push(m[1] ?? m[2] ?? m[3] ?? '')
13 }
14 return words
15}
16
17// Words that can come before the real command without changing what it does.
18const PREFIXES = new Set(['command', 'exec', 'nohup', 'time', 'then', 'do', 'else', '!', 'sudo'])
19
20/** The words of one segment, leading `VAR=x`, `env -u X` and the prefixes above removed. */
21export function commandWords(segment: string): string[] {
22 const words = tokenize(segment.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
23 for (;;) {
24 const word = words[0]
25 if (word === undefined) break
26 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || PREFIXES.has(word)) {
27 words.shift()
28 continue
29 }
30 if (word === 'env') {
31 words.shift()
32 while (words[0]?.startsWith('-')) {
33 const option = words.shift()
34 if (option === '-u') words.shift()
35 }
36 continue
37 }
38 break
39 }
40 // npx vercel, pnpm dlx vercel, pnpm exec changeset: the tool is the next word.
41 if (words[0] === 'npx' || words[0] === 'bunx') words.shift()
42 if (words[0] === 'pnpm' && (words[1] === 'dlx' || words[1] === 'exec')) words.splice(0, 2)
43 return words
44}
45
46/** The command line cut at `&&`, `||`, `;`, `|` and newlines. Quotes are not honoured: detection only. */
47export function splitSegments(command: string): string[] {
48 return command.split(/&&|\|\||;|\||\n/)
49}
50
51/** The pipelines of a command line, each a list of its stages. */
52export function splitPipelines(command: string): string[][] {
53 return command.split(/&&|\|\||;|\n/).map(pipeline => pipeline.split('|'))
54}
55
56function baseName(word: string): string {
57 return word.replace(/^\\/, '').split('/').pop() ?? word
58}
59
60/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
61function joinDir(dir: string | null, arg: string | undefined): string {
62 if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
63 return arg ?? '~'
64 }
65 return dir ? `${dir}/${arg}` : arg
66}
67
68/** The positional arguments, skipping each option and, for the options in `valued`, its value. */
69export function positionals(args: readonly string[], valued: ReadonlySet<string>): string[] {
70 const out: string[] = []
71 for (let i = 0; i < args.length; i += 1) {
72 const arg = args[i] ?? ''
73 if (arg === '--') {
74 out.push(...args.slice(i + 1))
75 break
76 }
77 if (arg.startsWith('-') && arg !== '-') {
78 if (valued.has(arg)) i += 1
79 continue
80 }
81 out.push(arg)
82 }
83 return out
84}
85
86/** The value of `--name value`, `--name=value` or `-n value`, or undefined. */
87export function optionValue(args: readonly string[], ...names: string[]): string | undefined {
88 for (let i = 0; i < args.length; i += 1) {
89 const arg = args[i] ?? ''
90 for (const name of names) {
91 if (arg === name) return args[i + 1]
92 if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
93 }
94 }
95 return undefined
96}
97
98// ---- Actions with an effect outside this machine -----------------------------
99
100export type OutwardKind =
101 | 'mail-send'
102 | 'merge'
103 | 'push'
104 | 'publish'
105 | 'deploy'
106 | 'release'
107 | 'external-gh'
108 | 'mcp'
109
110export const KIND_LABEL: Record<OutwardKind, string> = {
111 'mail-send': 'Mail versenden',
112 merge: 'Merge',
113 push: 'Push',
114 publish: 'Paket veröffentlichen',
115 deploy: 'Produktiv-Deploy',
116 release: 'Release',
117 'external-gh': 'Beitrag in fremdem GitHub-Repo',
118 mcp: 'MCP-Aktion mit Außenwirkung',
119}
120
121export type Outward = {
122 kind: OutwardKind
123 /** The detected action. */
124 label: string
125 /** The command's own words after the subcommand. */
126 args: string[]
127 /** Where a `cd` earlier on the line moved to; null means the session folder. */
128 dir: string | null
129 /** For merges and releases: which forge CLI. */
130 forge?: 'glab' | 'gh'
131 /** For deploys: which tool. */
132 tool?: string
133 /** For pushes: an earlier segment switched branches, so the branch now checked out is not the one pushed. */
134 switchedBefore?: boolean
135}
136
137/** The first segment of a shell command that acts outside this machine, or null. */
138/** The first action with an effect outside, or null (tests and labels). */
139export function classifyOutward(command: string): Outward | null {
140 return classifyAllOutward(command)[0] ?? null
141}
142
143/**
144 * Every action with an effect outside, one per segment: `git push origin feat && git push origin main`
145 * holds two, and a skippable first one must not let the second run (review R1, gate-host).
146 */
147export function classifyAllOutward(command: string): Outward[] {
148 const hits: Outward[] = []
149 let dir: string | null = null
150 let switched = false
151 for (const segment of splitSegments(command)) {
152 const words = commandWords(segment)
153 const [first, ...args] = words
154 if (first === undefined) continue
155 const cmd = baseName(first)
156 if (cmd === 'cd' || cmd === 'pushd') {
157 dir = joinDir(dir, args[0])
158 continue
159 }
160 const hit = classifyWords(cmd, args, dir)
161 if (hit !== null) hits.push(hit.kind === 'push' && switched ? { ...hit, switchedBefore: true } : hit)
162 if (cmd === 'git' && args.some(a => a === 'switch' || a === 'checkout')) switched = true
163 }
164 return hits
165}
166
167function classifyWords(cmd: string, args: string[], dir: string | null): Outward | null {
168 const [sub, action] = args
169 if (cmd === 'apple-mail' && sub === 'send') {
170 return { kind: 'mail-send', label: 'apple-mail send', args: args.slice(1), dir }
171 }
172 if (cmd === 'git') {
173 let gitDir = dir
174 let i = 0
175 while (i < args.length && (args[i] ?? '').startsWith('-')) {
176 if (args[i] === '-C' && i + 1 < args.length) {
177 gitDir = joinDir(gitDir, args[i + 1])
178 i += 2
179 } else if (args[i] === '-c' && i + 1 < args.length) {
180 i += 2
181 } else {
182 i += 1
183 }
184 }
185 if (args[i] === 'push') {
186 const rest = args.slice(i + 1)
187 if (rest.includes('--dry-run') || rest.includes('-n')) return null
188 return { kind: 'push', label: 'git push', args: rest, dir: gitDir }
189 }
190 return null
191 }
192 // Wrapper wie glab-<name> sprechen dieselbe API wie glab; ohne Host im Mod (Grenze bleibt CLI-Tor).
193 if (cmd === 'glab' || cmd === 'gh' || /^glab-[a-z0-9-]+$/.test(cmd)) {
194 const forge = cmd === 'gh' ? 'gh' : 'glab'
195 if ((sub === 'mr' || sub === 'pr') && action === 'merge') {
196 return { kind: 'merge', label: `${cmd} ${sub} merge`, args: args.slice(2), dir, forge }
197 }
198 if (sub === 'release' && action === 'create') {
199 return { kind: 'release', label: `${cmd} release create`, args: args.slice(2), dir, forge }
200 }
201 if (
202 cmd === 'gh' &&
203 (sub === 'pr' || sub === 'issue') &&
204 ['create', 'comment', 'review', 'edit', 'close', 'reopen'].includes(action ?? '')
205 ) {
206 return { kind: 'external-gh', label: `gh ${sub} ${action}`, args: args.slice(2), dir, forge }
207 }
208 return null
209 }
210 const joined = [cmd, ...args].join(' ')
211 if (args.includes('--dry-run')) return null
212 if (
213 /^(npm|pnpm|yarn) (-r |--recursive )?publish\b/.test(joined) ||
214 /^yarn npm publish\b/.test(joined) ||
215 /^(changeset|pnpm changeset) publish\b/.test(joined) ||
216 /^cargo publish\b/.test(joined)
217 ) {
218 return { kind: 'publish', label: joined.split(' ').slice(0, 3).join(' '), args, dir, tool: cmd }
219 }
220 if (cmd === 'vercel' && (args.includes('--prod') || args.includes('--production') || sub === 'promote' || sub === 'rollback')) {
221 return { kind: 'deploy', label: `vercel ${args.filter(a => !a.startsWith('--token')).join(' ')}`.trim(), args, dir, tool: 'vercel' }
222 }
223 if (cmd === 'supabase' && ((sub === 'db' && (action === 'push' || (action === 'reset' && args.includes('--linked')))) || (sub === 'migration' && action === 'up' && args.includes('--linked')))) {
224 return { kind: 'deploy', label: `supabase ${sub} ${action}`, args, dir, tool: 'supabase' }
225 }
226 if ((cmd === 'fly' || cmd === 'flyctl') && sub === 'deploy') {
227 return { kind: 'deploy', label: 'fly deploy', args, dir, tool: 'fly' }
228 }
229 if (cmd === 'railway' && sub === 'up') {
230 return { kind: 'deploy', label: 'railway up', args, dir, tool: 'railway' }
231 }
232 return null
233}
234
235// Reads never wait, even when their name carries a write verb (`get_purchase_quote`).
236const MCP_READS = /^(API-)?(get|list|search|query|retrieve|read|count|aggregate|view|find|filter)[-_]/i
237const MCP_WRITES = /(^|[-_])(buy|purchase|delete|trash|share|send|publish|promote|rollback|transfer|pause|cancel|revoke|respond)([-_]|$)|^(create_deployment|calendar_add|add_project_domain|update_firewall_config|put_firewall_config|create_project_env|edit_project_env|update_shared_env_variable|issue_cert|upload_cert|join_team|accept_project_transfer_request)$/i
238
239/** Whether an MCP tool's name says it buys, deletes, shares, sends, publishes or deploys. */
240export function isOutwardMcp(tool: string): boolean {
241 if (!tool.startsWith('mcp__')) return false
242 const name = tool.split('__').pop() ?? ''
243 return !MCP_READS.test(name) && MCP_WRITES.test(name)
244}
245
246// ---- Traps the rules name ---------------------------------------------------
247
248export type Trap = { id: string; title: string; why: string; fix: string }
249
250const SECRET_VAR = /\$\{?([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|_KEY|_PAT))\b/
251const ENV_FILE = /(^|\/)\.env(\.(?!example\b|sample\b|template\b|schema\b)[\w.-]+)?$/
252const KEYS_ONLY = /cut\s+-d\s*['"]?=['"]?\s+-f\s*1|sed\s+['"]?s\/=\.\*\/\/|awk\s+-F\s*['"]?=['"]?\s+['"]?\{\s*print\s+\$1/
253
254/** The first trap the rules of this estate name in a shell command, or null. */
255export function findTrap(command: string): Trap | null {
256 for (const segment of splitSegments(command)) {
257 const words = commandWords(segment)
258 const [first, ...args] = words
259 if (first === undefined) continue
260 const cmd = baseName(first)
261
262 if (
263 cmd === 'claude' &&
264 args.some(a => a === '-p' || a === '--print') &&
265 !/env\s+(-\S+\s+)*-u\s+ANTHROPIC_API_KEY\b|unset\s+ANTHROPIC_API_KEY\b/.test(command)
266 ) {
267 return {
268 id: 'claude-p-oauth',
269 title: 'claude -p ohne env -u ANTHROPIC_API_KEY',
270 why: 'claude -p erbt sonst den ANTHROPIC_API_KEY aus älteren Shells und rechnet über die bezahlte API ab statt über OAuth (Memory "claude -p nur über OAuth").',
271 fix: `env -u ANTHROPIC_API_KEY ${segment.trim()}`,
272 }
273 }
274
275 if (['cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'strings'].includes(cmd) && args.some(a => ENV_FILE.test(a))) {
276 const file = args.find(a => ENV_FILE.test(a)) ?? '.env'
277 return {
278 id: 'sec008-env-datei',
279 title: `${cmd} ${file}`,
280 why: 'Gibt Secret-Werte in Transkript und Log aus (SEC-008: never emit, do not mask).',
281 fix: `sed -n 's/^\\([A-Z_][A-Z0-9_]*\\)=.*/\\1/p' ${file} # nur die Schlüssel`,
282 }
283 }
284
285 if (cmd === 'docker' && (args[0] === 'inspect' || (args[0] === 'container' && args[1] === 'inspect'))) {
286 const formatted = args.some(a => a === '-f' || a === '--format' || a.startsWith('--format=') || /^-f./.test(a))
287 if (!formatted) {
288 return {
289 id: 'sec008-docker-inspect',
290 title: 'docker inspect ohne --format',
291 why: 'docker inspect druckt Config.Env im Klartext (SEC-008).',
292 fix: "docker exec <container> printenv | cut -d= -f1 | sort # oder: docker inspect -f '{{.State.Status}}' <container>",
293 }
294 }
295 }
296
297 if (cmd === 'git' && args[0] === 'add' && args.slice(1).some(a => a === '.' || a === '-A' || a === '--all' || a === ':/')) {
298 return {
299 id: 'commit-discipline-add',
300 title: 'git add . / -A / --all',
301 why: 'Pauschales Stagen nimmt die Arbeit paralleler Sessions mit (commit-discipline, PSA-004).',
302 fix: 'git add <datei> <datei> … # einzeln nach Namen, dann git diff --cached prüfen',
303 }
304 }
305
306 if (cmd === 'curl') {
307 if (/-H\s*["']?Authorization:\s*(Bearer|token)\s+\$|--header\s*["']?Authorization:\s*(Bearer|token)\s+\$/i.test(segment)) {
308 return {
309 id: 'sec008-secret-argv',
310 title: 'Token in curl-Argumenten',
311 why: 'Argumente sind für jeden lokalen User in ps lesbar und landen in Shell-History und Logs (SEC-008).',
312 fix: `printf 'header = "Authorization: Bearer %s"' "$TOKEN" | curl --config - <url>`,
313 }
314 }
315 if (/[?&](access_token|private_token|api_key|apikey|token|key)=\$/i.test(segment)) {
316 return {
317 id: 'sec008-token-in-url',
318 title: 'Token in der URL',
319 why: 'Query-Strings landen in Server-, Proxy- und Referer-Logs (SEC-008).',
320 fix: 'Token als Header über curl --config - übergeben, nie in der URL.',
321 }
322 }
323 }
324 }
325
326 if (/\$\{?PIPESTATUS\[/.test(command)) {
327 return {
328 id: 'zsh-pipestatus',
329 title: '${PIPESTATUS[0]} in zsh',
330 why: 'Die Shell hier ist zsh: ${PIPESTATUS[0]} ist leer, und ein leeres EXIT= liest sich wie Erfolg (bash-harness-pitfalls §6).',
331 fix: 'cmd > /tmp/out.log 2>&1; rc=$?; tail -5 /tmp/out.log; echo "EXIT=$rc"',
332 }
333 }
334
335 if (/\bgrep\s+(?:-[A-Za-z]*c[A-Za-z]*|--count)\b[^\n]*?\|\|\s*echo\s+["']?0["']?/.test(command)) {
336 return {
337 id: 'grep-c-echo-0',
338 title: 'grep -c … || echo 0',
339 why: 'grep -c druckt bei keinem Treffer selbst 0 und endet mit 1: das Ergebnis ist "0\\n0" und bricht jeden Zahlenvergleich (bash-harness-pitfalls §1).',
340 fix: 'count=$(grep -c "MUSTER" datei || true)',
341 }
342 }
343
344 for (const stages of splitPipelines(command)) {
345 // tokenize, not commandWords: a bare `env` is the command here, not a prefix.
346 const head = tokenize((stages[0] ?? '').trim())
347 const rest = stages.slice(1).join('|')
348 const headCmd = baseName(head[0] ?? '')
349 const dumpsEnv =
350 ((headCmd === 'env' || headCmd === 'printenv') && head.length === 1) ||
351 (headCmd === 'export' && head[1] === '-p' && head.length === 2) ||
352 (headCmd === 'printenv' && head.length === 2 && SECRET_VAR.test(`$${head[1] ?? ''}`))
353 if (dumpsEnv && !KEYS_ONLY.test(rest)) {
354 return {
355 id: 'sec008-env-dump',
356 title: `${head.join(' ')} ohne Filter`,
357 why: 'Ein Umgebungs-Dump druckt jeden Token-Wert ins Transkript (SEC-008).',
358 fix: 'env | cut -d= -f1 | sort # Schlüssel; Einzelwert: test -n "${VAR:-}" && echo "VAR: set"',
359 }
360 }
361 const last = stages[stages.length - 1] ?? ''
362 const lastCmd = baseName(commandWords(last)[0] ?? '')
363 if ((lastCmd === 'echo' || lastCmd === 'printf') && SECRET_VAR.test(last)) {
364 const name = last.match(SECRET_VAR)?.[1] ?? 'VAR'
365 return {
366 id: 'sec008-echo-secret',
367 title: `echo $${name}`,
368 why: 'Druckt den Wert ins Transkript (SEC-008). Prüfen geht ohne Ausgabe.',
369 fix: `test -n "\${${name}:-}" && echo "${name}: set"; echo "\${#${name}}" # gesetzt? Länge?`,
370 }
371 }
372 }
373 return null
374}
375
376// ---- Secrets in output ------------------------------------------------------
377
378export type SecretHit = { name: string; fp: string }
379
380export const SECRET_PATTERNS: readonly { name: string; re: RegExp }[] = [
381 { name: 'Private Key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
382 { name: 'GitLab-Token', re: /\bgl(?:pat|dt|rt|ptt|cbt|soat|ffct|imt|oas|agent)-[A-Za-z0-9_-]{20,}/g },
383 { name: 'GitHub-Token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g },
384 { name: 'Anthropic-Key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
385 { name: 'OpenAI-Key', re: /\bsk-(?:proj-|svcacct-)?(?!ant-)[A-Za-z0-9_-]{32,}/g },
386 { name: 'Stripe-Key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g },
387 { name: 'Stripe-Webhook-Secret', re: /\bwhsec_[A-Za-z0-9]{24,}/g },
388 { name: 'AWS-Access-Key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
389 { name: 'Slack-Token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
390 { name: 'Google-API-Key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
391]
392
393export function hasSecret(text: string): boolean {
394 return SECRET_PATTERNS.some(({ re }) => (text.match(re)?.length ?? 0) > 0)
395}
396
397async function fingerprint(secret: string): Promise<string> {
398 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
399 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12)
400}
401
402/** The text with every secret replaced by its kind and a sha256 fingerprint. */
403export async function redactText(text: string, hits: SecretHit[]): Promise<string> {
404 let out = text
405 for (const { name, re } of SECRET_PATTERNS) {
406 for (const secret of new Set(out.match(re) ?? [])) {
407 const fp = await fingerprint(secret)
408 if (!hits.some(hit => hit.fp === fp)) hits.push({ name, fp })
409 out = out.split(secret).join(`[${name} entfernt · sha256:${fp}]`)
410 }
411 }
412 return out
413}
414
415/** A copy of any JSON-like value with every string redacted. */
416export async function redactDeep(value: unknown, hits: SecretHit[]): Promise<unknown> {
417 if (typeof value === 'string') {
418 return hasSecret(value) ? redactText(value, hits) : value
419 }
420 if (Array.isArray(value)) {
421 const out: unknown[] = []
422 for (const item of value) out.push(await redactDeep(item, hits))
423 return out
424 }
425 if (value !== null && typeof value === 'object') {
426 const out: Record<string, unknown> = {}
427 for (const [key, item] of Object.entries(value)) out[key] = await redactDeep(item, hits)
428 return out
429 }
430 return value
431}
432
433// ---- Mail drafts ------------------------------------------------------------
434
435/** The draft id that `apple-mail draft|reply` or the MCP draft tools printed, or undefined. */
436export function draftIdFrom(text: string): string | undefined {
437 return text.match(/draft id:\s*(\d+)/i)?.[1] ?? text.match(/"draftId"\s*:\s*"?(\d+)/)?.[1]
438}
439types/index.d.ts 9 lines1/** A mail draft an agent opened in Mail.app, as long as nobody sent or dismissed it. */
2export type FreigabeDraft = { id: string; at: number; label: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 freigabe: { drafts: FreigabeDraft[] }
7 }
8}
9