SLOPSHOPPER

airlock

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-05BillyMRX1/airlock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · airlock
│ ┃ airlock-review ✕ › fix the failing auth test and add an audit log call │ ┃ Airlock has no open transaction for this │ ┃ repository. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /airlock-status │ ⎿ airlock: airlock: no open transaction. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · airlock-review
Airlock has no open transaction for this repository.
README

Airlock — a Claude Code mod

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.

Requirements

  • Claude Code 2.1.289 (tested on macOS arm64).
  • Git available on PATH.
  • A project opened from an existing Git repository with a valid HEAD commit. Airlock does not initialize repositories or create commits in the real project.
  • Host tools used by Airlock, including bash, cp, mkdir, and rm.

One-time setup

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.

Everyday use

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.

Updating

claude plugin marketplace update airlock-marketplace
claude plugin update airlock@airlock-marketplace

Restart Claude after updating.

Load a source checkout instead

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.

Local review archive

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.

Try one transaction

  1. Start Claude Code in an existing Git repository using the command above.
  2. Ask Claude to make a small, reviewable code change.
  3. When the turn finishes, inspect the Airlock review pane. If it is hidden, run /airlock-review to open it, or /airlock-diff for text output; /airlock-status shows the transaction state and worktree.
  4. Run /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.

Small demo: accept, then 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.

Commands

CommandPurpose
/airlock-statusShow the open transaction, its state, paths, change counts, and routed activity.
/airlock-diffShow the current transaction diff (output is capped at 8,000 characters).
/airlock-reviewOpen and focus the review pane. Finish ACTIVE work with /airlock-begin end first; headless sessions use /airlock-diff.
/airlock-acceptApply a transaction in review after conflict and apply checks.
/airlock-rejectDiscard the open transaction; it does not apply a patch.
/airlock-abortDiscard an open transaction before review, or clear a pending multi-turn setting.
/airlock-cleanupReport stale Airlock workspaces; /airlock-cleanup purge removes eligible stale workspaces.
/airlock-historyList recent finished transactions for the current repository.
/airlock-modeShow the repository's mode; add strict, balanced, or permissive to set it.
/airlock-beginKeep the next coding transaction open across multiple turns. /airlock-begin end stages it for review.
/airlock-rejectedList retained rejected workspaces; add a transaction ID to inspect its saved diff.

Multi-turn work

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.

Configuration

Plugin configuration defaults are:

SettingDefaultMeaning
modestrictDenies 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.
includeUntrackedtrueCopies eligible, non-ignored untracked regular files into the transaction baseline. Ignored untracked files are excluded; symlinks, directories, and files over 4 MiB are skipped.
retainRejectedTransactionsfalseWhen true, keeps rejected or aborted worktrees for later inspection instead of removing them.
maxStoredTransactions50Maximum 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.

Scope and known limitations

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.

Development checks

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.

Development evidence

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.

Security and recovery

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.

License

Airlock is licensed under the MIT License.

Source 14 files
hooks/register.ts 48 lines
1// 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}
48
hooks/tx/state.ts 159 lines
1// 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}
159
hooks/tx/recovery.ts 98 lines
1// 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}
98
hooks/tx/lifecycle.ts 360 lines
1// 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}
360
hooks/tx/commands.tsx 777 lines
1// /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}
777
hooks/tools/rewrite.ts 107 lines
1// 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}
107
hooks/tools/bash.ts 130 lines
1// 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}
130
hooks/tools/escape.ts 33 lines
1// 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}
33
hooks/ui/review.tsx 30 lines
1// 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}
30
hooks/tx/workspace.ts 32 lines
1// 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}
32
hooks/tx/patch.ts 110 lines
1// 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}
110
hooks/tx/paths.ts 43 lines
1// 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