Routes supported project edits into a Git worktree for review, accept or reject. Worktree routing is not a filesystem sandbox.

Airlock is a Claude Code mod built with function hooks. It routes supported project edits into a Git worktree so you can review each coding turn, then Accept its patch or Reject it. It uses Claude Code's plugin system for installation and distribution. This is worktree routing, not a filesystem or command sandbox.
PATH.HEAD commit. Airlock does not initialize repositories or create commits in the real project.bash, cp, mkdir, and rm.Install the mod and create its temporary-worktree directory once:
claude plugin marketplace add BillyMRX1/airlock
claude plugin install airlock@airlock-marketplace
mkdir -p "$HOME/.claude-airlock"
The repository is private for now, so your GitHub account must have access and Git must be authenticated for GitHub. Claude manages the download and plugin cache; you do not need a manual clone or pull.
Next, save access to that directory once so you don't need a launch flag every time. In ~/.claude/settings.json, add ~/.claude-airlock to permissions.additionalDirectories:
{
"permissions": {
"additionalDirectories": ["~/.claude-airlock"]
}
}
If the file already has settings, merge this entry into it; keep the existing settings and directory entries. If the file doesn't exist, create it with the example above. This lets Claude's file tools access Airlock's worktree copies. See Claude's working-directory permissions.
That's the setup. You don't need to repeat the install, mkdir, or permission configuration for each session.
Open a terminal in your Git project and start Claude as usual:
claude
The installed mod loads automatically. Restart an existing Claude session after installing, and use /airlock-status to check that the command is available. Ask for a code change, inspect the review, and choose Accept or Reject. If the pane is hidden, run /airlock-review.
If you prefer not to save the directory permission, use claude --add-dir "$HOME/.claude-airlock" for each launch instead.
claude plugin marketplace update airlock-marketplace
claude plugin update airlock@airlock-marketplace
Restart Claude after updating.
For development or manual installation, start Claude Code in your Git project and pass the absolute path to the Airlock repository directory. With the one-time directory permission saved above:
claude --plugin-dir /absolute/path/to/airlock
Use the real absolute path for your checkout. If you skipped the saved directory permission, add --add-dir "$HOME/.claude-airlock" to this launch. Without either form of access, routed file tools may prompt or be denied. Avoid loading a source copy alongside an already installed Airlock copy.
The current project must already be a Git repository with a HEAD. The first coding turn creates a transaction automatically. Workspaces live beneath ~/.claude-airlock/; transaction records are kept in Claude Code’s plugin-private persistent store.
From the plugin directory, python3 scripts/package.py creates a local ZIP under dist/. The private 0.1.0 preview release also includes this archive. Extract it and pass the resulting airlock directory directly to --plugin-dir (in place of the checkout path), with the same directory permission as above. Tests and integration hosts stay in the source checkout; generated type files and the private handoff documents are excluded from the archive.
/airlock-review to open it, or /airlock-diff for text output; /airlock-status shows the transaction state and worktree./airlock-accept to apply the reviewed project patch, or /airlock-reject to discard the transaction. The pane also offers Accept, Reject, and Review actions.Accept checks for conflicting edits and whether the patch still applies, then applies it to the real worktree without changing the Git index. Reject does not apply the patch. Effects from commands outside the routed project files are not undone by either action.
Claude Code keeps automatically opened panes hidden below 144 terminal columns (110 after you have explicitly opened that pane, until you close it by hand). /airlock-review explicitly requests the pane and can show it inline in a narrower terminal. While the pane has keyboard focus, Tab moves between controls and Enter presses the selected button; Esc returns to the prompt. Headless sessions use /airlock-diff, /airlock-accept, and /airlock-reject.
Use an unused, non-ignored filename such as airlock-demo.txt in an existing repository, with the default includeUntracked: true. In the Claude session, ask:
Create airlock-demo.txt containing "ready for review" using Write. Do not run commands or change other files.
When the turn ends, /airlock-status should show REVIEW and /airlock-diff should show the proposed new file. In another terminal at the real repository, git status --short -- airlock-demo.txt should still show no file. Run /airlock-accept: the file now exists in the real tree as an untracked file; Airlock has not staged or committed it.
Next ask Claude:
Read airlock-demo.txt and append "discard this line" using Edit. Do not run commands or change other files.
Review the proposed line, then run /airlock-reject. The real file should still contain only "ready for review". Keep or remove the demo file yourself when finished. These observations check this small structured-tool workflow; they do not prove that arbitrary commands are isolated.
| Command | Purpose |
|---|---|
/airlock-status | Show the open transaction, its state, paths, change counts, and routed activity. |
/airlock-diff | Show the current transaction diff (output is capped at 8,000 characters). |
/airlock-review | Open and focus the review pane. Finish ACTIVE work with /airlock-begin end first; headless sessions use /airlock-diff. |
/airlock-accept | Apply a transaction in review after conflict and apply checks. |
/airlock-reject | Discard the open transaction; it does not apply a patch. |
/airlock-abort | Discard an open transaction before review, or clear a pending multi-turn setting. |
/airlock-cleanup | Report stale Airlock workspaces; /airlock-cleanup purge removes eligible stale workspaces. |
/airlock-history | List recent finished transactions for the current repository. |
/airlock-mode | Show the repository's mode; add strict, balanced, or permissive to set it. |
/airlock-begin | Keep the next coding transaction open across multiple turns. /airlock-begin end stages it for review. |
/airlock-rejected | List retained rejected workspaces; add a transaction ID to inspect its saved diff. |
Run /airlock-begin before starting a coding turn to keep its transaction open for follow-up turns. When the work is ready, run /airlock-begin end to prepare it for review, then use /airlock-diff and /airlock-accept or /airlock-reject. Resolving the transaction clears multi-turn mode, so later coding turns use the default one-turn lifecycle.
Plugin configuration defaults are:
| Setting | Default | Meaning |
|---|---|---|
mode | strict | Denies classified external side effects and Git topology commands. balanced asks for one-time approval for a matched external side effect in an interactive session; permissive allows and records classified actions. |
includeUntracked | true | Copies eligible, non-ignored untracked regular files into the transaction baseline. Ignored untracked files are excluded; symlinks, directories, and files over 4 MiB are skipped. |
retainRejectedTransactions | false | When true, keeps rejected or aborted worktrees for later inspection instead of removing them. |
maxStoredTransactions | 50 | Maximum finished transaction records stored per repository. |
/airlock-mode sets a mode override for the current repository. Balanced mode requires an interactive exact “Allow once” response for recognized external side effects; headless sessions deny those actions. The mode classifier is heuristic and incomplete; the setting does not make Bash safe or contained.
Airlock routes supported structured file tools such as Read, Edit, Write, and NotebookEdit when their paths are inside the current repository. Paths outside the repository pass through. Search tools are not virtualized and can read the real tree, which may differ from the transaction copy. MCP tools are outside Airlock's routing and side-effect controls.
Bash is executed by the plugin in the worktree, but it is not sandboxed and bypasses Claude Code's Bash permission gate. The command classifier is incomplete: unmatched commands pass in every mode, including strict. Absolute paths, cd, scripts, aliases, and network or database operations can affect resources outside the transaction. Rejecting or aborting does not undo those effects. Background Bash is denied during transactions.
Per-repository session checks are not atomic locks. Concurrent sessions can race, and real-path checks cannot eliminate time-of-check/time-of-use races such as a path changing between validation and access. Airlock does not run the project's test suite automatically; review-pane checks are preflight checks, not completed project tests.
If accept fails after applying has begun, Airlock attempts to restore backups and retains the workspace and backup when recovery is needed. A transaction marked APPLY_FAILED refuses another accept and requires manual recovery, including when termination happened before the actual apply. Inspect the reported paths and backup under ~/.claude-airlock/, compare them with the repository, and recover manually before removing the retained files. A process termination during actual partial Git writes has not been verified end to end.
Run these from the plugin directory (the published repository root, or mod/ in the original development workspace):
claude plugin validate --strict .
claude plugin test .
NODE_PATH="$PWD/integration" bun run integration/real-git.ts
NODE_PATH="$PWD/integration" bun run integration/crash-recovery.ts
The integration hosts additionally need Bun and the existing demo seed at ../spike-sandbox/demo-project relative to the plugin directory. This development seed is excluded from the published repository and archive; the hosts cannot run without it. Bun and Python are development/packaging tools, not plugin runtime dependencies.
The plugin passes strict static validation and 137 engine tests across fourteen files. Separate no-model hosts exercise production handlers against real Git, including crash recovery at a pre-apply boundary. Owner testing on macOS/Warp verified a live Write/accept and Read/Edit/reject workflow, native Review/Accept/ Reject actions, explicit /airlock-review opening, readable completion summaries, and balanced-mode Deny and Allow once with curl -X POST --help (local help only). This is a small manual workflow, not complete coverage of concurrency, command isolation, or every native control. The native review-command retest did not record a terminal column count; placement below 144 columns has not been independently established by the screenshots.
Read SECURITY.md for the implemented boundaries and recovery procedure. An APPLY_FAILED workspace and its sibling .backup directory should be inspected and preserved before reject, abort, or cleanup.
Airlock is licensed under the MIT License.
hooks/register.ts 48 lines1// Wiring only: register(on, options) connects the engine events to the
2// hook functions. All on(event, matcher, hook) registrations live here.
3
4import type { Register } from 'claude-code'
5import { applyOptions } from './tx/state.ts'
6import { onSessionStart } from './tx/recovery.ts'
7import { onTurnStart, onTurnComplete, onTurnStartError } from './tx/lifecycle.ts'
8import {
9 onTxStatus, onTxDiff, onTxAccept, onTxReject, onTxAbort, onTxCleanup, onTxHistory,
10 onTxMode, onTxBegin, onTxRejected, onTxReview, onReviewRender,
11} from './tx/commands.tsx'
12import { onReadCall, onEditCall, onWriteCall, onNotebookEditCall } from './tools/rewrite.ts'
13import { onBashCall } from './tools/bash.ts'
14import { onAgentCall, onEnterWorktreeCall, onExitWorktreeCall } from './tools/escape.ts'
15
16import { onAirlockPromptSection, REVIEW_PANE } from './ui/review.tsx'
17
18export const register: Register = (on, options) => {
19 applyOptions((options ?? {}) as Record<string, unknown>)
20
21 on('session.start', onSessionStart)
22 on('turn.start', onTurnStart).catch(onTurnStartError)
23 on('turn.complete', onTurnComplete)
24 on('ui.render', { component: 'Pane', requestId: REVIEW_PANE }, onReviewRender)
25 on('prompt.section', onAirlockPromptSection)
26
27 on('tool.call', { tool: 'Read' }, onReadCall)
28 on('tool.call', { tool: 'Edit' }, onEditCall)
29 on('tool.call', { tool: 'Write' }, onWriteCall)
30 on('tool.call', { tool: 'NotebookEdit' }, onNotebookEditCall)
31 on('tool.call', { tool: 'Bash' }, onBashCall)
32 on('tool.call', { tool: 'Agent' }, onAgentCall)
33 on('tool.call', { tool: 'EnterWorktree' }, onEnterWorktreeCall)
34 on('tool.call', { tool: 'ExitWorktree' }, onExitWorktreeCall)
35
36 on('command.run', { command: 'airlock-status' }, onTxStatus)
37 on('command.run', { command: 'airlock-diff' }, onTxDiff)
38 on('command.run', { command: 'airlock-accept' }, onTxAccept)
39 on('command.run', { command: 'airlock-reject' }, onTxReject)
40 on('command.run', { command: 'airlock-abort' }, onTxAbort)
41 on('command.run', { command: 'airlock-cleanup' }, onTxCleanup)
42 on('command.run', { command: 'airlock-history' }, onTxHistory)
43 on('command.run', { command: 'airlock-mode' }, onTxMode)
44 on('command.run', { command: 'airlock-begin' }, onTxBegin)
45 on('command.run', { command: 'airlock-rejected' }, onTxRejected)
46 on('command.run', { command: 'airlock-review' }, onTxReview)
47}
48hooks/tx/state.ts 159 lines1// Transaction record (plan §9) and session context.
2//
3// Module-level context is acceptable for now: a session loads the module
4// once and hot reloads re-run register(), which resets this state; the
5// authoritative cross-session copy lives in $.store (the engine's static
6// analysis requires $ to stay within one file, so every $.store /
7// $.process call lives in the hook file that makes it — this module is
8// pure data and logic only). Migration of live state to $.state remains
9// outstanding; store-backed handlers cover persistent state today.
10
11export type TxMode = 'strict' | 'balanced' | 'permissive'
12
13export type TxState =
14 | 'ACTIVE'
15 | 'REVIEW'
16 | 'ACCEPTED'
17 | 'REJECTED'
18 | 'ABORTED'
19 | 'CONFLICTED'
20 | 'APPLY_FAILED'
21
22export interface SideEffectEvent {
23 at: number
24 command: string
25 pattern: string
26 reason: string
27 action: 'denied' | 'recorded'
28}
29
30export interface TxStats {
31 files: number
32 insertions: number
33 deletions: number
34}
35
36export interface TransactionRecord {
37 transactionId: string
38 sessionId: string
39 turnId: string
40 repoRoot: string
41 txRoot: string
42 startedAt: number
43 baseHead: string
44 /** What the transaction's own diff is measured against (plan §10): the
45 * ephemeral baseline commit inside the worktree when the real tree was
46 * dirty, else baseHead (clean fast path). */
47 baselineCommit: string
48 /** Real-tree blob hashes of the paths the baseline touched, taken at
49 * PREPARING; ACCEPT compares them to detect concurrent human edits
50 * before any patch is applied (plan §11, conflict layer 1). */
51 baselineFingerprint: Record<string, string>
52 /** Non-ignored untracked files copied into the worktree. */
53 untrackedCopied: string[]
54 /** Untracked files deliberately not copied (too large, symlink, dir…). */
55 skippedFiles: string[]
56 state: TxState
57 changedFiles: string[]
58 stats: TxStats
59 sideEffectEvents: SideEffectEvent[]
60 bashCalls: number
61 rewrites: number
62 /** When true, each turn remains in this worktree until /airlock-begin end. */
63 multiTurn?: boolean
64}
65
66export interface HistoryRecord {
67 transactionId: string
68 turnId: string
69 repoRoot: string
70 txRoot: string | null
71 /** Baseline commit used for retained rejected-workspace inspection. */
72 baselineCommit?: string
73 startedAt: number
74 endedAt: number
75 outcome: 'accepted' | 'rejected' | 'aborted'
76 stats: TxStats
77 retained: boolean
78}
79
80export interface Ctx {
81 repoRoot: string
82 isInteractive: boolean
83 multiTurn: boolean
84 tx: TransactionRecord | null
85 isolationFailed: boolean
86 /** Session id of another session that owns the open transaction for this
87 * repo (multi-session guard): mutations are denied here until that
88 * session accepts/aborts. Null when this session may mutate. */
89 blockedByOtherSession: string | null
90 commandsRegistered: boolean
91 options: {
92 mode: TxMode
93 includeUntracked: boolean
94 retainRejectedTransactions: boolean
95 maxStoredTransactions: number
96 }
97}
98
99export const ctx: Ctx = {
100 repoRoot: '',
101 isInteractive: false,
102 multiTurn: false,
103 tx: null,
104 // A fresh/hot-reloaded module must initialize through session.start before mutations.
105 isolationFailed: true,
106 blockedByOtherSession: null,
107 commandsRegistered: false,
108 options: {
109 mode: 'strict',
110 includeUntracked: true,
111 retainRejectedTransactions: false,
112 maxStoredTransactions: 50,
113 },
114}
115
116export function activeKey(repoRoot: string): string { return `airlock:${repoRoot}:active` }
117export function historyKey(repoRoot: string): string { return `airlock:${repoRoot}:history` }
118export function modeKey(repoRoot: string): string { return `airlock:${repoRoot}:mode` }
119export function multiTurnKey(repoRoot: string): string { return `airlock:${repoRoot}:multi-turn` }
120
121// Legacy global keys are read only for a safe, repo-matched migration.
122export const LEGACY_ACTIVE_KEY = 'airlock:active'
123export const LEGACY_HISTORY_KEY = 'airlock:history'
124
125// `options` arrives from the manifest's userConfig with defaults filled in,
126// but a field can still be unset or invalid; read defensively.
127export function applyOptions(options: Record<string, unknown>): void {
128 const mode = options.mode
129 if (mode === 'balanced' || mode === 'permissive' || mode === 'strict') ctx.options.mode = mode
130 if (typeof options.includeUntracked === 'boolean') ctx.options.includeUntracked = options.includeUntracked
131 if (typeof options.retainRejectedTransactions === 'boolean') {
132 ctx.options.retainRejectedTransactions = options.retainRejectedTransactions
133 }
134 const max = Number(options.maxStoredTransactions)
135 if (Number.isFinite(max) && max >= 1) ctx.options.maxStoredTransactions = Math.floor(max)
136}
137
138// Pure: is this store record a recoverable transaction for this repo?
139export function recoverableRecord(saved: unknown, repoRoot: string): TransactionRecord | null {
140 if (!saved || typeof saved !== 'object') return null
141 const r = saved as TransactionRecord
142 if (typeof r.txRoot !== 'string' || r.repoRoot !== repoRoot) return null
143 // Records written before the baseline subsystem lack the new fields;
144 // recover them against HEAD with an empty fingerprint rather than
145 // breaking turn.complete / accept on undefined refs.
146 if (typeof r.baselineCommit !== 'string' || r.baselineCommit === '') r.baselineCommit = r.baseHead
147 if (r.baselineFingerprint === null || typeof r.baselineFingerprint !== 'object') r.baselineFingerprint = {}
148 if (!Array.isArray(r.untrackedCopied)) r.untrackedCopied = []
149 if (!Array.isArray(r.skippedFiles)) r.skippedFiles = []
150 return r
151}
152
153// Pure: history list trimmed to the configured size.
154export function trimHistory(list: unknown, max: number): HistoryRecord[] {
155 const history = Array.isArray(list) ? list.filter((r): r is HistoryRecord => !!r && typeof r === 'object') : []
156 const overflow = history.length - max
157 return overflow > 0 ? history.slice(overflow) : history
158}
159hooks/tx/recovery.ts 98 lines1// Session start: repo detection, command registration, and recovery of
2// a transaction left open by a previous session (crash, killed terminal).
3// Recovery favors preserving data: the worktree and the store record are
4// left exactly as they were; only the in-memory context is rebuilt.
5
6import { ctx, activeKey, historyKey, modeKey, multiTurnKey, LEGACY_ACTIVE_KEY, LEGACY_HISTORY_KEY, recoverableRecord, trimHistory } from './state.ts'
7
8export async function onSessionStart($: any, e: any, next: any): Promise<unknown> {
9 ctx.tx = null
10 ctx.blockedByOtherSession = null
11 ctx.isInteractive = e.isInteractive === true
12 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: e.cwd })
13 ctx.repoRoot = git.exitCode === 0 && !git.isStdoutTruncated ? git.stdout.trim() : ''
14 if (!ctx.repoRoot) {
15 // No repo (or git broken): fail safe — no transactions in this session.
16 ctx.isolationFailed = true
17 } else {
18 ctx.isolationFailed = false
19 const key = activeKey(ctx.repoRoot)
20 let saved = await $.store.get(key)
21 // Migrate only when the legacy value explicitly identifies this repo.
22 if (saved === undefined) {
23 const legacy = await $.store.get(LEGACY_ACTIVE_KEY)
24 const candidate = recoverableRecord(legacy, ctx.repoRoot)
25 if (candidate) { saved = candidate; await $.store.set(key, candidate); await $.store.delete(LEGACY_ACTIVE_KEY) }
26 }
27 const oldHistory = await $.store.get(LEGACY_HISTORY_KEY)
28 if (Array.isArray(oldHistory)) {
29 const mine = oldHistory.filter((r: any) => r && r.repoRoot === ctx.repoRoot)
30 if (mine.length > 0) {
31 const current = await $.store.get(historyKey(ctx.repoRoot))
32 const merged = [...(Array.isArray(current) ? current : []), ...mine]
33 const unique = merged.filter((r: any, i: number) => merged.findIndex((candidate: any) => candidate?.transactionId === r?.transactionId) === i)
34 await $.store.set(historyKey(ctx.repoRoot), trimHistory(unique, ctx.options.maxStoredTransactions))
35 }
36 const other = oldHistory.filter((r: any) => !r || r.repoRoot !== ctx.repoRoot)
37 if (other.length > 0) await $.store.set(LEGACY_HISTORY_KEY, other)
38 else await $.store.delete(LEGACY_HISTORY_KEY)
39 }
40 const savedMode = await $.store.get(modeKey(ctx.repoRoot))
41 if (savedMode === 'strict' || savedMode === 'balanced' || savedMode === 'permissive') ctx.options.mode = savedMode
42 const multiTurn = await $.store.get(multiTurnKey(ctx.repoRoot))
43 ctx.multiTurn = multiTurn === true
44 const recovered = recoverableRecord(saved, ctx.repoRoot)
45 // A record whose workspace no longer exists is a ghost: adopting it
46 // would point every git call at a deleted directory. It stays in the
47 // store (data preservation) for /airlock-cleanup to prune; this session
48 // opens its own transaction normally (the turn.start guard clears it).
49 let adopt = false
50 if (recovered) {
51 try {
52 adopt = await $.fs.exists(recovered.txRoot)
53 } catch {
54 adopt = false
55 }
56 }
57 if (recovered && adopt) {
58 // Cross-session recovery is the feature (plan §21: a crash or killed
59 // terminal leaves the worktree and record behind; a later session
60 // adopts them and can accept or reject). The status names the owner
61 // when the record was left by a different session, so two live
62 // sessions are honest about who is working where.
63 ctx.tx = recovered
64 let mine = false
65 try {
66 const sid = await $.session.id()
67 mine = sid === recovered.sessionId
68 } catch {
69 mine = false
70 }
71 const owner = mine ? '' : ` — left open by session ${recovered.sessionId}`
72 $.ui.status(`AIRLOCK ${recovered.transactionId} • RECOVERED${owner}`)
73 } else {
74 ctx.tx = null
75 }
76 }
77
78 try {
79 await $.command.register({ name: 'airlock-status', description: 'Show the open transaction' })
80 await $.command.register({ name: 'airlock-diff', description: 'Show the transaction diff' })
81 await $.command.register({ name: 'airlock-accept', description: 'Apply the transaction to the real worktree' })
82 await $.command.register({ name: 'airlock-reject', description: 'Discard the transaction without applying it' })
83 await $.command.register({ name: 'airlock-abort', description: 'Abort the in-flight transaction (discard without review)' })
84 await $.command.register({ name: 'airlock-cleanup', description: 'List stale transaction workspaces (add "purge" to remove them)' })
85 await $.command.register({ name: 'airlock-history', description: 'Show recent transaction history for this repository' })
86 await $.command.register({ name: 'airlock-mode', description: 'Show or set this repository’s safety mode: strict, balanced, or permissive' })
87 await $.command.register({ name: 'airlock-begin', description: 'Opt into a multi-turn transaction; use “end” to open it for review' })
88 await $.command.register({ name: 'airlock-rejected', description: 'Show retained rejected transaction workspaces' })
89 await $.command.register({ name: 'airlock-review', description: 'Open the interactive transaction review pane' })
90 ctx.commandsRegistered = true
91 } catch {
92 // Command registration failing (e.g. an unusual host) must not break
93 // the rest of the session; the /airlock-* hooks still answer if dispatched.
94 ctx.commandsRegistered = false
95 }
96 return next(e)
97}
98hooks/tx/lifecycle.ts 360 lines1// Turn lifecycle (plan §8, §10): turn.start opens the transaction (one
2// typed main-loop prompt → one transaction) and builds the baseline —
3// the user's tracked-dirty changes and non-ignored untracked files are
4// reproduced inside the worktree, then committed there as an ephemeral
5// baseline so Claude's diff never includes the user's pre-existing work.
6// turn.complete gathers stats against that baseline and moves to REVIEW.
7// All engine calls are made here, in full ($.noun.event(...)) — the
8// validator follows $ only within one file.
9
10import { ctx, activeKey, recoverableRecord, multiTurnKey, LEGACY_ACTIVE_KEY } from './state.ts'
11import type { TransactionRecord } from './state.ts'
12import { txidFor, txRootFor } from './workspace.ts'
13import { parseNumstat, parseNameOnly, parentDirOf } from './patch.ts'
14
15// $.fs.read caps at 4 MiB; an untracked file above that is skipped and
16// recorded rather than half-copied (plan §23: keep startup lightweight).
17const MAX_COPY_BYTES = 4 * 1024 * 1024
18// `git hash-object <path>` cannot hash a missing file. Keep absence distinct
19// from every valid object id so accept can compare a deleted baseline safely.
20const ABSENT_BASELINE = 'airlock:absent'
21
22// Core has already shown the answer. Preserve a distinct completion notice
23// supplied by another hook, but do not replay the answer in our plugin row.
24// Notification rows sanitize controls; keep any forwarded notice on one line.
25function completionNotice(previous: string, answer: string, notice: string): string {
26 if (!previous || previous === answer) return notice
27 return `${previous.replace(/[\x00-\x1f\x7f]/g, ' ')} | ${notice}`
28}
29
30async function home($: any): Promise<string> {
31 const h = await $.env.get('HOME')
32 return typeof h === 'string' && h.length > 0 ? h : '/tmp'
33}
34
35// Real-tree blob hashes for the paths the baseline touches (conflict
36// layer 1, plan §11). One hash-object --stdin-paths batch; per-file
37// fallback when the batch fails or comes back short.
38async function hashPaths($: any, repoRoot: string, paths: string[]): Promise<Record<string, string>> {
39 const out: Record<string, string> = {}
40 if (paths.length === 0) return out
41 const hashOne = async (p: string): Promise<string> => {
42 const one = await $.process.run(['git', 'hash-object', '--', p], { cwd: repoRoot })
43 if (one.exitCode === 0 && !one.isStdoutTruncated && one.stdout.trim() !== '') return one.stdout.trim()
44 let exists: boolean
45 try {
46 exists = await $.fs.exists(`${repoRoot}/${p}`)
47 } catch {
48 throw new Error(`could not inspect baseline path ${p}`)
49 }
50 if (!exists) return ABSENT_BASELINE
51 throw new Error(`could not fingerprint baseline path ${p}`)
52 }
53 // `--stdin-paths` is newline-delimited, so it cannot represent a path
54 // containing a literal newline. Quoting and backslashes are also kept in
55 // argv form to avoid ambiguity in Git's path parsing.
56 if (paths.some(p => /["\\\r\n]/.test(p))) {
57 for (const p of paths) out[p] = await hashOne(p)
58 return out
59 }
60 const batch = await $.process.run(['git', 'hash-object', '--stdin-paths'], {
61 cwd: repoRoot,
62 stdin: `${paths.join('\n')}\n`,
63 })
64 const lines = batch.stdout.split('\n').map(s => s.trim()).filter(s => s !== '')
65 if (batch.exitCode === 0 && !batch.isStdoutTruncated && lines.length === paths.length) {
66 for (let i = 0; i < paths.length; i++) out[paths[i]] = lines[i]
67 return out
68 }
69 for (const p of paths) out[p] = await hashOne(p)
70 return out
71}
72
73// Copy non-ignored untracked files into the worktree. Copied by argv
74// (`cp -p`) rather than $.fs.read+$.fs.write on purpose: $.fs.write is
75// text-only and would corrupt binary files, and the bytes of a bad copy
76// would ride into the patch applied to the real tree on accept. Files
77// that cannot be copied faithfully are skipped and recorded, never fatal.
78async function copyUntracked($: any, repoRoot: string, txRoot: string): Promise<{ copied: string[]; skipped: string[] }> {
79 const ls = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard'], { cwd: repoRoot })
80 if (ls.exitCode !== 0 || ls.isStdoutTruncated) throw new Error(`untracked-file listing failed${ls.isStdoutTruncated ? ' (output truncated)' : ` (exit ${ls.exitCode})`}`)
81 const paths = parseNameOnly(ls.stdout)
82 const copied: string[] = []
83 const skipped: string[] = []
84 for (const rel of paths) {
85 if (rel.startsWith('/') || rel.split('/').includes('..')) {
86 skipped.push(rel) // not a sane repo-relative path; never touch it
87 continue
88 }
89 let st: { kind: string; size: number; isLink: boolean } | null = null
90 try {
91 st = await $.fs.stat(`${repoRoot}/${rel}`)
92 } catch {
93 st = null
94 }
95 // Skip symlinks (they may lead outside the repo, plan §22), dirs and
96 // anything oversized.
97 if (!st || st.kind !== 'file' || st.isLink || st.size > MAX_COPY_BYTES) {
98 skipped.push(rel)
99 continue
100 }
101 const parent = parentDirOf(rel)
102 if (parent !== '') {
103 await $.process.run(['mkdir', '-p', '--', `${txRoot}/${parent}`], { cwd: repoRoot })
104 }
105 const cp = await $.process.run(['cp', '-p', '--', `${repoRoot}/${rel}`, `${txRoot}/${rel}`], { cwd: repoRoot })
106 if (cp.exitCode !== 0) {
107 skipped.push(rel)
108 continue
109 }
110 copied.push(rel)
111 }
112 return { copied, skipped }
113}
114
115// Creates the worktree and its baseline; resolves null when any step
116// failed (the caller then fails safe: block mutations, never edit the
117// real tree). The worktree is removed on failure so nothing is leaked.
118async function createTx($: any, repoRoot: string, turnId: string, sessionId: string): Promise<TransactionRecord | null> {
119 const h = await home($)
120 const txid = txidFor(turnId)
121 const txRoot = txRootFor(h, repoRoot, txid)
122 const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: repoRoot })
123 if (head.exitCode !== 0) return null
124 const add = await $.process.run(['git', 'worktree', 'add', '--detach', txRoot, 'HEAD'], {
125 cwd: repoRoot,
126 timeoutMs: 60000,
127 })
128 if (add.exitCode !== 0) return null
129 try {
130 // 1. The user's tracked changes (staged + unstaged vs HEAD)…
131 const dirty = await $.process.run(['git', 'diff', 'HEAD', '--binary'], { cwd: repoRoot })
132 if (dirty.exitCode !== 0 || dirty.isStdoutTruncated) {
133 throw new Error(`tracked-change diff failed${dirty.isStdoutTruncated ? ' (output truncated)' : ` (exit ${dirty.exitCode})`}`)
134 }
135 const dirtyPatch = dirty.stdout
136 // …reproduced inside the worktree. If they will not apply, there is
137 // no faithful baseline: fail safe, destroy the workspace.
138 if (dirtyPatch.trim() !== '') {
139 const applied = await $.process.run(['git', 'apply', '--binary', '-'], { cwd: txRoot, stdin: dirtyPatch })
140 if (applied.exitCode !== 0) throw new Error(`baseline apply failed: ${applied.stderr.slice(0, 200)}`)
141 }
142 // 2. Non-ignored untracked files, copied in (skips are recorded).
143 const un = ctx.options.includeUntracked
144 ? await copyUntracked($, repoRoot, txRoot)
145 : { copied: [] as string[], skipped: [] as string[] }
146 // 3. Fingerprint the real paths the baseline touched.
147 const names = await $.process.run(['git', 'diff', 'HEAD', '--name-only'], { cwd: repoRoot })
148 if (names.exitCode !== 0 || names.isStdoutTruncated) {
149 throw new Error(`changed-file listing failed${names.isStdoutTruncated ? ' (output truncated)' : ` (exit ${names.exitCode})`}`)
150 }
151 const dirtyPaths = dirtyPatch.trim() !== '' ? parseNameOnly(names.stdout) : []
152 const fingerprint = await hashPaths($, repoRoot, [...dirtyPaths, ...un.copied])
153 // 4. The ephemeral baseline commit — skipped entirely on a clean tree
154 // (fast path: baselineCommit is HEAD, no extra git calls).
155 let baselineCommit = head.stdout.trim()
156 if (dirtyPatch.trim() !== '' || un.copied.length > 0) {
157 const staged = await $.process.run(['git', 'add', '-A'], { cwd: txRoot })
158 if (staged.exitCode !== 0 || staged.isStderrTruncated) throw new Error(`baseline staging failed${staged.isStderrTruncated ? ' (error output truncated)' : ` (exit ${staged.exitCode})`}`)
159 // NB: `-c key=value` config overrides must precede the subcommand —
160 // after `commit`, `-c` means "reuse message from commit <commit>".
161 const commit = await $.process.run(
162 ['git', '-c', 'user.name=airlock', '-c', 'user.email=airlock@localhost', 'commit', '-m', 'airlock baseline'],
163 { cwd: txRoot },
164 )
165 if (commit.exitCode !== 0) throw new Error(`baseline commit failed: ${commit.stderr.slice(0, 200)}`)
166 const base = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: txRoot })
167 if (base.exitCode !== 0) throw new Error('baseline rev-parse failed')
168 baselineCommit = base.stdout.trim()
169 }
170 return {
171 transactionId: txid,
172 sessionId,
173 turnId,
174 repoRoot,
175 txRoot,
176 startedAt: Date.now(),
177 baseHead: head.stdout.trim(),
178 baselineCommit,
179 baselineFingerprint: fingerprint,
180 untrackedCopied: un.copied,
181 skippedFiles: un.skipped,
182 state: 'ACTIVE',
183 changedFiles: [],
184 stats: { files: 0, insertions: 0, deletions: 0 },
185 sideEffectEvents: [],
186 bashCalls: 0,
187 rewrites: 0,
188 }
189 } catch (_err) {
190 // Fail safe (plan §24): remove the half-built workspace; the caller
191 // blocks mutations rather than edit the real tree un-isolated.
192 await $.process.run(['git', 'worktree', 'remove', '--force', txRoot], { cwd: repoRoot })
193 await $.process.run(['git', 'worktree', 'prune'], { cwd: repoRoot })
194 return null
195 }
196}
197
198export async function onTurnStart($: any, e: any, next: any): Promise<unknown> {
199 if (ctx.isolationFailed || !ctx.repoRoot) return next(e)
200 if (!e.text) return next(e) // a turn with no typed prompt (command output, continuations)
201
202 let sessionId = ''
203 try {
204 sessionId = await $.session.id()
205 } catch {
206 sessionId = ''
207 }
208
209 // Multi-session guard (self-healing): the machine shares one
210 // active-transaction record. When another session owns it for THIS repo
211 // and its workspace still exists, refuse to open a second one — two
212 // loops writing one worktree cannot be tracked. Mutations are denied
213 // (not fail-safe-blocked: the other session's transaction is healthy)
214 // until that session accepts or aborts; every later turn re-checks, so
215 // the block lifts itself once the record is resolved. A record whose
216 // workspace is gone is stale: clear it and go on.
217 const key = activeKey(ctx.repoRoot)
218 let saved = await $.store.get(key)
219 if (saved === undefined) {
220 const legacy = await $.store.get(LEGACY_ACTIVE_KEY)
221 const candidate = recoverableRecord(legacy, ctx.repoRoot)
222 if (candidate) { saved = candidate; await $.store.set(key, candidate); await $.store.delete(LEGACY_ACTIVE_KEY) }
223 }
224 const foreign = recoverableRecord(saved, ctx.repoRoot)
225 if (foreign && foreign.sessionId === sessionId && foreign.multiTurn === true && foreign.state === 'ACTIVE') {
226 ctx.tx = foreign
227 ctx.blockedByOtherSession = null
228 return next(e)
229 }
230 if (foreign && foreign.sessionId === sessionId) {
231 ctx.tx = null
232 ctx.blockedByOtherSession = `${foreign.state === 'ACTIVE' ? 'unfinished' : 'review pending'} (${foreign.transactionId})`
233 return next(e)
234 }
235 if (foreign && foreign.sessionId !== sessionId) {
236 ctx.tx = null
237 let live = false
238 try {
239 live = await $.fs.exists(foreign.txRoot)
240 } catch {
241 live = false
242 }
243 if (live) {
244 if (ctx.blockedByOtherSession === null) {
245 $.ui.status(`airlock: transaction ${foreign.transactionId} is open in another session — mutations blocked here`)
246 }
247 ctx.blockedByOtherSession = `session ${foreign.sessionId}`
248 return next(e)
249 }
250 await $.store.delete(key)
251 }
252
253 // Reached when no other session holds the record (or its workspace was
254 // a stale ghost just cleared above): any earlier block lifts.
255 ctx.blockedByOtherSession = null
256
257 ctx.tx = null
258 const optedIn = ctx.multiTurn || await $.store.get(multiTurnKey(ctx.repoRoot)) === true
259 ctx.multiTurn = optedIn
260 const record = await createTx($, ctx.repoRoot, e.turnId, sessionId)
261 if (record) record.multiTurn = optedIn
262 if (!record) {
263 // Fail safe (plan §24): no isolation, so mutations are blocked for
264 // the rest of the session rather than silently hitting the real tree.
265 ctx.isolationFailed = true
266 $.ui.status('airlock: isolation unavailable — mutations blocked (fail-safe)')
267 return next(e)
268 }
269 ctx.tx = record
270 await $.store.set(key, record)
271 $.ui.status(`AIRLOCK ${record.transactionId} • ACTIVE (mode: ${ctx.options.mode})`)
272 return next(e)
273}
274
275export async function onTurnComplete($: any, e: any, next: any): Promise<unknown> {
276 const t = ctx.tx
277 if (!t || e.agentId) return next(e) // subagent turns roll into the same transaction
278
279 // Commands can resolve or replace this transaction while a turn is in
280 // flight. Read the persisted owner before writing it back so completion
281 // cannot resurrect a transaction that was just aborted or replaced.
282 let persisted: any
283 try {
284 persisted = await $.store.get(activeKey(t.repoRoot))
285 } catch {
286 const message = 'airlock: could not verify the active transaction record; it remains unresolved.'
287 $.ui.status(message)
288 const r = await next(e)
289 return { ...r, text: completionNotice(r.text, e.answer, message) }
290 }
291 if (!persisted || persisted.transactionId !== t.transactionId || persisted.state !== 'ACTIVE' || t.state !== 'ACTIVE') {
292 if (ctx.tx?.transactionId === t.transactionId) ctx.tx = null
293 return next(e)
294 }
295
296 if (t.multiTurn === true) {
297 await $.store.set(activeKey(t.repoRoot), t)
298 return next(e)
299 }
300
301 // Stage everything inside the worktree (its own index — the real repo's
302 // staging area is untouched) so new and deleted files are in the patch.
303 const staged = await $.process.run(['git', 'add', '-A'], { cwd: t.txRoot })
304 if (staged.exitCode !== 0 || staged.isStderrTruncated) {
305 t.state = 'ACTIVE'
306 await $.store.set(activeKey(t.repoRoot), t)
307 const message = `airlock: could not stage the transaction for review${staged.isStderrTruncated ? ' (git error output was truncated)' : ` (git exit ${staged.exitCode})`}. The transaction remains ACTIVE and was not accepted.`
308 $.ui.status(message)
309 const r = await next(e)
310 return { ...r, text: completionNotice(r.text, e.answer, message) }
311 }
312
313 // Stats are measured against the baseline, never HEAD: on a dirty tree
314 // the user's pre-existing work must not appear in this transaction.
315 const numstat = await $.process.run(['git', 'diff', t.baselineCommit, '--numstat'], { cwd: t.txRoot })
316 if (numstat.exitCode !== 0 || numstat.isStdoutTruncated) {
317 t.state = 'ACTIVE'
318 await $.store.set(activeKey(t.repoRoot), t)
319 const message = `airlock: could not read complete transaction stats${numstat.isStdoutTruncated ? ' (output truncated)' : ` (git exit ${numstat.exitCode})`}. The transaction remains ACTIVE and was not accepted.`
320 $.ui.status(message)
321 const r = await next(e)
322 return { ...r, text: completionNotice(r.text, e.answer, message) }
323 }
324 const stat = numstat.exitCode === 0
325 ? parseNumstat(numstat.stdout)
326 : { files: 0, insertions: 0, deletions: 0, changedFiles: [] as string[] }
327 t.changedFiles = stat.changedFiles
328 t.stats = { files: stat.files, insertions: stat.insertions, deletions: stat.deletions }
329 t.state = 'REVIEW'
330 await $.store.set(activeKey(t.repoRoot), t)
331 $.ui.status(undefined)
332 if (ctx.isInteractive) {
333 const opened = await $.ui.open({ id: 'airlock-review', title: 'Airlock review', rows: 12 })
334 if (!opened.isPlaced) $.ui.toast('Airlock review is ready; use /airlock-review to open the pane, or /airlock-diff.')
335 }
336
337 const summary = [
338 `airlock: transaction ${t.transactionId} awaiting review`,
339 stat.files > 0
340 ? `${stat.files} file(s) changed, +${stat.insertions}/-${stat.deletions}${t.sideEffectEvents.length > 0 ? `, ${t.sideEffectEvents.length} external-effect event(s)` : ''}`
341 : 'no changes',
342 `bash routed into workspace: ${t.bashCalls}; path rewrites: ${t.rewrites}`,
343 'review with /airlock-review or /airlock-diff; apply with /airlock-accept; discard with /airlock-reject',
344 ].join(' | ')
345 const r = await next(e)
346 // Core already shows the assistant answer. A different completion text
347 // becomes a separate plugin row, not a replacement of that answer. Keep
348 // this row to our own single-line summary: replaying r.text duplicates
349 // the answer, and native notification rows sanitize embedded newlines.
350 return { ...r, text: completionNotice(r.text, e.answer, summary) }
351}
352
353// Error handler for turn.start: a hook that fails must never leave the
354// session editing the real tree believing a transaction is open.
355export function onTurnStartError($: any, e: any, next: any): unknown {
356 ctx.isolationFailed = true
357 $.ui.status('airlock: isolation failed — mutations blocked (fail-safe)')
358 return next(e)
359}
360hooks/tx/commands.tsx 777 lines1// /airlock-status, /airlock-diff, /airlock-accept, /airlock-reject (plan
2// §12, §13, §17). Accept is two conflict layers then a guarded apply:
3// 1. baseline fingerprint — re-hash the real paths the transaction's
4// patch touches; any drift means a concurrent human edit → CONFLICTED,
5// nothing applied (plan §11).
6// 2. git apply --check — the patch must still fit the real tree.
7// Then a byte-faithful backup of the files the patch touches (cp, argv —
8// $.fs.write is text-only) so a mid-apply failure is rolled back exactly;
9// on failure the transaction is kept in APPLY_FAILED, never half-applied
10// silently. Nothing is ever committed; git apply never touches the index,
11// so the user's staging state survives.
12//
13import { ctx, activeKey, historyKey, modeKey, multiTurnKey, LEGACY_ACTIVE_KEY, recoverableRecord } from './state.ts'
14import type { HistoryRecord } from './state.ts'
15import { isOurTxRoot, transactionsRoot } from './workspace.ts'
16import { trimHistory } from './state.ts'
17import { parseNameOnly, parseNumstat, parentDirOf } from './patch.ts'
18
19async function home($: any): Promise<string> {
20 const h = await $.env.get('HOME')
21 return typeof h === 'string' && h.length > 0 ? h : '/tmp'
22}
23
24async function baselineHashes($: any, t: any, paths: string[]): Promise<Record<string, string | null | undefined>> {
25 const out: Record<string, string | null | undefined> = {}
26 for (const path of paths) {
27 const saved = t.baselineFingerprint?.[path]
28 if (saved === 'airlock:absent') { out[path] = null; continue }
29 if (typeof saved === 'string') { out[path] = saved; continue }
30
31 const spec = `${t.baselineCommit}:${path}`
32 const blob = await $.process.run(['git', 'rev-parse', '--verify', '--end-of-options', spec], { cwd: t.txRoot })
33 if (blob.exitCode === 0 && blob.isStdoutTruncated !== true && /^[0-9a-f]{40}(?:[0-9a-f]{24})?\s*$/i.test(blob.stdout)) {
34 out[path] = blob.stdout.trim()
35 continue
36 }
37 // A failed blob lookup means the baseline path may be absent, but first
38 // distinguish that from a Git/repository/process error.
39 const entry = await $.process.run(['git', 'ls-tree', '-r', '-z', '--name-only', t.baselineCommit, '--', path], { cwd: t.txRoot })
40 if (entry.exitCode !== 0 || entry.isStdoutTruncated === true) continue
41 const names = entry.stdout.split('\0').filter((name: string) => name !== '')
42 out[path] = names.includes(path) ? undefined : null
43 }
44 return out
45}
46
47async function currentHashes($: any, repoRoot: string, paths: string[]): Promise<Record<string, string | null | undefined>> {
48 const out: Record<string, string | null | undefined> = {}
49 for (const path of paths) {
50 const hash = await $.process.run(['git', 'hash-object', '--', path], { cwd: repoRoot })
51 if (hash.exitCode === 0 && hash.isStdoutTruncated !== true && /^[0-9a-f]{40}(?:[0-9a-f]{24})?\s*$/i.test(hash.stdout)) {
52 out[path] = hash.stdout.trim()
53 continue
54 }
55 try {
56 out[path] = await $.fs.exists(`${repoRoot}/${path}`) ? undefined : null
57 } catch {
58 out[path] = undefined
59 }
60 }
61 return out
62}
63
64async function unsafeAffectedPaths($: any, t: any, paths: string[]): Promise<string[]> {
65 const unsafe: string[] = []
66 let repoReal = t.repoRoot
67 try {
68 const rootStat = await $.fs.stat(t.repoRoot, { resolve: true })
69 if (typeof rootStat?.realPath !== 'string') return [...paths]
70 repoReal = rootStat.realPath.replace(/\/$/, '')
71 } catch {
72 return [...paths]
73 }
74 for (const rel of paths) {
75 const parts = rel.split('/')
76 let bad = false
77 for (let i = 0; i < parts.length; i++) {
78 const spelling = `${t.repoRoot}/${parts.slice(0, i + 1).join('/')}`
79 let stat: any
80 try {
81 stat = await $.fs.stat(spelling, { resolve: true })
82 } catch {
83 try {
84 if (await $.fs.exists(spelling)) bad = true
85 } catch {
86 bad = true
87 }
88 if (bad) break
89 continue // A missing path component is valid for a new file.
90 }
91 if (i === parts.length - 1 && stat?.isLink === true) { bad = true; break }
92 if (typeof stat?.realPath !== 'string') {
93 if (stat?.kind === 'other') {
94 try {
95 if (!(await $.fs.exists(spelling))) continue
96 } catch {
97 bad = true
98 break
99 }
100 }
101 bad = true
102 break
103 }
104 const real = stat.realPath.replace(/\/$/, '')
105 if (real !== repoReal && !real.startsWith(`${repoReal}/`)) { bad = true; break }
106 }
107 if (bad) unsafe.push(rel)
108 }
109 return unsafe
110}
111
112// The transaction's own patch, measured against the baseline commit.
113async function generatePatch($: any, t: any): Promise<string | null> {
114 try {
115 const diff = await $.process.run(['git', 'diff', t.baselineCommit, '--binary'], { cwd: t.txRoot })
116 return diff.exitCode === 0 && diff.isStdoutTruncated !== true ? diff.stdout : null
117 } catch {
118 return null
119 }
120}
121
122function safeRepoPath(path: string): boolean {
123 return path !== '' && !path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path) && !path.includes('\0') &&
124 !path.split('/').some(part => part === '' || part === '.' || part === '..')
125}
126
127async function touchedPaths($: any, t: any): Promise<string[] | null> {
128 try {
129 const names = await $.process.run(['git', 'diff', '--name-only', t.baselineCommit], { cwd: t.txRoot })
130 if (names.exitCode !== 0 || names.isStdoutTruncated === true) return null
131 const paths = parseNameOnly(names.stdout)
132 return paths.every(safeRepoPath) ? paths : null
133 } catch {
134 return null
135 }
136}
137
138async function destroyTx($: any, h: string, txRoot: string, repoRoot: string): Promise<boolean> {
139 if (!isOurTxRoot(h, txRoot)) return false
140 try {
141 const rm = await $.process.run(['git', 'worktree', 'remove', '--force', txRoot], { cwd: repoRoot })
142 if (rm.exitCode === 0) return true
143 // Best effort: prune stale admin entries so later worktrees list clean.
144 await $.process.run(['git', 'worktree', 'prune'], { cwd: repoRoot })
145 return false
146 } catch {
147 return false
148 }
149}
150
151async function pushHistory($: any, record: HistoryRecord): Promise<void> {
152 const list = trimHistory(await $.store.get(historyKey(record.repoRoot)), ctx.options.maxStoredTransactions)
153 list.push(record)
154 await $.store.set(historyKey(record.repoRoot), trimHistory(list, ctx.options.maxStoredTransactions))
155}
156
157async function currentTx($: any): Promise<any | null> {
158 if (!ctx.repoRoot) return null
159 const saved = await $.store.get(activeKey(ctx.repoRoot))
160 const t = recoverableRecord(saved, ctx.repoRoot)
161 if (!t) { ctx.tx = null; return null }
162 let live = false
163 try { live = await $.fs.exists(t.txRoot) } catch { live = false }
164 if (!live) { ctx.tx = null; return null }
165 ctx.tx = t
166 return t
167}
168
169function historyOf(t: any, outcome: 'accepted' | 'rejected' | 'aborted', retained: boolean): HistoryRecord {
170 return {
171 transactionId: t.transactionId,
172 turnId: t.turnId,
173 repoRoot: t.repoRoot,
174 txRoot: retained ? t.txRoot : null,
175 baselineCommit: retained ? t.baselineCommit : undefined,
176 startedAt: t.startedAt,
177 endedAt: Date.now(),
178 outcome,
179 stats: t.stats,
180 retained,
181 }
182}
183
184// Backup every real-tree file the patch touches (byte-faithful cp, argv
185// form); returns which paths existed (backed up) and which are new.
186async function backupTouched($: any, t: any, touched: string[]): Promise<{ backedUp: string[]; newFiles: string[]; backupRoot: string } | null> {
187 const backupRoot = `${t.txRoot}.backup`
188 if (!isOurTxRoot(await home($), backupRoot)) return null
189 try {
190 // Never reuse a backup path which Bash or an earlier failed attempt
191 // could have replaced with a symlink or another filesystem object.
192 if (await $.fs.exists(backupRoot)) return null
193 const made = await $.process.run(['mkdir', '-p', '--', backupRoot], { cwd: t.repoRoot })
194 if (made.exitCode !== 0) return null
195 const rootStat = await $.fs.stat(backupRoot, { resolve: true })
196 if (rootStat?.kind !== 'dir' || rootStat?.isLink === true || typeof rootStat?.realPath !== 'string') return null
197 const txRootStat = await $.fs.stat(t.txRoot, { resolve: true })
198 const transactionsStat = await $.fs.stat(transactionsRoot(await home($)), { resolve: true })
199 const canonicalTransactionsRoot = typeof transactionsStat?.realPath === 'string' ? transactionsStat.realPath.replace(/\/$/, '') : ''
200 if (typeof txRootStat?.realPath !== 'string' || rootStat.realPath !== `${txRootStat.realPath}.backup` || canonicalTransactionsRoot === '' || !rootStat.realPath.startsWith(`${canonicalTransactionsRoot}/`)) return null
201 } catch {
202 return null
203 }
204 const backedUp: string[] = []
205 const newFiles: string[] = []
206 for (const p of touched) {
207 let exists = false
208 try {
209 exists = await $.fs.exists(`${t.repoRoot}/${p}`)
210 } catch {
211 return null
212 }
213 if (exists) {
214 const parent = parentDirOf(p)
215 if (parent !== '') {
216 const parentMade = await $.process.run(['mkdir', '-p', '--', `${backupRoot}/${parent}`], { cwd: t.repoRoot })
217 if (parentMade.exitCode !== 0) return null
218 }
219 const cp = await $.process.run(['cp', '-p', '--', `${t.repoRoot}/${p}`, `${backupRoot}/${p}`], { cwd: t.repoRoot })
220 if (cp.exitCode !== 0) return null // no faithful backup → refuse to apply
221 backedUp.push(p)
222 } else {
223 newFiles.push(p)
224 }
225 }
226 return { backedUp, newFiles, backupRoot }
227}
228
229// Roll back a failed apply: restore the backed-up files byte-for-byte,
230// remove files the patch created, report exactly what happened.
231async function rollbackApply($: any, t: any, backup: { backedUp: string[]; newFiles: string[]; backupRoot: string }): Promise<{ restored: string[]; removed: string[]; failed: string[] }> {
232 const restored: string[] = []
233 const removed: string[] = []
234 const failed: string[] = []
235 for (const p of backup.backedUp) {
236 try {
237 const cp = await $.process.run(['cp', '-p', '--', `${backup.backupRoot}/${p}`, `${t.repoRoot}/${p}`], { cwd: t.repoRoot })
238 if (cp.exitCode === 0) restored.push(p)
239 else failed.push(`restore ${p}`)
240 } catch {
241 failed.push(`restore ${p}`)
242 }
243 }
244 for (const p of backup.newFiles) {
245 let exists = false
246 try {
247 exists = await $.fs.exists(`${t.repoRoot}/${p}`)
248 } catch {
249 failed.push(`verify removal ${p}`)
250 continue
251 }
252 if (exists) {
253 try {
254 const rm = await $.process.run(['rm', '--', `${t.repoRoot}/${p}`], { cwd: t.repoRoot })
255 if (rm.exitCode === 0) removed.push(p)
256 else failed.push(`remove ${p}`)
257 } catch {
258 failed.push(`remove ${p}`)
259 }
260 }
261 }
262 return { restored, removed, failed }
263}
264
265export async function onTxStatus($: any, _e: any): Promise<{ text: string }> {
266 const t = await currentTx($)
267 if (!t) return { text: 'airlock: no open transaction.' }
268 const lines = [
269 `transaction ${t.transactionId} • ${t.state}`,
270 `repo: ${t.repoRoot}`,
271 `worktree: ${t.txRoot}`,
272 `base: ${t.baseHead.slice(0, 10)}${t.baselineCommit !== t.baseHead ? ` (baseline ${t.baselineCommit.slice(0, 10)})` : ''}`,
273 `files: ${t.stats.files} (+${t.stats.insertions}/-${t.stats.deletions})`,
274 `bash routed: ${t.bashCalls} · path rewrites: ${t.rewrites} · side-effect events: ${t.sideEffectEvents.length}`,
275 `mode: ${ctx.options.mode}`,
276 ]
277 if (t.untrackedCopied.length > 0) lines.push(`untracked carried into baseline: ${t.untrackedCopied.length}`)
278 if (t.skippedFiles.length > 0) lines.push(`untracked skipped (size/symlink/dir): ${t.skippedFiles.join(', ')}`)
279 return { text: lines.join('\n') }
280}
281
282export async function onTxDiff($: any, _e: any): Promise<{ text: string }> {
283 const t = await currentTx($)
284 if (!t) return { text: 'airlock: no open transaction.' }
285 const patch = await generatePatch($, t)
286 return { text: patch === null ? 'airlock: could not read the complete transaction diff.' : patch.slice(0, 8000) || '(no changes in the transaction)' }
287}
288
289export async function onTxAccept($: any, _e: any): Promise<{ text: string }> {
290 const t = await currentTx($)
291 if (!t) return { text: 'airlock: no open transaction.' }
292 const h = await home($)
293 if (!isOurTxRoot(h, t.txRoot)) {
294 return { text: 'airlock: refusing to act on a workspace that is not ours (safety guard).' }
295 }
296 if (t.state === 'APPLY_FAILED') {
297 return { text: 'airlock: this transaction may be partially applied and needs manual recovery; it cannot be accepted again. The workspace and backups are retained.' }
298 }
299 if (t.state !== 'REVIEW' && t.state !== 'CONFLICTED') {
300 return { text: `airlock: transaction is ${t.state}; finish the multi-turn work with /airlock-begin end before accepting.` }
301 }
302
303 const patch = await generatePatch($, t)
304 if (patch === null) {
305 return { text: 'airlock: could not read the complete transaction diff — refusing to accept. The transaction is kept for review.' }
306 }
307 if (!patch.trim()) {
308 const destroyed = await destroyTx($, h, t.txRoot, t.repoRoot)
309 await $.store.delete(activeKey(t.repoRoot))
310 await $.store.set(multiTurnKey(t.repoRoot), false)
311 ctx.multiTurn = false
312 ctx.blockedByOtherSession = null
313 ctx.tx = null
314 await pushHistory($, historyOf(t, 'accepted', !destroyed))
315 return { text: `airlock: empty transaction — nothing to apply.${destroyed ? ' Workspace removed.' : ` Workspace retained at ${t.txRoot} because it could not be removed.`}` }
316 }
317
318 const touched = await touchedPaths($, t)
319 if (touched === null || touched.length === 0) {
320 return { text: 'airlock: could not read a complete, safe, nonempty list of changed paths — refusing to accept. The transaction is kept for review.' }
321 }
322
323 // Conflict layer 1 (plan §11): the real tree must be byte-identical on
324 // every path the baseline fingerprinted that this patch touches. Any
325 // drift is a concurrent human edit: never auto-overwrite.
326 const unsafe = await unsafeAffectedPaths($, t, touched)
327 const expected = await baselineHashes($, t, touched)
328 const actual = await currentHashes($, t.repoRoot, touched)
329 const drifted = touched.filter(p => unsafe.includes(p) || expected[p] === undefined || actual[p] === undefined || expected[p] !== actual[p])
330 if (drifted.length > 0) {
331 t.state = 'CONFLICTED'
332 await $.store.set(activeKey(t.repoRoot), t)
333 return {
334 text: `airlock: CONFLICT — these file(s) changed or failed path verification in the real tree since the transaction began:\n ${drifted.join('\n ')}\nNothing was applied; the transaction is kept. Review with /airlock-diff, discard with /airlock-reject, or resolve the change and accept again.`,
335 }
336 }
337
338 // Conflict layer 2: the patch must still fit the real tree.
339 const check = await $.process.run(['git', 'apply', '--check', '-'], { cwd: t.repoRoot, stdin: patch })
340 if (check.exitCode !== 0) {
341 t.state = 'CONFLICTED'
342 await $.store.set(activeKey(t.repoRoot), t)
343 return {
344 text: `airlock: CONFLICT — the real tree changed since the transaction began; nothing was applied and the transaction is kept for review.\n${check.stderr.slice(0, 500)}`,
345 }
346 }
347
348 // Pre-apply backup (plan §12): if the apply fails midway we restore
349 // exactly, leaving no unexplained partial state. Without a faithful
350 // backup we refuse to apply at all.
351 let backup: Awaited<ReturnType<typeof backupTouched>> = null
352 try { backup = await backupTouched($, t, touched) } catch { backup = null }
353 if (!backup) {
354 t.state = 'CONFLICTED'
355 await $.store.set(activeKey(t.repoRoot), t)
356 return { text: 'airlock: could not back up the affected files — refusing to apply (fail-safe). The transaction is kept.' }
357 }
358
359 // Persist a crash-recovery state before invoking a command that may make
360 // partial changes. A process/session death leaves this transaction
361 // ineligible for another blind accept.
362 t.state = 'APPLY_FAILED'
363 await $.store.set(activeKey(t.repoRoot), t)
364 let applied: any
365 try {
366 applied = await $.process.run(['git', 'apply', '-'], { cwd: t.repoRoot, stdin: patch })
367 } catch (error) {
368 applied = { exitCode: 1, stderr: error instanceof Error ? error.message : 'process.run failed' }
369 }
370 if (applied.exitCode !== 0) {
371 const rolled = await rollbackApply($, t, backup)
372 t.state = 'APPLY_FAILED'
373 await $.store.set(activeKey(t.repoRoot), t)
374 return {
375 text: `airlock: apply failed midway${rolled.failed.length === 0 ? ' and was rolled back completely' : ' and rollback was incomplete'}${rolled.restored.length > 0 ? ` — restored ${rolled.restored.join(', ')}` : ''}${rolled.removed.length > 0 ? ` — removed ${rolled.removed.join(', ')}` : ''}${rolled.failed.length > 0 ? ` — manual recovery needed: ${rolled.failed.join(', ')}` : ''}. The transaction and backup ${backup.backupRoot} are kept in APPLY_FAILED for manual recovery.\n${applied.stderr.slice(0, 400)}`,
376 }
377 }
378
379 const destroyed = await destroyTx($, h, t.txRoot, t.repoRoot)
380 // The backup directory is ours by construction; remove it only under
381 // the same ownership guard as the workspace itself.
382 if (isOurTxRoot(h, backup.backupRoot)) {
383 await $.process.run(['rm', '-rf', '--', backup.backupRoot], { cwd: t.repoRoot })
384 }
385 await $.store.delete(activeKey(t.repoRoot))
386 await $.store.set(multiTurnKey(t.repoRoot), false)
387 ctx.multiTurn = false
388 ctx.blockedByOtherSession = null
389 ctx.tx = null
390 await pushHistory($, historyOf(t, 'accepted', !destroyed))
391 return {
392 text: `airlock: applied transaction ${t.transactionId} to the real worktree (${t.stats.files} file(s), +${t.stats.insertions}/-${t.stats.deletions}). Nothing was committed; changes are unstaged.${destroyed ? '' : ` The transaction workspace is retained at ${t.txRoot} because it could not be removed.`}`,
393 }
394}
395
396export async function onTxReject($: any, _e: any): Promise<{ text: string }> {
397 const t = await currentTx($)
398 if (!t) return { text: 'airlock: no open transaction.' }
399 const h = await home($)
400 if (!isOurTxRoot(h, t.txRoot)) {
401 return { text: 'airlock: refusing to act on a workspace that is not ours (safety guard).' }
402 }
403
404 if (ctx.options.retainRejectedTransactions) {
405 // Keep the worktree and the diff on disk for later inspection.
406 t.state = 'REJECTED'
407 await $.store.delete(activeKey(t.repoRoot))
408 await $.store.set(multiTurnKey(t.repoRoot), false)
409 ctx.multiTurn = false
410 ctx.blockedByOtherSession = null
411 ctx.tx = null
412 await pushHistory($, historyOf(t, 'rejected', true))
413 return {
414 text: `airlock: transaction ${t.transactionId} rejected. No patch is applied by this action; the workspace is retained at ${t.txRoot} for inspection.`,
415 }
416 }
417
418 const destroyed = await destroyTx($, h, t.txRoot, t.repoRoot)
419 await $.store.delete(activeKey(t.repoRoot))
420 await $.store.set(multiTurnKey(t.repoRoot), false)
421 ctx.multiTurn = false
422 ctx.blockedByOtherSession = null
423 ctx.tx = null
424 await pushHistory($, historyOf(t, 'rejected', !destroyed))
425 return {
426 text: destroyed
427 ? 'airlock: transaction rejected and destroyed. This action did not apply a patch. Effects outside the workspace are not undone.'
428 : 'airlock: transaction rejected; the worktree could not be removed automatically (kept for manual cleanup).',
429 }
430}
431
432// /airlock-abort (plan §17): the in-flight discard — same guarantee as
433// reject (discard without applying a patch), taken before review rather
434// than after. Idempotent.
435export async function onTxAbort($: any, _e: any): Promise<{ text: string }> {
436 const t = await currentTx($)
437 if (!t) {
438 if (ctx.repoRoot) await $.store.set(multiTurnKey(ctx.repoRoot), false)
439 ctx.multiTurn = false
440 return { text: 'airlock: no open transaction; pending multi-turn mode cleared.' }
441 }
442 const h = await home($)
443 if (!isOurTxRoot(h, t.txRoot)) {
444 return { text: 'airlock: refusing to act on a workspace that is not ours (safety guard).' }
445 }
446
447 if (ctx.options.retainRejectedTransactions) {
448 t.state = 'ABORTED'
449 await $.store.delete(activeKey(t.repoRoot))
450 await $.store.set(multiTurnKey(t.repoRoot), false)
451 ctx.multiTurn = false
452 ctx.blockedByOtherSession = null
453 ctx.tx = null
454 $.ui.status(undefined)
455 await pushHistory($, historyOf(t, 'aborted', true))
456 return {
457 text: `airlock: transaction ${t.transactionId} aborted. No patch is applied by this action; the workspace is retained at ${t.txRoot} for inspection.`,
458 }
459 }
460
461 const destroyed = await destroyTx($, h, t.txRoot, t.repoRoot)
462 await $.store.delete(activeKey(t.repoRoot))
463 await $.store.set(multiTurnKey(t.repoRoot), false)
464 ctx.multiTurn = false
465 ctx.blockedByOtherSession = null
466 ctx.tx = null
467 $.ui.status(undefined)
468 await pushHistory($, historyOf(t, 'aborted', !destroyed))
469 return {
470 text: destroyed
471 ? 'airlock: transaction aborted and destroyed. This action did not apply a patch. Effects outside the workspace are not undone.'
472 : 'airlock: transaction aborted; the worktree could not be removed automatically (kept for manual cleanup with /airlock-cleanup).',
473 }
474}
475
476// /airlock-cleanup (plan §17): stale workspaces under our transactions
477// root. Report-only by default (recovery favors preserving data, plan
478// §21); `purge` removes them — never anything outside ~/.claude-airlock
479// and never the current transaction (or its pre-apply backup directory).
480export async function onTxCleanup($: any, e: any): Promise<{ text: string }> {
481 const h = await home($)
482 const root = transactionsRoot(h)
483 const purge = typeof e?.args === 'string' && e.args.includes('purge')
484
485 // Cleanup must never remove a workspace owned by any repository record.
486 const protectedRoots = new Set<string>()
487 for (const key of await $.store.keys()) {
488 if (!key.endsWith(':active') && key !== LEGACY_ACTIVE_KEY) continue
489 const saved = await $.store.get(key) as { txRoot?: unknown } | undefined
490 if (!saved || typeof saved.txRoot !== 'string') continue
491 let live = true
492 try { live = await $.fs.exists(saved.txRoot) } catch { live = true }
493 if (live) protectedRoots.add(saved.txRoot)
494 else await $.store.delete(key)
495 }
496
497 let entries: Array<{ name: string }> = []
498 try {
499 entries = (await $.fs.list(root)) as Array<{ name: string }>
500 } catch {
501 return { text: 'airlock: no transactions directory yet — nothing to clean.' }
502 }
503
504 const stale: Array<{ path: string; mtimeMs: number }> = []
505 for (const en of entries ?? []) {
506 const path = `${root}/${en.name}`
507 if (protectedRoots.has(path) || [...protectedRoots].some(active => `${active}.backup` === path)) continue
508 if (!isOurTxRoot(h, path)) continue // never touch anything not directly ours
509 let mtimeMs = 0
510 try {
511 const st = await $.fs.stat(path)
512 mtimeMs = typeof st?.mtimeMs === 'number' ? st.mtimeMs : 0
513 } catch {
514 mtimeMs = 0
515 }
516 stale.push({ path, mtimeMs })
517 }
518
519 if (stale.length === 0) {
520 return { text: 'airlock: no stale workspaces (the open transaction, if any, is excluded).' }
521 }
522
523 if (!purge) {
524 const lines = stale.map(s => {
525 const age = s.mtimeMs > 0 ? `${Math.max(1, Math.round((Date.now() - s.mtimeMs) / 60000))} min old` : 'unknown age'
526 return ` ${s.path} (${age})`
527 })
528 return {
529 text: `airlock: ${stale.length} stale workspace(s):\n${lines.join('\n')}\nNothing was removed. Run /airlock-cleanup purge to remove them.`,
530 }
531 }
532
533 const removed: string[] = []
534 const failed: string[] = []
535 for (const s of stale) {
536 let gone = false
537 // Real git refuses `worktree remove` on the worktree the command
538 // itself runs in, so resolve the main repository first and remove
539 // from there — this also cleans the worktree's admin entry.
540 const common = await $.process.run(['git', '-C', s.path, 'rev-parse', '--git-common-dir'])
541 if (common.exitCode === 0) {
542 let dir = common.stdout.trim()
543 if (dir !== '' && !dir.startsWith('/')) dir = `${s.path}/${dir}`
544 const mainRepo = dir.replace(/\/\.git$/, '').replace(/\/$/, '')
545 if (mainRepo !== '' && isOurTxRoot(h, s.path)) {
546 const rm = await $.process.run(['git', '-C', mainRepo, 'worktree', 'remove', '--force', s.path])
547 if (rm.exitCode === 0) gone = true
548 }
549 }
550 if (!gone) {
551 // Not a live worktree (a pruned admin entry, a backup directory):
552 // remove directly — the ownership guard already proved it is ours.
553 const rmrf = await $.process.run(['rm', '-rf', '--', s.path])
554 if (rmrf.exitCode === 0) gone = true
555 }
556 if (gone) removed.push(s.path)
557 else failed.push(s.path)
558 }
559
560 const parts = [`airlock: cleanup removed ${removed.length} workspace(s).`]
561 if (failed.length > 0) parts.push(`Could not remove (manual inspection needed):\n ${failed.join('\n ')}`)
562 return { text: parts.join('\n') }
563}
564
565// /airlock-history (plan §17): the bounded ring of finished transactions.
566export async function onTxHistory($: any, _e: any): Promise<{ text: string }> {
567 if (!ctx.repoRoot) return { text: 'airlock: transaction history is available only inside a git repository.' }
568 const list = trimHistory(await $.store.get(historyKey(ctx.repoRoot)), ctx.options.maxStoredTransactions)
569 if (list.length === 0) return { text: 'airlock: no transaction history yet.' }
570 const lines = list.slice(-20).map(r => {
571 const when = new Date(r.startedAt).toISOString()
572 const tail = r.retained && r.txRoot !== null ? ` • retained: ${r.txRoot}` : ''
573 return `${r.transactionId} • ${r.outcome} • ${r.stats.files} file(s) +${r.stats.insertions}/-${r.stats.deletions} • ${when}${tail}`
574 })
575 return { text: `airlock: last ${lines.length} transaction(s):\n${lines.join('\n')}` }
576}
577
578export async function onTxMode($: any, e: any): Promise<{ text: string }> {
579 const arg = typeof e?.args === 'string' ? e.args.trim().toLowerCase() : ''
580 if (!ctx.repoRoot) return { text: 'airlock: safety mode is available only inside a git repository.' }
581 if (arg === '') {
582 const saved = await $.store.get(modeKey(ctx.repoRoot))
583 if (saved === 'strict' || saved === 'balanced' || saved === 'permissive') ctx.options.mode = saved
584 return { text: `airlock: mode for ${ctx.repoRoot}: ${ctx.options.mode}. Set with /airlock-mode strict|balanced|permissive.` }
585 }
586 if (arg !== 'strict' && arg !== 'balanced' && arg !== 'permissive') return { text: 'airlock: mode must be strict, balanced, or permissive.' }
587 ctx.options.mode = arg
588 await $.store.set(modeKey(ctx.repoRoot), arg)
589 return { text: `airlock: mode for ${ctx.repoRoot} set to ${arg}.` }
590}
591
592export async function onTxBegin($: any, e: any): Promise<{ text: string }> {
593 const arg = typeof e?.args === 'string' ? e.args.trim().toLowerCase() : ''
594 if (!ctx.repoRoot) return { text: 'airlock: multi-turn transactions are available only inside a git repository.' }
595 if (arg !== '' && arg !== 'end' && arg !== 'review') return { text: 'airlock: use /airlock-begin to enable multi-turn mode or /airlock-begin end to open the current transaction for review.' }
596 const t = await currentTx($)
597 if (arg === 'end' || arg === 'review') {
598 if (!t) return { text: 'airlock: no open multi-turn transaction.' }
599 if (t.state !== 'ACTIVE') return { text: `airlock: transaction is ${t.state}; use /airlock-accept or /airlock-reject.` }
600 let stat: any
601 try {
602 const added = await $.process.run(['git', 'add', '-A'], { cwd: t.txRoot })
603 if (added.exitCode !== 0 || added.isStdoutTruncated === true) {
604 return { text: 'airlock: could not stage the complete multi-turn workspace; it remains ACTIVE. Retry /airlock-begin end after resolving the Git error.' }
605 }
606 stat = await $.process.run(['git', 'diff', t.baselineCommit, '--numstat'], { cwd: t.txRoot })
607 } catch {
608 return { text: 'airlock: Git failed while preparing multi-turn review; the transaction remains ACTIVE.' }
609 }
610 if (stat.exitCode !== 0 || stat.isStdoutTruncated === true) {
611 return { text: 'airlock: could not read complete multi-turn change statistics; the transaction remains ACTIVE.' }
612 }
613 const parsed = parseNumstat(stat.stdout)
614 t.changedFiles = parsed.changedFiles
615 t.stats = { files: parsed.files, insertions: parsed.insertions, deletions: parsed.deletions }
616 t.state = 'REVIEW'
617 t.multiTurn = false
618 ctx.multiTurn = false
619 await $.store.set(multiTurnKey(ctx.repoRoot), false)
620 await $.store.set(activeKey(ctx.repoRoot), t)
621 if (ctx.isInteractive) await $.ui.open({ id: 'airlock-review', title: 'Airlock review', rows: 12 })
622 return { text: `airlock: multi-turn transaction ${t.transactionId} is ready for review (${parsed.files} file(s)). Use /airlock-accept or /airlock-reject.` }
623 }
624 if (t && t.state !== 'ACTIVE') return { text: `airlock: transaction is ${t.state}; resolve it before enabling multi-turn mode.` }
625 if (t?.state === 'ACTIVE') {
626 t.multiTurn = true
627 ctx.multiTurn = true
628 await $.store.set(multiTurnKey(ctx.repoRoot), true)
629 await $.store.set(activeKey(ctx.repoRoot), t)
630 return { text: `airlock: transaction ${t.transactionId} will stay open across turns. Use /airlock-begin end when ready for review.` }
631 }
632 ctx.multiTurn = true
633 await $.store.set(multiTurnKey(ctx.repoRoot), true)
634 return { text: 'airlock: multi-turn mode enabled for this repository. The next coding turn opens a transaction that remains active across turns; use /airlock-begin end to review it.' }
635}
636
637export async function onTxRejected($: any, _e: any): Promise<{ text: string }> {
638 if (!ctx.repoRoot) return { text: 'airlock: retained transactions are available only inside a git repository.' }
639 const list = trimHistory(await $.store.get(historyKey(ctx.repoRoot)), ctx.options.maxStoredTransactions)
640 const requested = typeof _e?.args === 'string' ? _e.args.trim() : ''
641 const retained = list.filter(r => r.outcome === 'rejected' && r.retained && (requested === '' || r.transactionId === requested))
642 if (retained.length === 0) return { text: 'airlock: no retained rejected transactions for this repository.' }
643 if (requested !== '') {
644 const record = retained[0]
645 if (!record.txRoot || record.repoRoot !== ctx.repoRoot || !isOurTxRoot(await home($), record.txRoot)) {
646 return { text: 'airlock: retained workspace path failed its repository ownership check.' }
647 }
648 let live = false
649 try { live = await $.fs.exists(record.txRoot) } catch { live = false }
650 if (!live) return { text: 'airlock: retained workspace is no longer available.' }
651 const base = typeof record.baselineCommit === 'string' && record.baselineCommit !== '' ? record.baselineCommit : 'HEAD'
652 const diff = await $.process.run(['git', 'diff', base, '--binary'], { cwd: record.txRoot })
653 if (diff.exitCode !== 0) return { text: 'airlock: could not read retained transaction diff.' }
654 return { text: diff.stdout.length > 8000 ? `${diff.stdout.slice(0, 8000)}\n\n[diff truncated at 8000 characters]` : diff.stdout || '(no changes in retained transaction)' }
655 }
656 return { text: `airlock: retained rejected transactions (use /airlock-rejected <transaction-id> to inspect):\n${retained.map(r => ` ${r.transactionId} • ${r.txRoot}`).join('\n')}` }
657}
658
659export async function onTxReview($: any, _e: any): Promise<{ text: string }> {
660 const t = await currentTx($)
661 if (!t) return { text: 'airlock: no open transaction to review.' }
662 if (t.state === 'ACTIVE') {
663 return { text: 'airlock: this transaction is still ACTIVE. Finish the work with /airlock-begin end before opening it for review.' }
664 }
665 if (t.state !== 'REVIEW' && t.state !== 'CONFLICTED' && t.state !== 'APPLY_FAILED') {
666 return { text: `airlock: transaction is ${t.state}; there is no open review to show.` }
667 }
668 if (!ctx.isInteractive) {
669 return { text: 'airlock: the review pane needs an interactive session. Use /airlock-diff to inspect the transaction here.' }
670 }
671
672 const opened = await $.ui.open({ id: REVIEW_PANE, title: 'Airlock review', focus: true, rows: 12 })
673 if (!opened?.isPlaced) {
674 const reason = typeof opened?.reason === 'string' && opened.reason !== '' ? ` (${opened.reason})` : ''
675 return { text: `airlock: could not place the review pane${reason}. Use /airlock-diff to inspect the transaction.` }
676 }
677 if (t.state === 'APPLY_FAILED') {
678 return { text: `airlock: opened inspection for transaction ${t.transactionId} in APPLY_FAILED. Keep the workspace and backup for manual recovery; Airlock will not retry this apply.` }
679 }
680 return { text: `airlock: opened review for transaction ${t.transactionId} (${t.state}). Accept checks concurrent edits and patch fit; it does not run project tests.` }
681}
682
683// Pane handlers share this file with command handlers so the engine can
684// track every $ call and button actions use the same accept/reject logic.
685const REVIEW_PANE = 'airlock-review'
686
687type ReviewTransaction = {
688 transactionId?: string
689 repoRoot?: string
690 txRoot?: string
691 baseHead?: string
692 baselineCommit?: string
693 state?: string
694 changedFiles?: string[]
695 skippedFiles?: string[]
696 untrackedCopied?: string[]
697 stats?: { files?: number; insertions?: number; deletions?: number }
698 sideEffectEvents?: Array<{ pattern?: string; action?: string; reason?: string }>
699}
700
701async function storedTransaction($: any): Promise<ReviewTransaction | null> {
702 if (!ctx.repoRoot) return null
703 const value = await $.store.get(activeKey(ctx.repoRoot))
704 return value && typeof value === 'object' ? value as ReviewTransaction : null
705}
706
707function diffKey(repoRoot: string): string { return `airlock:${repoRoot}:review-diff` }
708
709async function isCurrentReview($: any, transactionId: string): Promise<boolean> {
710 const latest = await storedTransaction($)
711 if (latest?.transactionId === transactionId) return true
712 $.ui.toast('This review pane is stale. Open the current transaction review before acting.')
713 $.ui.invalidate('ui.render')
714 return false
715}
716
717export async function onReviewRender($: any, e: any): Promise<unknown> {
718 const { Box, Text, Button, Code } = $.ui.resolve(e)
719 const tx = await storedTransaction($)
720 if (!tx) return <Box flexDirection="column"><Text>Airlock has no open transaction for this repository.</Text></Box>
721
722 const stats = tx.stats ?? {}
723 const files = Array.isArray(tx.changedFiles) ? tx.changedFiles : []
724 const skipped = Array.isArray(tx.skippedFiles) ? tx.skippedFiles : []
725 const untracked = Array.isArray(tx.untrackedCopied) ? tx.untrackedCopied : []
726 const effects = Array.isArray(tx.sideEffectEvents) ? tx.sideEffectEvents : []
727 const savedDiff = await $.store.get(diffKey(ctx.repoRoot)) as { transactionId?: string; text?: string; truncated?: boolean } | undefined
728 const currentDiff = savedDiff?.transactionId === tx.transactionId ? savedDiff : undefined
729
730 return (
731 <Box flexDirection="column">
732 <Text>Transaction {tx.transactionId ?? '(unknown)'} · {tx.state ?? 'REVIEW'}</Text>
733 <Text dimColor>Repository: {tx.repoRoot ?? ctx.repoRoot}</Text>
734 <Text dimColor>Worktree: {tx.txRoot}</Text>
735 <Text>Changes: {stats.files ?? files.length} file(s), +{stats.insertions ?? 0}/-{stats.deletions ?? 0}</Text>
736 {files.length > 0 && <Text>Files: {files.slice(0, 8).join(', ')}{files.length > 8 ? ` and ${files.length - 8} more` : ''}</Text>}
737 {untracked.length > 0 && <Text dimColor>Untracked files included in baseline: {untracked.length}</Text>}
738 {skipped.length > 0 && <Text dimColor>Skipped files: {skipped.join(', ')}</Text>}
739 {effects.length > 0 && <Text dimColor>Side-effect events: {effects.length} ({effects.filter(x => x.action === 'denied').length} denied). Effects already performed outside the workspace cannot be rolled back by reject.</Text>}
740 <Text dimColor>Checks: Airlock does not run your project test suite. Accept checks concurrent edits and patch fit before applying. Reject discards this worktree.</Text>
741 {tx.state === 'APPLY_FAILED' && <Text>APPLY_FAILED: this transaction may be partially applied. Preserve the workspace and backup for manual recovery; Accept cannot retry this apply.</Text>}
742 {typeof currentDiff?.text === 'string' && currentDiff.text !== '' && (currentDiff.truncated
743 ? <Box flexDirection="column"><Text dimColor>Pane preview is capped at 10,000 characters; the diff may be incomplete.</Text><Code source={currentDiff.text} /></Box>
744 : <Code source={currentDiff.text} format="diff" />)}
745 <Box flexDirection="row">
746 <Button key="review-diff" label="Review" hotkey="v" onPress={async () => {
747 if (!(await isCurrentReview($, tx.transactionId ?? ''))) return
748 const patch = await $.process.run(['git', 'diff', tx.baselineCommit ?? tx.baseHead ?? 'HEAD', '--binary'], { cwd: tx.txRoot })
749 const latest = await storedTransaction($)
750 if (latest?.transactionId !== tx.transactionId) return
751 const text = patch.exitCode === 0 ? patch.stdout : `Could not read the transaction diff (exit code ${patch.exitCode ?? 'unknown'}).`
752 await $.store.set(diffKey(ctx.repoRoot), {
753 transactionId: tx.transactionId,
754 text: text.slice(0, 10000),
755 truncated: patch.isStdoutTruncated || text.length > 10000,
756 })
757 $.ui.invalidate('ui.render')
758 }} />
759 <Button key="accept" label="Accept" hotkey="a" variant="primary" onPress={async e => {
760 if (!(await isCurrentReview($, tx.transactionId ?? ''))) return
761 const result: any = await onTxAccept($, {})
762 $.ui.toast(result?.text?.split('\n')[0] ?? 'Airlock accept finished.', { timeoutMs: 6000 })
763 if (await $.store.get(activeKey(ctx.repoRoot)) === undefined) await $.ui.close({ id: REVIEW_PANE })
764 else $.ui.invalidate('ui.render')
765 }} />
766 <Button key="reject" label="Reject" hotkey="r" variant="secondary" onPress={async e => {
767 if (!(await isCurrentReview($, tx.transactionId ?? ''))) return
768 const result: any = await onTxReject($, {})
769 $.ui.toast(result?.text?.split('\n')[0] ?? 'Airlock reject finished.', { timeoutMs: 6000 })
770 if (await $.store.get(activeKey(ctx.repoRoot)) === undefined) await $.ui.close({ id: REVIEW_PANE })
771 else $.ui.invalidate('ui.render')
772 }} />
773 </Box>
774 </Box>
775 )
776}
777hooks/tools/rewrite.ts 107 lines1// Structured file paths are routed through canonical source and destination
2// checks. Missing leaves resolve through an existing ancestor; inaccessible or
3// unresolved existing paths deny mutations. Read escapes pass through. This
4// is snapshot containment, not a sandbox or protection against symlink races.
5
6import { ctx, activeKey } from '../tx/state.ts'
7import { virtualize, contains, normalize } from '../tx/paths.ts'
8
9const ESCAPE_DENY =
10 'airlock: path resolves outside the repository (symlink?); refused while a transaction is open.'
11
12// Resolve missing leaves through their nearest existing ancestor. Unknown or
13// inaccessible paths fail closed. This is a snapshot check, not a filesystem
14// lock; arbitrary Bash and concurrent symlink replacement remain limitations.
15async function canonicalPath($: any, path: string): Promise<string | null> {
16 let current = normalize(path)
17 const suffix: string[] = []
18 while (current.startsWith('/')) {
19 try {
20 const st = await $.fs.stat(current, { resolve: true })
21 if (typeof st?.realPath === 'string') return normalize([st.realPath, ...suffix].join('/'))
22 if (st?.isLink || st?.kind !== 'other') return null
23 if (await $.fs.exists(current)) return null
24 } catch (err: any) {
25 if (err?.code !== 'ENOENT' && !String(err?.message ?? err).includes('ENOENT')) return null
26 }
27 if (current === '/') return null
28 const at = current.lastIndexOf('/')
29 suffix.unshift(current.slice(at + 1))
30 current = current.slice(0, at) || '/'
31 }
32 return null
33}
34
35async function safeMappedPath($: any, original: string): Promise<string | null> {
36 const lexicalInside = contains(ctx.repoRoot, original) || contains(ctx.tx!.txRoot, original)
37 let repo: string | null = null
38 let tx: string | null = null
39 try {
40 const repoStat = await $.fs.stat(ctx.repoRoot, { resolve: true })
41 const txStat = await $.fs.stat(ctx.tx!.txRoot, { resolve: true })
42 repo = typeof repoStat?.realPath === 'string' ? normalize(repoStat.realPath) : null
43 tx = txStat?.isLink !== true && typeof txStat?.realPath === 'string' ? normalize(txStat.realPath) : null
44 } catch { return null }
45 if (!repo || !tx || repo === tx) return null
46 if (!lexicalInside && !contains(repo, original) && !contains(tx, original)) return original
47 const source = await canonicalPath($, original)
48 if (!source) return null
49 const fromTx = contains(ctx.tx!.txRoot, original) || contains(tx, original)
50 if (!contains(fromTx ? tx : repo, source)) return null
51 const mapped = fromTx ? source : virtualize(source, repo, tx)
52 const destination = await canonicalPath($, mapped)
53 return destination && contains(tx, destination) ? destination : null
54}
55
56async function stillActive($: any): Promise<boolean> {
57 try {
58 const saved = await $.store.get(activeKey(ctx.repoRoot))
59 return saved?.transactionId === ctx.tx?.transactionId && saved?.state === 'ACTIVE'
60 } catch { return false }
61}
62
63function blockedDeny(verb: string): { deny: string } {
64 return {
65 deny: `airlock: ${ctx.blockedByOtherSession} holds the open transaction for this repository. Review or resolve it with /airlock-diff, /airlock-accept, or /airlock-abort before retrying. ${verb} refused.`,
66 }
67}
68
69export async function onReadCall($: any, e: any, next: any): Promise<unknown> {
70 if (!ctx.tx) return next(e)
71 const mapped = await safeMappedPath($, e.file_path)
72 return next(mapped === null ? e : { ...e, file_path: mapped })
73}
74
75export async function onEditCall($: any, e: any, next: any): Promise<unknown> {
76 if (ctx.isolationFailed) return { deny: 'airlock: no transaction workspace (fail-safe); edit refused.' }
77 if (ctx.blockedByOtherSession) return blockedDeny('Edit')
78 if (!ctx.tx) return next(e)
79 if (ctx.tx.state !== 'ACTIVE' || !(await stillActive($))) return { deny: 'airlock: this transaction is awaiting review. Accept or reject it before editing.' }
80 const mapped = await safeMappedPath($, e.file_path)
81 if (mapped === null) return { deny: ESCAPE_DENY }
82 ctx.tx.rewrites++
83 return next({ ...e, file_path: mapped })
84}
85
86export async function onWriteCall($: any, e: any, next: any): Promise<unknown> {
87 if (ctx.isolationFailed) return { deny: 'airlock: no transaction workspace (fail-safe); write refused.' }
88 if (ctx.blockedByOtherSession) return blockedDeny('Write')
89 if (!ctx.tx) return next(e)
90 if (ctx.tx.state !== 'ACTIVE' || !(await stillActive($))) return { deny: 'airlock: this transaction is awaiting review. Accept or reject it before editing.' }
91 const mapped = await safeMappedPath($, e.file_path)
92 if (mapped === null) return { deny: ESCAPE_DENY }
93 ctx.tx.rewrites++
94 return next({ ...e, file_path: mapped })
95}
96
97export async function onNotebookEditCall($: any, e: any, next: any): Promise<unknown> {
98 if (ctx.isolationFailed) return { deny: 'airlock: no transaction workspace (fail-safe); edit refused.' }
99 if (ctx.blockedByOtherSession) return blockedDeny('NotebookEdit')
100 if (!ctx.tx) return next(e)
101 if (ctx.tx.state !== 'ACTIVE' || !(await stillActive($))) return { deny: 'airlock: this transaction is awaiting review. Accept or reject it before editing.' }
102 const mapped = await safeMappedPath($, e.notebook_path)
103 if (mapped === null) return { deny: ESCAPE_DENY }
104 ctx.tx.rewrites++
105 return next({ ...e, notebook_path: mapped })
106}
107hooks/tools/bash.ts 130 lines1// Bash interception (plan §7 Option A): while a transaction is open the
2// hook answers the call itself with $.process.run(argv, { cwd }) —
3// structured working-directory control, no string surgery on the command.
4// Before running, the command passes the side-effect classifier and the
5// git policy (mode-dependent).
6//
7// The cwd is the session's own working directory mapped into the workspace
8// (repoRoot→txRoot prefix rule), so a session started in a subdirectory
9// behaves as the model expects. A session cwd outside the repo cannot be
10// virtualized; the command then runs from the workspace root.
11//
12// SECURITY: self-execution bypasses the engine's Bash permission gate
13// and sandbox; this hook IS the gate while a transaction is open (see
14// docs/SECURITY.md, M6).
15
16import { ctx, activeKey, modeKey } from '../tx/state.ts'
17import type { SideEffectEvent } from '../tx/state.ts'
18import { virtualize } from '../tx/paths.ts'
19import { classifyGit } from '../tx/git-policy.ts'
20import { classifySideEffect, SIDE_EFFECT_WARNING } from './side-effects.ts'
21
22async function record($: any, event: SideEffectEvent): Promise<void> {
23 if (ctx.tx) {
24 const saved = await $.store.get(activeKey(ctx.tx.repoRoot))
25 if (saved?.transactionId !== ctx.tx.transactionId || saved?.state !== 'ACTIVE') return
26 ctx.tx.sideEffectEvents.push(event)
27 await $.store.set(activeKey(ctx.tx.repoRoot), ctx.tx)
28 }
29}
30
31export async function onBashCall($: any, e: any, next: any): Promise<unknown> {
32 // The blocked check comes first: a blocked session has ctx.tx === null,
33 // and a command let through there would run against the REAL tree.
34 if (ctx.blockedByOtherSession) {
35 return {
36 deny: `airlock: ${ctx.blockedByOtherSession} holds the open transaction for this repository. Review or resolve it with /airlock-diff, /airlock-accept, or /airlock-abort before retrying. Command refused.`,
37 }
38 }
39 if (ctx.isolationFailed) {
40 return { deny: 'airlock: no transaction workspace (fail-safe); command refused.' }
41 }
42 if (!ctx.tx) return next(e)
43 let savedActive: any
44 try { savedActive = await $.store.get(activeKey(ctx.tx.repoRoot)) } catch { savedActive = null }
45 if (ctx.tx.state !== 'ACTIVE' || savedActive?.transactionId !== ctx.tx.transactionId || savedActive?.state !== 'ACTIVE') {
46 return { deny: 'airlock: this transaction is awaiting review or is no longer active. Resolve it before running another command.' }
47 }
48 if (e.run_in_background) {
49 return {
50 deny: 'airlock: background commands cannot be routed into the transaction workspace yet — the engine\'s background machinery is not mod-accessible. Re-run the command in the foreground (a timeout up to 600000 ms is supported).',
51 }
52 }
53
54 const savedMode = await $.store.get(modeKey(ctx.tx.repoRoot))
55 if (savedMode === 'strict' || savedMode === 'balanced' || savedMode === 'permissive') ctx.options.mode = savedMode
56 const mode = ctx.options.mode
57 const side = classifySideEffect(e.command)
58 const git = classifyGit(e.command)
59
60 if (side.matched || git === 'remote') {
61 const what = side.matched ? side.pattern : 'remote git operation'
62 const why = side.matched ? side.reason : 'remote git operations touch the shared repository'
63 let confirmed = false
64 let fallback = ''
65 if (mode === 'balanced') {
66 if (!ctx.isInteractive) {
67 fallback = ' Confirmation is unavailable in headless mode; run this command in an interactive session.'
68 } else {
69 try {
70 confirmed = await $.ui.ask(
71 `airlock: ${what}: ${why}. Command: ${e.command}. ${SIDE_EFFECT_WARNING} Allow this command once?`,
72 ['Deny', 'Allow once'],
73 ) === 'Allow once'
74 } catch {
75 fallback = ' Confirmation was dismissed or unavailable; the command was not run.'
76 }
77 }
78 }
79 if (mode === 'strict' || (mode === 'balanced' && !confirmed)) {
80 await record($, { at: Date.now(), command: e.command, pattern: what, reason: why, action: 'denied' })
81 return {
82 deny: `airlock (mode: ${mode}): refused «${e.command.slice(0, 200)}» — ${what}: ${why}. ${SIDE_EFFECT_WARNING}${fallback}`,
83 }
84 }
85 await record($, { at: Date.now(), command: e.command, pattern: what, reason: why, action: 'recorded' })
86 } else if (git === 'mutating') {
87 if (mode !== 'permissive') {
88 return {
89 deny: `airlock (mode: ${mode}): git commands that change repository topology are denied inside a transaction (plan §15). Read-only git (status, diff, log…) is fine; the transaction diff is produced by the mod itself.`,
90 }
91 }
92 await record($, {
93 at: Date.now(), command: e.command, pattern: 'git-topology', reason: 'mutating git allowed in permissive mode', action: 'recorded',
94 })
95 }
96
97 const t = ctx.tx
98 const latest = await $.store.get(activeKey(t.repoRoot))
99 if (latest?.transactionId !== t.transactionId || latest?.state !== 'ACTIVE') return { deny: 'airlock: the transaction was resolved while the command was pending; command refused.' }
100 t.bashCalls++
101
102 // Session-cwd virtualization: map the session's cwd into the workspace.
103 let sessionCwd = ''
104 try {
105 sessionCwd = await $.session.cwd()
106 } catch {
107 sessionCwd = ''
108 }
109 const mapped = sessionCwd !== '' ? virtualize(sessionCwd, t.repoRoot, t.txRoot) : sessionCwd
110 const cwd = mapped === sessionCwd ? t.txRoot : mapped
111
112 let ran: { exitCode: number | null; stdout: string; stderr: string }
113 try {
114 ran = await $.process.run(['bash', '-c', e.command], {
115 cwd,
116 timeoutMs: Math.min(e.timeout ?? 120000, 600000),
117 })
118 } catch (err) {
119 return {
120 result: { stdout: '', stderr: `airlock: command could not run (${String(err)})`, interrupted: false },
121 isError: true,
122 }
123 }
124 const exitCode = ran.exitCode ?? -1
125 const stderr = exitCode !== 0 ? `${ran.stderr}\n(exit code ${exitCode})` : ran.stderr
126 const result = { stdout: ran.stdout, stderr, interrupted: false }
127 if (exitCode !== 0) return { result, isError: true }
128 return { result }
129}
130hooks/tools/escape.ts 33 lines1// Containment (plan §21 'Agents'): while a transaction is open, tools that
2// would move work outside the workspace are denied. A plain subagent is
3// fine — its own tool calls flow through these same hooks — but an
4// isolated subagent (its own worktree or a remote environment) would edit
5// where the rewrites cannot follow, and a session worktree move (Enter/
6// ExitWorktree) would leave the transaction or destroy its tracking.
7
8import { ctx } from '../tx/state.ts'
9
10export function onAgentCall(_$: any, e: any, next: any): unknown {
11 if (!ctx.tx) return next(e)
12 if (e.isolation === 'worktree' || e.isolation === 'remote') {
13 return {
14 deny: 'airlock: an isolated subagent (its own worktree or a remote environment) would leave the transaction, where its edits cannot be captured. Spawn the agent without isolation — its tool calls are routed into the transaction anyway.',
15 }
16 }
17 return next(e)
18}
19
20export function onEnterWorktreeCall(_$: any, _e: any, next: any): unknown {
21 if (!ctx.tx) return next(e)
22 return {
23 deny: 'airlock: entering a worktree would move the session out of the open transaction. Accept or reject it first (/airlock-accept, /airlock-reject).',
24 }
25}
26
27export function onExitWorktreeCall(_$: any, _e: any, next: any): unknown {
28 if (!ctx.tx) return next(e)
29 return {
30 deny: 'airlock: exiting a worktree would break the open transaction\'s tracking. Accept or reject it first (/airlock-accept, /airlock-reject).',
31 }
32}
33hooks/ui/review.tsx 30 lines1// Review pane and prompt framing for Airlock (M4).
2// Every engine `$` call stays in this module, where the hook receives it.
3
4import { ctx, activeKey } from '../tx/state.ts'
5
6export const REVIEW_PANE = 'airlock-review'
7
8
9const WORKTREE_NOTE =
10 'Airlock is using a Git worktree for this turn. Supported file-tool paths inside the repository and foreground Bash are routed to that worktree; paths outside the repository pass through unchanged, and tool output may show the worktree path. Search tools are not virtualized and may see the real tree. MCP tools are not routed through Airlock, so their effects are outside its guarantees. Background Bash is unavailable during a transaction. Accept applies the reviewed patch after conflict checks; reject discards the transaction.'
11
12async function storedTransaction($: any): Promise<{ transactionId?: string } | null> {
13 if (!ctx.repoRoot) return null
14 const value = await $.store.get(activeKey(ctx.repoRoot))
15 return value && typeof value === 'object' ? value as { transactionId?: string } : null
16}
17
18export async function onAirlockPromptSection($: any, e: { name: string; text: string | null }, next: any): Promise<{ text: string | null }> {
19 const base = await next(e)
20 if (e.name !== 'env_info_simple') return base
21 if (ctx.blockedByOtherSession) {
22 const note = `Airlock found an open transaction awaiting resolution (${ctx.blockedByOtherSession}). This session's edits and Bash mutations are blocked until it is resolved with /airlock-accept or /airlock-abort in the owning session.`
23 return { text: base.text ? `${base.text.trim()}\n\n${note}` : note }
24 }
25 if (!(await storedTransaction($))) return base
26 if (base.text?.includes('Airlock is using a Git worktree for this turn.')) return base
27 const existing = base.text?.trim() ?? ''
28 return { text: existing === '' ? WORKTREE_NOTE : `${existing}\n\n${WORKTREE_NOTE}` }
29}
30hooks/tx/workspace.ts 32 lines1// Workspace naming and safety guards (pure; the $.process calls that
2// create/destroy worktrees live in the hook files that need them).
3//
4// The transaction workspace is a detached git worktree under
5// ~/.claude-airlock/ — it shares the repo's object store but has
6// its own index, so staging inside it never touches the real repository.
7// Never mutate the user's branch/ref to create one (plan §5, §15, §23).
8
9export const TX_DIR_NAME = '.claude-airlock'
10
11export function transactionsRoot(home: string): string {
12 const h = home.endsWith('/') ? home.slice(0, -1) : home
13 return `${h}/${TX_DIR_NAME}`
14}
15
16export function txidFor(turnId: string): string {
17 return `${turnId.slice(0, 8)}-${Date.now().toString(36)}`
18}
19
20export function txRootFor(home: string, repoRoot: string, txid: string): string {
21 const base = repoRoot.split('/').filter(Boolean).pop() ?? 'repo'
22 return `${transactionsRoot(home)}/${base}-${txid}`
23}
24
25// A path we may safely destroy must be one we created: directly inside
26// our own transactions root and nowhere deeper or elsewhere (plan §22).
27export function isOurTxRoot(home: string, txRoot: string): boolean {
28 const root = transactionsRoot(home)
29 const leaf = txRoot.slice(root.length + 1)
30 return txRoot.startsWith(root + '/') && leaf !== '' && leaf !== '.' && leaf !== '..' && !leaf.includes('/')
31}
32hooks/tx/patch.ts 110 lines1// Patch math (pure; the $.process calls that generate/apply patches live
2// in the hook files that need them).
3
4// `git diff HEAD --numstat` lines: "<ins>\t<del>\t<path>" (binary files
5// show "-\t-\t<path>").
6export function parseNumstat(stdout: string): { files: number; insertions: number; deletions: number; changedFiles: string[] } {
7 const files: string[] = []
8 let insertions = 0
9 let deletions = 0
10 for (const line of stdout.split('\n')) {
11 if (!line.trim()) continue
12 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
13 if (!m) continue
14 files.push(unquoteGitPath(m[3]))
15 if (m[1] !== '-') insertions += Number(m[1])
16 if (m[2] !== '-') deletions += Number(m[2])
17 }
18 return { files: files.length, insertions, deletions, changedFiles: files }
19}
20
21export interface PatchOutcomeClass {
22 outcome: 'applied' | 'conflict' | 'failed'
23 stderr: string
24}
25
26// `git diff --name-only` output: one path per line; git quotes a path
27// whose name needs it (spaces, non-ASCII under core.quotePath) in a
28// C-style double-quoted form with octal escapes. Binary-safe callers
29// should use -z in production; this parser keeps the common cases exact.
30export function parseNameOnly(stdout: string): string[] {
31 if (stdout.includes('\0')) return stdout.split('\0').filter(p => p !== '')
32 const out: string[] = []
33 for (const line of stdout.split('\n')) {
34 if (line === '') continue
35 out.push(unquoteGitPath(line))
36 }
37 return out
38}
39
40// Unquote git's C-style quoted path form: "..." with \" \\ \n \t and
41// \nnn octal escapes; octal runs are UTF-8 byte sequences.
42export function unquoteGitPath(path: string): string {
43 if (path.length < 2 || !path.startsWith('"') || !path.endsWith('"')) return path
44 let out = ''
45 let bytes: number[] = []
46 const flush = () => {
47 if (bytes.length) {
48 try {
49 out += new TextDecoder().decode(new Uint8Array(bytes))
50 } catch {
51 out += bytes.map(b => String.fromCharCode(b)).join('')
52 }
53 bytes = []
54 }
55 }
56 for (let i = 1; i < path.length - 1; i++) {
57 const c = path[i]
58 if (c !== '\\') {
59 flush()
60 out += c
61 continue
62 }
63 const n = path[i + 1]
64 if (n === undefined) break
65 if (n === '"' || n === '\\') {
66 flush()
67 out += n
68 i++
69 } else if ('abfrv'.includes(n ?? '')) {
70 flush()
71 const controls: Record<string, string> = { a: '\x07', b: '\b', f: '\f', r: '\r', v: '\v' }
72 out += controls[n]
73 i++
74 } else if (n === 'n') {
75 flush()
76 out += '\n'
77 i++
78 } else if (n === 't') {
79 flush()
80 out += '\t'
81 i++
82 } else if (n >= '0' && n <= '7') {
83 let oct = ''
84 let j = i + 1
85 while (j < path.length - 1 && oct.length < 3 && path[j] >= '0' && path[j] <= '7') {
86 oct += path[j]
87 j++
88 }
89 if (oct) {
90 bytes.push(parseInt(oct, 8))
91 i = j - 1
92 } else {
93 flush()
94 out += '\\'
95 }
96 } else {
97 flush()
98 out += '\\'
99 }
100 }
101 flush()
102 return out
103}
104
105// Parent directory of a repo-relative path ('' when the path is at the root).
106export function parentDirOf(path: string): string {
107 const i = path.lastIndexOf('/')
108 return i === -1 ? '' : path.slice(0, i)
109}
110hooks/tx/paths.ts 43 lines1// Pure lexical path mapping. tools/rewrite.ts adds filesystem snapshot checks.
2
3// Resolve '.', '..' and duplicate '/' lexically. Keeps a leading '/'.
4export function normalize(p: string): string {
5 const out: string[] = []
6 for (const seg of p.split('/')) {
7 if (seg === '' || seg === '.') continue
8 if (seg === '..') {
9 if (out.length > 0 && out[out.length - 1] !== '..') out.pop()
10 else if (!p.startsWith('/')) out.push('..')
11 // '..' above the root of an absolute path stays at the root
12 continue
13 }
14 out.push(seg)
15 }
16 const joined = out.join('/')
17 return p.startsWith('/') ? '/' + joined : joined
18}
19
20function stripTrailingSlash(root: string): string {
21 return root.length > 1 && root.endsWith('/') ? root.slice(0, -1) : root
22}
23
24// True when p is root itself or inside root, after lexical normalization.
25export function contains(root: string, p: string): boolean {
26 const r = stripTrailingSlash(normalize(root))
27 const n = normalize(p)
28 if (n === r) return true
29 return n.startsWith(r + '/')
30}
31
32// Map a real repo path onto the transaction worktree; paths outside the
33// repo (and paths already inside the worktree) pass through unchanged.
34export function virtualize(path: string, repoRoot: string, txRoot: string): string {
35 const root = stripTrailingSlash(normalize(repoRoot))
36 const tx = stripTrailingSlash(normalize(txRoot))
37 if (contains(tx, path)) return path
38 if (!contains(root, path)) return path
39 const n = normalize(path)
40 if (n === root) return tx
41 return tx + n.slice(root.length)
42}
43