diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0db1156e..3c8c5a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,7 +334,7 @@ jobs: xcodebuild -project GhosttyTabs.xcodeproj \ -target programa-cli \ -configuration Debug \ - CONFIGURATION_BUILD_DIR="$CLI_REGRESSION_DIR" \ + SYMROOT="$CLI_REGRESSION_DIR" \ CODE_SIGNING_ALLOWED=NO \ build @@ -342,7 +342,7 @@ jobs: run: | set -euo pipefail - CLI_BIN="$RUNNER_TEMP/programa-cli-regression/programa" + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then echo "programa CLI binary not found in DerivedData" >&2 exit 1 @@ -354,7 +354,7 @@ jobs: run: | set -euo pipefail - CLI_BIN="$RUNNER_TEMP/programa-cli-regression/programa" + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then echo "programa CLI binary not found in DerivedData" >&2 exit 1 @@ -362,6 +362,18 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_argument_grammar.py + - name: Run Codex hook trust regression + run: | + set -euo pipefail + + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" + if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then + echo "programa CLI binary not found in DerivedData" >&2 + exit 1 + fi + + PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_hook_trust.py + - name: Run unit tests env: PROGRAMA_UNIT_TEST_SCOPE: split-stateful diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 9e343205..2749f80f 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1,6 +1,7 @@ import Foundation import CryptoKit import Darwin +import TOML #if canImport(LocalAuthentication) import LocalAuthentication #endif @@ -1365,282 +1366,1287 @@ extension ProgramaCLI { // MARK: - Codex hooks - /// The hooks.json content that programa installs into ~/.codex/. - /// Each hook calls `programa codex-hook ` which gracefully no-ops - /// when not running inside programa. The command checks for programa on PATH - /// first so it silently succeeds even when programa is not installed - /// (e.g. user opened codex in a non-programa terminal). - private static func codexHookCommand(_ event: String) -> String { - "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa codex-hook \(event) || echo '{}'" + private static let codexMaximumFileBytes = 16 * 1024 * 1024 + private static let codexTrustBlockStart = "# >>> programa managed codex hook trust v1 >>>" + private static let codexTrustBlockEnd = "# <<< programa managed codex hook trust v1 <<<" + private static let codexLegacyTrustBlockStart = "# >>> programa codex hook trust >>>" + private static let codexLegacyTrustBlockEnd = "# <<< programa codex hook trust <<<" + + private struct CodexHookSpec { + let event: String + let label: String + let commandEvent: String + let timeout: Int } - private static let codexHooksJSON: [String: Any] = [ - "hooks": [ - "SessionStart": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("session-start"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "UserPromptSubmit": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("prompt-submit"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "Stop": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("stop"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "Notification": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("notification"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "SessionEnd": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("session-end"), - "timeout": 1 - ] as [String: Any]] - ] as [String: Any]] - ] as [String: Any] + private static let codexHookSpecs = [ + CodexHookSpec(event: "SessionStart", label: "session_start", commandEvent: "session-start", timeout: 10), + CodexHookSpec(event: "UserPromptSubmit", label: "user_prompt_submit", commandEvent: "prompt-submit", timeout: 10), + CodexHookSpec(event: "Stop", label: "stop", commandEvent: "stop", timeout: 10), + CodexHookSpec(event: "PermissionRequest", label: "permission_request", commandEvent: "notification", timeout: 10), + CodexHookSpec(event: "SessionEnd", label: "session_end", commandEvent: "session-end", timeout: 1), ] + private static let codexOwnedCommandEventByHookEvent: [String: String] = { + var result = Dictionary(uniqueKeysWithValues: codexHookSpecs.map { ($0.event, $0.commandEvent) }) + result["Notification"] = "notification" + return result + }() - /// Identifier used to detect programa-owned hooks during uninstall. - private static let codexHookCommandMarker = "programa codex-hook" + private struct CodexFileSnapshot: Equatable { + let data: Data + let mode: mode_t + } - func runCodexInstallHooks() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] - ?? NSString(string: "~/.codex").expandingTildeInPath - let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") - let configPath = (codexHome as NSString).appendingPathComponent("config.toml") - let fm = FileManager.default + private struct CodexPaths { + let home: String + let lockHome: String + let hooks: String + let configLink: String + let configTarget: String + } - // Ensure ~/.codex/ exists - try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil) + private struct CodexTrustEdit { + let configPath: String + let configLinkPath: String + let hooksKeyPrefix: String + let desired: [String: String] + let removalKeys: Set + let ownedHashes: Set + } - // Read existing state - let existingHooksContent: String? = fm.fileExists(atPath: hooksPath) - ? (try? String(contentsOfFile: hooksPath, encoding: .utf8)) - : nil + private struct CodexPreparedConfig { + let edit: CodexTrustEdit + let snapshot: CodexFileSnapshot? + let rendered: Data + } - // Build merged hooks - var existing: [String: Any] = [:] - if let existingHooksContent, - let data = existingHooksContent.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - existing = parsed - } + private struct CodexTOMLAssignment { + let path: [String] + let lineRange: Range + let valueRange: Range? + let hasInlineComment: Bool + } - var hooks = existing["hooks"] as? [String: Any] ?? [:] - let programaHooks = Self.codexHooksJSON["hooks"] as! [String: Any] - for (eventName, programaGroups) in programaHooks { - guard let programaGroupArray = programaGroups as? [[String: Any]] else { continue } - var eventGroups = hooks[eventName] as? [[String: Any]] ?? [] - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true + private struct CodexTOMLSection { + let path: [String] + let headerRange: Range + let bodyRange: Range + let fullRange: Range + let hasInlineComment: Bool + } + + private struct CodexTOMLSourceMap { + let assignments: [CodexTOMLAssignment] + let sections: [CodexTOMLSection] + } + + private struct CodexSourceSplice { + let range: Range + let replacement: String + } + + private enum CodexTOMLStringMode { + case none + case basic + case literal + case multilineBasic + case multilineLiteral + } + + private struct CodexTOMLLine { + let range: Range + let contentRange: Range + let text: String + let startsInString: Bool + } + + private indirect enum CodexTOMLValue: Decodable, Equatable { + case string(String) + case integer(Int64) + case float(Double) + case boolean(Bool) + case offsetDateTime(Date) + case localDateTime(LocalDateTime) + case localDate(LocalDate) + case localTime(LocalTime) + case array([CodexTOMLValue]) + case table([String: CodexTOMLValue]) + case null + + private struct Key: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } + } + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: Key.self) { + var result: [String: CodexTOMLValue] = [:] + for key in container.allKeys { + result[key.stringValue] = try container.decode(CodexTOMLValue.self, forKey: key) } + self = .table(result) + return } - eventGroups.append(contentsOf: programaGroupArray) - hooks[eventName] = eventGroups + if var container = try? decoder.unkeyedContainer() { + var result: [CodexTOMLValue] = [] + while !container.isAtEnd { + result.append(try container.decode(CodexTOMLValue.self)) + } + self = .array(result) + return + } + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null; return } + if let value = try? container.decode(Bool.self) { self = .boolean(value); return } + if let value = try? container.decode(Int64.self) { self = .integer(value); return } + if let value = try? container.decode(Double.self) { self = .float(value); return } + if let value = try? container.decode(String.self) { self = .string(value); return } + if let value = try? container.decode(Date.self) { self = .offsetDateTime(value); return } + if let value = try? container.decode(LocalDateTime.self) { self = .localDateTime(value); return } + if let value = try? container.decode(LocalDate.self) { self = .localDate(value); return } + if let value = try? container.decode(LocalTime.self) { self = .localTime(value); return } + throw DecodingError.typeMismatch( + CodexTOMLValue.self, + .init(codingPath: decoder.codingPath, debugDescription: "Unsupported TOML value") + ) } - existing["hooks"] = hooks - let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) - let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" - - // Build new config.toml content - let existingConfigContent: String = fm.fileExists(atPath: configPath) - ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") - : "" - let newConfigContent = buildConfigWithCodexHooks(existingConfigContent) - - // Check if anything would change - let hooksChanged = existingHooksContent != newHooksContent - let configChanged = existingConfigContent != newConfigContent - - // Also install the `programa` agent skill into $HOME/.agents/skills — - // the user-level location Codex (and OpenCode) scan for skills — so a - // fresh Codex session inside programa knows it can drive the app. - // This is $HOME-relative, not $CODEX_HOME-relative: it's the shared - // cross-tool ".agents/skills" convention, not a Codex-specific path. - // Refs #165. - let skillPath = Self.agentSkillFilePath( - skillsRoot: NSString(string: "~/.agents/skills").expandingTildeInPath - ) - let skillState = agentSkillInstallState(path: skillPath) - if !hooksChanged && !configChanged && !skillState.changed { - print("programa hooks are already installed. Nothing to change.") - return + var tableValue: [String: CodexTOMLValue]? { + guard case .table(let value) = self else { return nil } + return value + } + + var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value } + } + + private struct CodexHooksError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + /// Each installed hook gracefully no-ops outside a programa surface. + private static func codexHookCommand(_ event: String) -> String { + "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa codex-hook \(event) || echo '{}'" + } + + func runCodexInstallHooks() throws { + try runCodexHooksMutation(install: true) + } + + func runCodexUninstallHooks() throws { + try runCodexHooksMutation(install: false) + } - // Show diff and ask for confirmation - if hooksChanged { - print(" \(hooksPath):") - if let existingHooksContent { - printSimpleDiff(old: existingHooksContent, new: newHooksContent) + private func runCodexHooksMutation(install: Bool) throws { + let paths = try codexPaths() + try withCodexHooksLock(at: paths.lockHome) { + let hooksSnapshot = try codexReadRegularFile(paths.hooks, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + let previousRoot = try codexHooksRoot(from: hooksSnapshot?.data, path: paths.hooks) + let nextRoot = try codexRewriteHooks(previousRoot, install: install) + let nextHooksData: Data? = install || hooksSnapshot != nil ? try codexEncodeHooks(nextRoot) : nil + let hooksChanged: Bool + if install { + hooksChanged = hooksSnapshot?.data != nextHooksData + } else if hooksSnapshot != nil { + hooksChanged = try codexEncodeHooks(previousRoot) != nextHooksData } else { - print(" (new file)") - let lines = newHooksContent.components(separatedBy: "\n") - for (i, line) in lines.enumerated() { - let lineLabel = String(format: "%3d", i + 1) - print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") + hooksChanged = false + } + + let hooksKeyPath = paths.hooks + let desired = install ? try codexExpectedTrustEntries(hooksPath: hooksKeyPath, root: nextRoot) : [:] + let edit = CodexTrustEdit( + configPath: paths.configTarget, + configLinkPath: paths.configLink, + hooksKeyPrefix: hooksKeyPath + ":", + desired: desired, + removalKeys: codexOwnedEntryKeys(hooksPath: hooksKeyPath, root: previousRoot), + ownedHashes: try codexOwnedTrustHashes() + ) + let preparedConfig = try codexPrepareConfig(edit) + let existingConfig = preparedConfig.snapshot.flatMap { String(data: $0.data, encoding: .utf8) } ?? "" + let renderedConfig = String(data: preparedConfig.rendered, encoding: .utf8) ?? "" + let configChanged = (preparedConfig.snapshot?.data ?? Data()) != preparedConfig.rendered + + let skillPath = Self.agentSkillFilePath( + skillsRoot: NSString(string: "~/.agents/skills").expandingTildeInPath + ) + let skillInstall = install ? agentSkillInstallState(path: skillPath) : nil + let skillUninstall = install ? nil : agentSkillUninstallState(path: skillPath) + let skillChanged = skillInstall?.changed == true || skillUninstall != nil + + if !hooksChanged && !configChanged && !skillChanged { + print(install ? "programa hooks are already installed. Nothing to change." : "No programa hooks found.") + return + } + + if hooksChanged { + print(" \(paths.hooks):") + if let old = hooksSnapshot.flatMap({ String(data: $0.data, encoding: .utf8) }), let nextHooksData { + printSimpleDiff(old: old, new: String(data: nextHooksData, encoding: .utf8) ?? "") + } else { + print(" (new file)") } + print("") + } + if configChanged { + print(" \(paths.configLink):") + if existingConfig.isEmpty { print(" (new file)") } + printSimpleDiff(old: existingConfig, new: renderedConfig) + print("") } + if let skillInstall, skillInstall.changed { + printAgentSkillDiff(path: skillPath, existing: skillInstall.existing) + } else if let skillUninstall { + printAgentSkillRemovalDiff(path: skillPath, content: skillUninstall) + } + + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + if !skipConfirm { + print("Apply these changes? [Y/n] ", terminator: "") + if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !response.isEmpty && response != "y" && response != "yes" { + print("Aborted.") + return + } + } + + let freshHooks = try codexReadRegularFile(paths.hooks, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + guard freshHooks == hooksSnapshot else { + throw CodexHooksError(message: "\(paths.hooks) changed while awaiting confirmation; retry the command") + } + if hooksChanged { + if let nextHooksData { + try codexAtomicWrite(nextHooksData, to: paths.hooks, mode: hooksSnapshot?.mode ?? 0o600) + } else { + try codexRestoreFile(nil, at: paths.hooks) + } + } + do { + try codexCommitConfig(preparedConfig) + } catch { + guard hooksChanged else { throw error } + do { + try codexRestoreFile(hooksSnapshot, at: paths.hooks) + } catch let rollbackError { + throw CodexHooksError(message: "Codex config update failed: \(error.localizedDescription); restoring hooks.json also failed: \(rollbackError.localizedDescription)") + } + throw error + } + + // The shared skill is deliberately last: hooks.json and trust state + // must commit together before any adjacent integration is changed. + if install, skillInstall?.changed == true { + try writeAgentSkillFile(path: skillPath) + } else if !install, skillUninstall != nil { + try removeAgentSkillFileIfManaged(path: skillPath) + } + print("") + if install { + print("Installed. Codex trusts programa hooks inside programa; they silently no-op elsewhere.") + print("To remove: programa codex uninstall-hooks") + } else { + print("Removed programa Codex hooks and their trust entries.") + } } + } - if configChanged { - print(" \(configPath):") - if existingConfigContent.isEmpty { - print(" (new file)") - let lines = newConfigContent.components(separatedBy: "\n") - for (i, line) in lines.enumerated() where !line.isEmpty { - let lineLabel = String(format: "%3d", i + 1) - print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") + private func codexPaths() throws -> CodexPaths { + let environment = ProcessInfo.processInfo.environment + let override = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicit = override?.isEmpty == false + let rawHome = explicit ? override! : NSString(string: "~/.codex").expandingTildeInPath + let expanded = NSString(string: rawHome).expandingTildeInPath + let absolute = expanded.hasPrefix("/") + ? URL(fileURLWithPath: expanded).standardizedFileURL.path + : URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(expanded).standardizedFileURL.path + try FileManager.default.createDirectory(atPath: absolute, withIntermediateDirectories: true, attributes: nil) + guard let resolvedHome = absolute.withCString({ realpath($0, nil) }) else { + throw codexPOSIXError("resolve", path: absolute) + } + defer { free(resolvedHome) } + let canonicalHome = String(cString: resolvedHome) + let home = explicit ? canonicalHome : absolute + let hooks = (home as NSString).appendingPathComponent("hooks.json") + let configLink = (home as NSString).appendingPathComponent("config.toml") + let configTarget = try codexResolveConfigTarget(configLink) + return CodexPaths(home: home, lockHome: canonicalHome, hooks: hooks, configLink: configLink, configTarget: configTarget) + } + + private func codexResolveConfigTarget(_ path: String) throws -> String { + var info = stat() + let result = path.withCString { Darwin.lstat($0, &info) } + if result != 0 { + if errno == ENOENT { return path } + throw codexPOSIXError("inspect", path: path) + } + guard (info.st_mode & S_IFMT) == S_IFLNK else { return path } + guard let resolved = path.withCString({ realpath($0, nil) }) else { + throw CodexHooksError(message: "\(path) is a dangling symlink; refusing to replace it") + } + defer { free(resolved) } + return String(cString: resolved) + } + + private func withCodexHooksLock(at home: String, _ body: () throws -> T) throws -> T { + let path = (home as NSString).appendingPathComponent(".programa-hooks.lock") + let descriptor = path.withCString { Darwin.open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0o600) } + guard descriptor >= 0 else { throw codexPOSIXError("open lock", path: path) } + defer { Darwin.close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "\(path) is not a regular lock file") + } + guard flock(descriptor, LOCK_EX) == 0 else { throw codexPOSIXError("lock", path: path) } + defer { flock(descriptor, LOCK_UN) } + return try body() + } + + private func codexReadRegularFile(_ path: String, limit: Int, refuseSymlink: Bool) throws -> CodexFileSnapshot? { + if refuseSymlink { + var linkInfo = stat() + let result = path.withCString { Darwin.lstat($0, &linkInfo) } + if result == 0 { + if (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw CodexHooksError(message: "refusing to read symlink \(path)") } - } else { - printSimpleDiff(old: existingConfigContent, new: newConfigContent) + } else if errno != ENOENT { + throw codexPOSIXError("inspect", path: path) } - print("") } + let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + if errno == ENOENT { return nil } + throw codexPOSIXError("open", path: path) + } + defer { Darwin.close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw codexPOSIXError("inspect", path: path) } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "\(path) is not a regular file; refusing to read it") + } + guard info.st_size >= 0, info.st_size <= Int64(limit) else { + throw CodexHooksError(message: "\(path) exceeds 16 MiB") + } + var data = Data() + data.reserveCapacity(Int(info.st_size)) + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count == 0 { break } + if count < 0 { + if errno == EINTR { continue } + throw codexPOSIXError("read", path: path) + } + data.append(buffer, count: count) + guard data.count <= limit else { throw CodexHooksError(message: "\(path) exceeds 16 MiB") } + } + return CodexFileSnapshot(data: data, mode: info.st_mode & 0o777) + } - if skillState.changed { - printAgentSkillDiff(path: skillPath, existing: skillState.existing) + private func codexHooksRoot(from data: Data?, path: String) throws -> [String: Any] { + guard let data else { return [:] } + let value: Any + do { value = try JSONSerialization.jsonObject(with: data) } + catch { throw CodexHooksError(message: "\(path) is not valid JSON: \(error.localizedDescription)") } + guard let root = value as? [String: Any] else { + throw CodexHooksError(message: "\(path) must contain a JSON object") } + return root + } - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !response.isEmpty && response != "y" && response != "yes" { - print("Aborted.") - return + private func codexRewriteHooks(_ source: [String: Any], install: Bool) throws -> [String: Any] { + var root = source + let rawHooks = root["hooks"] + guard rawHooks == nil || rawHooks is [String: Any] else { + throw CodexHooksError(message: "hooks.json hooks must be an object") + } + if rawHooks == nil, !install { return root } + var hooks = rawHooks as? [String: Any] ?? [:] + for event in Array(hooks.keys) { + guard let rawGroups = hooks[event] as? [Any] else { + throw CodexHooksError(message: "hooks.json event \(event) must be an array") } + var rewritten: [[String: Any]] = [] + for (groupIndex, rawGroup) in rawGroups.enumerated() { + guard var group = rawGroup as? [String: Any] else { + throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex) must be an object") + } + guard let rawHandlers = group["hooks"] as? [Any] else { + throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") + } + var handlers: [[String: Any]] = [] + for (handlerIndex, rawHandler) in rawHandlers.enumerated() { + guard let handler = rawHandler as? [String: Any] else { + throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") + } + if let command = handler["command"], !(command is String) { + throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") + } + if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) } + } + if !handlers.isEmpty { + group["hooks"] = handlers + rewritten.append(group) + } + } + if rewritten.isEmpty { hooks.removeValue(forKey: event) } + else { hooks[event] = rewritten } } + if install { + for spec in Self.codexHookSpecs { + var groups = hooks[spec.event] as? [[String: Any]] ?? [] + groups.append(["hooks": [[ + "type": "command", + "command": Self.codexHookCommand(spec.commandEvent), + "timeout": spec.timeout, + ] as [String: Any]]]) + hooks[spec.event] = groups + } + } + root["hooks"] = hooks + try codexValidateForeignHandlerPositions(before: source, after: root) + return root + } - // Apply changes - if hooksChanged { - try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) + private func codexValidateForeignHandlerPositions( + before: [String: Any], + after: [String: Any] + ) throws { + for spec in Self.codexHookSpecs { + let oldPositions = codexForeignHandlerPositions(root: before, event: spec.event) + let newPositions = codexForeignHandlerPositions(root: after, event: spec.event) + guard oldPositions.count == newPositions.count else { + throw CodexHooksError(message: "rewriting \(spec.event) would change the foreign handler set; refusing to reuse Codex trust positions") + } + for (index, pair) in zip(oldPositions, newPositions).enumerated() where pair.0 != pair.1 { + throw CodexHooksError( + message: "rewriting \(spec.event) would move foreign handler \(index) from \(pair.0.0):\(pair.0.1) to \(pair.1.0):\(pair.1.1); refusing to reuse Codex trust positions" + ) + } } - if configChanged { - try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) + } + + private func codexForeignHandlerPositions(root: [String: Any], event: String) -> [(Int, Int)] { + guard let hooks = root["hooks"] as? [String: Any], + let groups = hooks[event] as? [[String: Any]] else { return [] } + var positions: [(Int, Int)] = [] + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where !codexIsOwnedHandler(handler, event: event) { + positions.append((groupIndex, handlerIndex)) + } } - if skillState.changed { - try writeAgentSkillFile(path: skillPath) + return positions + } + + private func codexIsOwnedHandler(_ handler: [String: Any], event: String) -> Bool { + guard let commandEvent = Self.codexOwnedCommandEventByHookEvent[event], + let command = handler["command"] as? String else { return false } + return command == Self.codexHookCommand(commandEvent) + || command == "programa codex-hook \(commandEvent)" + } + + private func codexEncodeHooks(_ root: [String: Any]) throws -> Data { + guard JSONSerialization.isValidJSONObject(root) else { + throw CodexHooksError(message: "merged hooks.json is not serializable") } + var data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + data.append(0x0A) + return data + } - print("") - print("Installed. Hooks activate inside programa and silently no-op elsewhere.") - print("To remove: programa codex uninstall-hooks") + private func codexExpectedTrustEntries(hooksPath: String, root: [String: Any]) throws -> [String: String] { + var result: [String: String] = [:] + for spec in Self.codexHookSpecs { + let entry = try codexOwnedHookEntry(root: root, spec: spec) + let key = "\(hooksPath):\(spec.label):\(entry.groupIndex):\(entry.handlerIndex)" + result[key] = try codexTrustHash(label: spec.label, group: entry.group, handler: entry.handler) + } + return result } - func runCodexUninstallHooks() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] - ?? NSString(string: "~/.codex").expandingTildeInPath - let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") - let configPath = (codexHome as NSString).appendingPathComponent("config.toml") - let fm = FileManager.default + private func codexOwnedHookEntry( + root: [String: Any], + spec: CodexHookSpec + ) throws -> (groupIndex: Int, handlerIndex: Int, group: [String: Any], handler: [String: Any]) { + guard let hooks = root["hooks"] as? [String: Any], + let groups = hooks[spec.event] as? [[String: Any]] else { + throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") + } + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: spec.event) { + return (groupIndex, handlerIndex, group, handler) + } + } + throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") + } - // Hooks removal, computed as an optional so a missing/malformed - // hooks.json doesn't short-circuit the skill-file cleanup below. - var hooksRemoval: (newJsonData: Data, newHooksContent: String, oldHooksContent: String)? - if fm.fileExists(atPath: hooksPath), - let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)), - var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - var hooks = parsed["hooks"] as? [String: Any] { - var removedCount = 0 - for eventName in hooks.keys { - guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue } - let before = eventGroups.count - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true - } + private func codexOwnedEntryKeys(hooksPath: String, root: [String: Any]) -> Set { + guard let hooks = root["hooks"] as? [String: Any] else { return [] } + let labels = Dictionary(uniqueKeysWithValues: Self.codexHookSpecs.map { ($0.event, $0.label) }) + var keys: Set = [] + for (event, rawGroups) in hooks { + guard let label = labels[event], let groups = rawGroups as? [[String: Any]] else { continue } + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: event) { + keys.insert("\(hooksPath):\(label):\(groupIndex):\(handlerIndex)") } - removedCount += before - eventGroups.count - if eventGroups.isEmpty { - hooks.removeValue(forKey: eventName) + } + } + return keys + } + + private func codexOwnedTrustHashes() throws -> Set { + var hashes: Set = [] + for spec in Self.codexHookSpecs { + let group: [String: Any] = [:] + let handler: [String: Any] = [ + "type": "command", + "command": Self.codexHookCommand(spec.commandEvent), + "timeout": spec.timeout, + ] + hashes.insert(try codexTrustHash(label: spec.label, group: group, handler: handler)) + } + return hashes + } + + private func codexTrustHash(label: String, group: [String: Any], handler: [String: Any]) throws -> String { + guard (handler["type"] as? String) == "command", + let command = handler["command"] as? String else { + throw CodexHooksError(message: "Codex hook \(label) must be a command handler") + } + let asynchronous: Bool + if let value = handler["async"] { + guard let value = value as? Bool else { throw CodexHooksError(message: "Codex hook async must be a boolean") } + asynchronous = value + } else { + asynchronous = false + } + var normalized: [String: Any] = [ + "async": asynchronous, + "command": command, + "timeout": try codexNormalizedTimeout(label: label, value: handler["timeout"]), + "type": "command", + ] + if let status = handler["statusMessage"] { + guard let status = status as? String else { throw CodexHooksError(message: "Codex hook statusMessage must be a string") } + normalized["statusMessage"] = status + } + if let rawLimit = handler["additionalContextLimit"] { + let limit = try codexInteger(rawLimit, field: "additionalContextLimit") + if ["pre_tool_use", "post_tool_use", "session_start", "user_prompt_submit", "subagent_start"].contains(label), + limit != 2_500 { + normalized["additionalContextLimit"] = limit + } + } + var identity: [String: Any] = ["event_name": label, "hooks": [normalized]] + if label != "user_prompt_submit", label != "stop", let matcher = group["matcher"] { + guard let matcher = matcher as? String else { throw CodexHooksError(message: "Codex hook matcher must be a string") } + identity["matcher"] = matcher + } + let data = try JSONSerialization.data(withJSONObject: identity, options: [.sortedKeys, .withoutEscapingSlashes]) + return "sha256:" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func codexNormalizedTimeout(label: String, value: Any?) throws -> Int { + let fallback = label == "session_end" ? 1 : 600 + let timeout = value == nil ? fallback : try codexInteger(value!, field: "timeout") + return label == "session_end" ? min(max(timeout, 1), 3) : max(timeout, 1) + } + + private func codexInteger(_ value: Any, field: String) throws -> Int { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + throw CodexHooksError(message: "Codex hook \(field) must be an integer") + } + let double = number.doubleValue + guard double.rounded() == double, double >= Double(Int.min), double <= Double(Int.max) else { + throw CodexHooksError(message: "Codex hook \(field) must be an integer") + } + return number.intValue + } + + private func codexPrepareConfig(_ edit: CodexTrustEdit) throws -> CodexPreparedConfig { + let snapshot = try codexReadRegularFile(edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + let original = try codexUTF8(snapshot?.data ?? Data(), path: edit.configPath) + let rendered = try codexRenderConfig(original, edit: edit) + try codexProbeWritableDirectory((edit.configPath as NSString).deletingLastPathComponent) + return CodexPreparedConfig(edit: edit, snapshot: snapshot, rendered: Data(rendered.utf8)) + } + + private func codexCommitConfig(_ prepared: CodexPreparedConfig) throws { + let resolvedTarget = try codexResolveConfigTarget(prepared.edit.configLinkPath) + guard resolvedTarget == prepared.edit.configPath else { + throw CodexHooksError(message: "\(prepared.edit.configLinkPath) changed targets during installation; retry the command") + } + let current = try codexReadRegularFile(prepared.edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + let rendered: Data + if current == prepared.snapshot { + rendered = prepared.rendered + } else { + let fresh = try codexUTF8(current?.data ?? Data(), path: prepared.edit.configPath) + rendered = Data(try codexRenderConfig(fresh, edit: prepared.edit).utf8) + } + guard (current?.data ?? Data()) != rendered else { return } + try codexAtomicWrite(rendered, to: prepared.edit.configPath, mode: current?.mode ?? 0o600) + } + + private func codexRenderConfig(_ source: String, edit: CodexTrustEdit) throws -> String { + let originalTree = try codexParseTOML(source, path: edit.configPath) + let newline = codexNewline(in: source) + var working = try codexRemovingManagedBlocks(source, edit: edit) + let baseTree = try codexParseTOML(working, path: edit.configPath) + try codexValidateTrustShape(baseTree, path: edit.configPath) + let map = try codexTOMLSourceMap(working) + var splices: [CodexSourceSplice] = [] + + if baseTree.tableValue?["features"]?.tableValue?["codex_hooks"] != nil { + let matches = map.assignments.filter { $0.path == ["features", "codex_hooks"] } + guard matches.count == 1, matches[0].valueRange != nil, !matches[0].hasInlineComment else { + throw CodexHooksError(message: "codex_hooks in \(edit.configPath) uses an inline or multiline form that cannot be edited safely") + } + splices.append(.init(range: matches[0].lineRange, replacement: "")) + } + + let state = baseTree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] + let ownedFromHashes = Set(state.compactMap { key, value -> String? in + guard key.hasPrefix(edit.hooksKeyPrefix), + let hash = value.tableValue?["trusted_hash"]?.stringValue, + edit.ownedHashes.contains(hash) else { return nil } + return key + }) + let removable = edit.removalKeys.union(ownedFromHashes).subtracting(edit.desired.keys) + let statePrefix = ["hooks", "state"] + for key in removable { + guard state[key] != nil else { continue } + let path = statePrefix + [key] + let sections = map.sections.filter { $0.path == path } + if sections.count == 1 { + try codexValidateRemovableTrustSection(sections[0], key: key, map: map, source: working) + splices.append(.init(range: sections[0].fullRange, replacement: "")) + continue + } + let assignments = map.assignments.filter { $0.path.starts(with: path) } + guard assignments.count == 1, + assignments[0].path == path + ["trusted_hash"], + assignments[0].valueRange != nil, + !assignments[0].hasInlineComment else { + throw CodexHooksError(message: "trust entry \(key) in \(edit.configPath) is inline and cannot be removed safely") + } + splices.append(.init(range: assignments[0].lineRange, replacement: "")) + } + + var appendDesired = edit.desired + for (key, hash) in edit.desired where state[key] != nil { + let path = statePrefix + [key] + let sections = map.sections.filter { $0.path == path } + if sections.count == 1 { + let hashAssignments = map.assignments.filter { $0.path == path + ["trusted_hash"] } + if hashAssignments.count == 1, let valueRange = hashAssignments[0].valueRange { + splices.append(.init(range: valueRange, replacement: try codexTOMLQuoted(hash))) + } else if hashAssignments.isEmpty { + splices.append(.init( + range: sections[0].bodyRange.lowerBound.. 0 { - parsed["hooks"] = hooks - let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys]) - let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" - let oldHooksContent = String(data: data, encoding: .utf8) ?? "" - hooksRemoval = (newJsonData, newHooksContent, oldHooksContent) + let dotted = map.assignments.filter { $0.path == path + ["trusted_hash"] } + guard dotted.count == 1, let valueRange = dotted[0].valueRange else { + throw CodexHooksError(message: "trust entry \(key) in \(edit.configPath) is inline and cannot be edited safely") + } + splices.append(.init(range: valueRange, replacement: try codexTOMLQuoted(hash))) + appendDesired.removeValue(forKey: key) + } + + working = try codexApplySplices(splices, to: working) + if !appendDesired.isEmpty { + if let hooks = baseTree.tableValue?["hooks"], hooks.tableValue == nil { + throw CodexHooksError(message: "hooks in \(edit.configPath) is not a table") + } + if map.assignments.contains(where: { $0.path == ["hooks"] || $0.path == ["hooks", "state"] }) { + throw CodexHooksError(message: "inline hooks/state in \(edit.configPath) cannot be extended safely") + } + if !working.isEmpty && !working.hasSuffix("\n") { working += newline } + if !working.isEmpty && !working.hasSuffix(newline + newline) { working += newline } + working += Self.codexTrustBlockStart + newline + for (key, hash) in appendDesired.sorted(by: { $0.key < $1.key }) { + working += "[hooks.state.\(try codexTOMLQuoted(key))]\(newline)" + working += "trusted_hash = \(try codexTOMLQuoted(hash))\(newline)\(newline)" + } + working += Self.codexTrustBlockEnd + newline + } + + let renderedTree = try codexParseTOML(working, path: edit.configPath) + try codexVerifyRenderedConfig(original: originalTree, rendered: renderedTree, edit: edit) + return working + } + + private func codexValidateTrustShape(_ root: CodexTOMLValue, path: String) throws { + guard let table = root.tableValue else { throw CodexHooksError(message: "\(path) must contain a TOML table") } + if let hooks = table["hooks"] { + guard let hooksTable = hooks.tableValue else { + throw CodexHooksError(message: "hooks in \(path) is not a table, so Codex cannot read trust state") + } + if let state = hooksTable["state"], state.tableValue == nil { + throw CodexHooksError(message: "hooks.state in \(path) is not a table") } } + } - // Build config.toml without codex_hooks - let existingConfigContent: String = fm.fileExists(atPath: configPath) - ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") - : "" - let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent) - let configChanged = existingConfigContent != newConfigContent + private func codexVerifyRenderedConfig( + original: CodexTOMLValue, + rendered: CodexTOMLValue, + edit: CodexTrustEdit + ) throws { + guard var originalRoot = original.tableValue, var renderedRoot = rendered.tableValue else { + throw CodexHooksError(message: "Codex config must be a TOML table") + } + var originalFeatures = originalRoot["features"]?.tableValue ?? [:] + var renderedFeatures = renderedRoot["features"]?.tableValue ?? [:] + originalFeatures.removeValue(forKey: "codex_hooks") + renderedFeatures.removeValue(forKey: "codex_hooks") + guard originalFeatures == renderedFeatures else { + throw CodexHooksError(message: "Codex config feature verification failed; refusing to write") + } + originalRoot.removeValue(forKey: "features") + renderedRoot.removeValue(forKey: "features") + + var originalHooks = originalRoot["hooks"]?.tableValue ?? [:] + var renderedHooks = renderedRoot["hooks"]?.tableValue ?? [:] + let originalState = originalHooks.removeValue(forKey: "state")?.tableValue ?? [:] + let renderedState = renderedHooks.removeValue(forKey: "state")?.tableValue ?? [:] + guard originalHooks == renderedHooks else { + throw CodexHooksError(message: "Codex config hooks verification failed; refusing to write") + } + originalRoot.removeValue(forKey: "hooks") + renderedRoot.removeValue(forKey: "hooks") + guard originalRoot == renderedRoot else { + throw CodexHooksError(message: "Codex config changed outside Programa-owned state; refusing to write") + } + + let ignored = edit.removalKeys.union(edit.desired.keys) + let foreignOriginal = originalState.filter { key, value in + guard key.hasPrefix(edit.hooksKeyPrefix) else { return true } + if ignored.contains(key) { return false } + let hash = value.tableValue?["trusted_hash"]?.stringValue + return hash == nil || !edit.ownedHashes.contains(hash!) + } + let foreignRendered = renderedState.filter { key, _ in foreignOriginal[key] != nil } + guard foreignOriginal == foreignRendered else { + throw CodexHooksError(message: "foreign Codex trust state changed; refusing to write") + } + for (key, hash) in edit.desired { + guard renderedState[key]?.tableValue?["trusted_hash"]?.stringValue == hash else { + throw CodexHooksError(message: "Codex trust entry \(key) did not render correctly") + } + } + for key in edit.removalKeys where edit.desired[key] == nil { + guard renderedState[key] == nil else { + throw CodexHooksError(message: "stale Codex trust entry \(key) was not removed") + } + } + } - let skillPath = Self.agentSkillFilePath( - skillsRoot: NSString(string: "~/.agents/skills").expandingTildeInPath + private func codexParseTOML(_ source: String, path: String) throws -> CodexTOMLValue { + let decoder = TOMLDecoder() + decoder.limits = .init( + maxInputSize: Self.codexMaximumFileBytes, + maxDepth: 128, + maxTableKeys: 100_000, + maxArrayLength: 100_000, + maxStringLength: Self.codexMaximumFileBytes ) - let skillContent = agentSkillUninstallState(path: skillPath) + do { return .table(try decoder.decode([String: CodexTOMLValue].self, from: source)) } + catch { throw CodexHooksError(message: "\(path) is not valid TOML: \(error.localizedDescription)") } + } - if hooksRemoval == nil && !configChanged && skillContent == nil { - print("No programa hooks found.") - return + private func codexRemovingManagedBlocks(_ source: String, edit: CodexTrustEdit) throws -> String { + let lines = codexTOMLLines(source) + let starts = lines.filter { line in + !line.startsInString && [Self.codexTrustBlockStart, Self.codexLegacyTrustBlockStart].contains(line.text) } + let ends = lines.filter { line in + !line.startsInString && [Self.codexTrustBlockEnd, Self.codexLegacyTrustBlockEnd].contains(line.text) + } + guard starts.count <= 1, ends.count <= 1 else { + throw CodexHooksError(message: "Codex config contains duplicate Programa trust blocks") + } + guard starts.count == ends.count else { + throw CodexHooksError(message: "Codex config contains an unmatched Programa trust marker") + } + guard let start = starts.first, let end = ends.first else { return source } + let expectedEnd = start.text == Self.codexTrustBlockStart + ? Self.codexTrustBlockEnd + : Self.codexLegacyTrustBlockEnd + guard end.text == expectedEnd, start.range.lowerBound < end.range.lowerBound else { + throw CodexHooksError(message: "Codex config contains mismatched Programa trust markers") + } + let interior = String(source[start.range.upperBound..= section.bodyRange.lowerBound + && $0.lineRange.upperBound <= section.bodyRange.upperBound + } + guard assignments.count == 1, + assignments[0].path == expectedPath, + assignments[0].valueRange != nil, + !assignments[0].hasInlineComment else { + throw CodexHooksError(message: "trust entry \(key) contains fields or comments that Programa cannot remove safely") + } + for line in codexTOMLLines(source) { + guard line.range.lowerBound >= section.bodyRange.lowerBound, + line.range.upperBound <= section.bodyRange.upperBound else { continue } + if line.range == assignments[0].lineRange { continue } + guard line.text.trimmingCharacters(in: .whitespaces).isEmpty else { + throw CodexHooksError(message: "trust entry \(key) contains unrecognized content that Programa cannot remove safely") + } } + } - if let skillContent { - printAgentSkillRemovalDiff(path: skillPath, content: skillContent) + private func codexTOMLLines(_ source: String) -> [CodexTOMLLine] { + var result: [CodexTOMLLine] = [] + var mode = CodexTOMLStringMode.none + var cursor = source.startIndex + while cursor < source.endIndex { + let newline = source[cursor...].firstIndex(of: "\n") + let contentEndWithCR = newline ?? source.endIndex + let lineEnd = newline.map { source.index(after: $0) } ?? source.endIndex + let contentEnd: String.Index + if contentEndWithCR > cursor, + source[source.index(before: contentEndWithCR)] == "\r" { + contentEnd = source.index(before: contentEndWithCR) + } else { + contentEnd = contentEndWithCR + } + let startsInString = mode != .none + let text = String(source[cursor.. CodexTOMLStringMode { + var mode = initial + var cursor = line.startIndex + while cursor < line.endIndex { + let suffix = line[cursor...] + switch mode { + case .none: + if line[cursor] == "#" { return .none } + if suffix.hasPrefix("\"\"\"") { + mode = .multilineBasic + cursor = line.index(cursor, offsetBy: 3) + } else if suffix.hasPrefix("'''") { + mode = .multilineLiteral + cursor = line.index(cursor, offsetBy: 3) + } else if line[cursor] == "\"" { + mode = .basic + cursor = line.index(after: cursor) + } else if line[cursor] == "'" { + mode = .literal + cursor = line.index(after: cursor) + } else { + cursor = line.index(after: cursor) + } + case .basic: + if line[cursor] == "\\" { + cursor = line.index(after: cursor) + if cursor < line.endIndex { cursor = line.index(after: cursor) } + } else { + if line[cursor] == "\"" { mode = .none } + cursor = line.index(after: cursor) + } + case .literal: + if line[cursor] == "'" { mode = .none } + cursor = line.index(after: cursor) + case .multilineBasic: + if suffix.hasPrefix("\"\"\"") { + mode = .none + cursor = line.index(cursor, offsetBy: 3) + } else if line[cursor] == "\\" { + cursor = line.index(after: cursor) + if cursor < line.endIndex { cursor = line.index(after: cursor) } + } else { + cursor = line.index(after: cursor) + } + case .multilineLiteral: + if suffix.hasPrefix("'''") { + mode = .none + cursor = line.index(cursor, offsetBy: 3) + } else { + cursor = line.index(after: cursor) + } } } + return mode + } - if let hooksRemoval { - try hooksRemoval.newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) + private func codexNewline(in source: String) -> String { + guard let newline = source.firstIndex(of: "\n") else { return "\n" } + return newline > source.startIndex && source[source.index(before: newline)] == "\r" ? "\r\n" : "\n" + } + + private func codexTOMLSourceMap(_ source: String) throws -> CodexTOMLSourceMap { + struct Header { + let path: [String] + let range: Range + let bodyStart: String.Index + let hasInlineComment: Bool + } + var headers: [Header] = [] + var assignments: [CodexTOMLAssignment] = [] + var currentTable: [String] = [] + for line in codexTOMLLines(source) where !line.startsInString { + let trimmed = line.text.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("[") { + if let header = try codexParseTableHeader(trimmed) { + currentTable = header.path + headers.append(.init( + path: header.path, + range: line.range, + bodyStart: line.range.upperBound, + hasInlineComment: header.hasInlineComment + )) + } + } else if !trimmed.isEmpty, !trimmed.hasPrefix("#"), + let assignment = try codexParseAssignment(source, line: line) { + assignments.append(.init( + path: currentTable + assignment.path, + lineRange: line.range, + valueRange: assignment.valueRange, + hasInlineComment: assignment.hasInlineComment + )) + } } - if configChanged { - try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) + let sections = headers.enumerated().map { index, header in + let end = index + 1 < headers.count ? headers[index + 1].range.lowerBound : source.endIndex + return CodexTOMLSection( + path: header.path, + headerRange: header.range, + bodyRange: header.bodyStart.. (path: [String], hasInlineComment: Bool)? { + let arrayTable = line.hasPrefix("[[") + let opening = arrayTable ? 2 : 1 + let start = line.index(line.startIndex, offsetBy: opening) + var quote: Character? + var escaped = false + var cursor = start + while cursor < line.endIndex { + let character = line[cursor] + if escaped { escaped = false; cursor = line.index(after: cursor); continue } + if quote == "\"", character == "\\" { escaped = true; cursor = line.index(after: cursor); continue } + if character == "\"" || character == "'" { + if quote == character { quote = nil } else if quote == nil { quote = character } + cursor = line.index(after: cursor) + continue + } + if character == "]", quote == nil { + let closeEnd = line.index(after: cursor) + if arrayTable { + guard closeEnd < line.endIndex, line[closeEnd] == "]" else { return nil } + } + let suffixStart = arrayTable ? line.index(after: closeEnd) : closeEnd + let suffix = line[suffixStart...].trimmingCharacters(in: .whitespaces) + guard suffix.isEmpty || suffix.hasPrefix("#") else { return nil } + return (try codexParseKeyPath(String(line[start.. (path: [String], valueRange: Range?, hasInlineComment: Bool)? { + let content = source[line.contentRange] + var quote: Character? + var escaped = false + for index in content.indices { + let character = source[index] + if escaped { escaped = false; continue } + if quote == "\"", character == "\\" { escaped = true; continue } + if character == "\"" || character == "'" { + if quote == character { quote = nil } else if quote == nil { quote = character } + continue + } + if character == "=", quote == nil { + let path = try codexParseKeyPath(String(source[line.contentRange.lowerBound.. Range? { + guard start < end else { return nil } + if source[start...].hasPrefix("\"\"\"") || source[start...].hasPrefix("'''") + || source[start] == "[" || source[start] == "{" { + return nil + } + if source[start] == "\"" || source[start] == "'" { + let quote = source[start] + var escaped = false + var cursor = source.index(after: start) + while cursor < end { + let character = source[cursor] + if escaped { escaped = false; cursor = source.index(after: cursor); continue } + if quote == "\"", character == "\\" { escaped = true; cursor = source.index(after: cursor); continue } + cursor = source.index(after: cursor) + if character == quote { return start.. start ? start.. [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaped = false + func finish() throws { + let token = current.trimmingCharacters(in: .whitespaces) + guard !token.isEmpty else { throw CodexHooksError(message: "invalid empty TOML key") } + let decoder = TOMLDecoder() + let decoded = try decoder.decode([String: Int].self, from: "\(token) = 1") + guard let key = decoded.keys.first else { throw CodexHooksError(message: "invalid TOML key \(token)") } + tokens.append(key) + current = "" + } + for character in source { + if escaped { current.append(character); escaped = false; continue } + if quote == "\"", character == "\\" { current.append(character); escaped = true; continue } + if character == "\"" || character == "'" { + current.append(character) + if quote == character { quote = nil } else if quote == nil { quote = character } + continue + } + if character == ".", quote == nil { try finish() } + else { current.append(character) } + } + try finish() + return tokens + } + + private func codexApplySplices(_ splices: [CodexSourceSplice], to source: String) throws -> String { + let sorted = splices.sorted { + source.distance(from: source.startIndex, to: $0.range.lowerBound) + > source.distance(from: source.startIndex, to: $1.range.lowerBound) + } + var result = source + var previousLower = source.endIndex + for splice in sorted { + guard splice.range.upperBound <= previousLower else { + throw CodexHooksError(message: "overlapping Codex config edits; refusing to write") + } + result.replaceSubrange(splice.range, with: splice.replacement) + previousLower = splice.range.lowerBound + } + return result + } + + private func codexTOMLQuoted(_ value: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [value], options: [.withoutEscapingSlashes]) + let encoded = String(decoding: data, as: UTF8.self) + return String(encoded.dropFirst().dropLast()) + } + + private func codexUTF8(_ data: Data, path: String) throws -> String { + guard let value = String(data: data, encoding: .utf8) else { + throw CodexHooksError(message: "\(path) is not valid UTF-8") + } + return value + } + + private func codexProbeWritableDirectory(_ path: String) throws { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + let probe = (path as NSString).appendingPathComponent(".programa-preflight-\(UUID().uuidString)") + let descriptor = probe.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) } + guard descriptor >= 0 else { throw codexPOSIXError("preflight write access to", path: path) } + Darwin.close(descriptor) + guard unlink(probe) == 0 else { throw codexPOSIXError("remove preflight", path: probe) } + } + + private func codexAtomicWrite(_ data: Data, to path: String, mode: mode_t) throws { + var targetInfo = stat() + if path.withCString({ Darwin.lstat($0, &targetInfo) }) == 0 { + guard (targetInfo.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "refusing to replace non-regular file \(path)") + } + } else if errno != ENOENT { + throw codexPOSIXError("inspect", path: path) + } + let parent = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true, attributes: nil) + let directoryDescriptor = try codexOpenDirectory(parent) + defer { Darwin.close(directoryDescriptor) } + let name = (path as NSString).lastPathComponent + let temporary = (parent as NSString).appendingPathComponent(".\(name).programa-\(UUID().uuidString)") + let descriptor = temporary.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode) } + guard descriptor >= 0 else { throw codexPOSIXError("create", path: temporary) } + var shouldRemove = true + var descriptorIsOpen = true + defer { + if descriptorIsOpen { Darwin.close(descriptor) } + if shouldRemove { unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0 { + if errno == EINTR { continue } + throw codexPOSIXError("write", path: temporary) + } + offset += count + } + } + guard fchmod(descriptor, mode) == 0 else { throw codexPOSIXError("set mode", path: temporary) } + guard fsync(descriptor) == 0 else { throw codexPOSIXError("sync", path: temporary) } + guard Darwin.close(descriptor) == 0 else { throw codexPOSIXError("close", path: temporary) } + descriptorIsOpen = false + var replacementInfo = stat() + if path.withCString({ Darwin.lstat($0, &replacementInfo) }) == 0, + (replacementInfo.st_mode & S_IFMT) != S_IFREG { + throw CodexHooksError(message: "refusing to replace non-regular file \(path)") + } + guard rename(temporary, path) == 0 else { throw codexPOSIXError("replace", path: path) } + shouldRemove = false + codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) + } + + private func codexRestoreFile(_ snapshot: CodexFileSnapshot?, at path: String) throws { + if let snapshot { + try codexAtomicWrite(snapshot.data, to: path, mode: snapshot.mode) + return + } + let parent = (path as NSString).deletingLastPathComponent + let directoryDescriptor = try codexOpenDirectory(parent) + defer { Darwin.close(directoryDescriptor) } + if unlink(path) != 0 { + if errno == ENOENT { return } + throw codexPOSIXError("remove", path: path) + } + codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) + } + + private func codexOpenDirectory(_ path: String) throws -> Int32 { + let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC) } + guard descriptor >= 0 else { throw codexPOSIXError("open directory", path: path) } + return descriptor + } + + private func codexSyncDirectoryAfterCommit(_ descriptor: Int32, path: String) { + guard fsync(descriptor) != 0 else { return } + let message = "warning: committed Codex hook change, but syncing directory \(path) failed: \(String(cString: strerror(errno)))\n" + FileHandle.standardError.write(Data(message.utf8)) + } + + private func codexPOSIXError(_ action: String, path: String) -> Error { + let code = errno + return CodexHooksError(message: "\(action) \(path): \(String(cString: strerror(code)))") } // MARK: - Agent skill (SKILL.md) @@ -2263,62 +3269,6 @@ extension ProgramaCLI { return result.reversed() } - /// Returns config.toml content with codex_hooks = true under [features]. - private func buildConfigWithCodexHooks(_ content: String) -> String { - var lines = content.components(separatedBy: "\n") - - // Check if codex_hooks key already exists (exact key match at line start) - if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) { - lines[idx] = "codex_hooks = true" - return lines.joined(separator: "\n") - } - - // Find [features] section and insert after it (first occurrence only) - if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { - lines.insert("codex_hooks = true", at: idx + 1) - return lines.joined(separator: "\n") - } - - // No [features] section, append one - var result = content - if !result.isEmpty && !result.hasSuffix("\n") { - result += "\n" - } - result += "\n[features]\ncodex_hooks = true\n" - return result - } - - /// Returns config.toml content with codex_hooks removed from [features]. - private func buildConfigWithoutCodexHooks(_ content: String) -> String { - var lines = content.components(separatedBy: "\n") - - // Remove the codex_hooks line - lines.removeAll { isTomlKey($0, key: "codex_hooks") } - - // If [features] section is now empty (only has the header, nothing before next section or EOF), - // remove the header too - if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { - let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: { - !$0.trimmingCharacters(in: .whitespaces).isEmpty - }) - let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[") - if sectionEmpty { - lines.remove(at: idx) - } - } - - return lines.joined(separator: "\n") - } - - /// Check if a TOML line sets a specific key (ignoring comments and whitespace). - private func isTomlKey(_ line: String, key: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.hasPrefix("#") else { return false } - guard trimmed.hasPrefix(key) else { return false } - let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) - return rest.hasPrefix("=") - } - /// Codex hook handler. Gracefully no-ops when not running inside programa. func runCodexHook( commandArgs: [String], diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 63c9a495..92dfbffd 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + TOML0003 /* TOML in Frameworks */ = {isa = PBXBuildFile; productRef = TOML0002 /* TOML */; }; A5001001 /* ProgramaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* ProgramaApp.swift */; }; NRPA00003 /* DebugWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00004 /* DebugWindows.swift */; }; NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; }; @@ -809,6 +810,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + TOML0003 /* TOML in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1375,6 +1377,9 @@ dependencies = ( ); name = "programa-cli"; + packageProductDependencies = ( + TOML0002 /* TOML */, + ); productName = programa; productReference = B9000004A1B2C3D4E5F60719 /* programa */; productType = "com.apple.product-type.tool"; @@ -1459,6 +1464,7 @@ A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, MOBB1001 /* XCRemoteSwiftPackageReference "iroh-ffi" */, 05A57C4D24BA3C92ED8757AB /* XCRemoteSwiftPackageReference "swift-sdk" */, + TOML0001 /* XCRemoteSwiftPackageReference "swift-toml" */, ); productRefGroup = A5001042 /* Products */; projectDirPath = ""; @@ -2259,6 +2265,14 @@ version = 0.12.1; }; }; + TOML0001 /* XCRemoteSwiftPackageReference "swift-toml" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mattt/swift-toml"; + requirement = { + kind = exactVersion; + version = 2.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2287,6 +2301,11 @@ package = 05A57C4D24BA3C92ED8757AB /* XCRemoteSwiftPackageReference "swift-sdk" */; productName = MCP; }; + TOML0002 /* TOML */ = { + isa = XCSwiftPackageProductDependency; + package = TOML0001 /* XCRemoteSwiftPackageReference "swift-toml" */; + productName = TOML; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCConfigurationList section */ diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 284be481..34dee39a 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1a8872e675021285c14cd14cbad9dc6ac9d2ba486620da28ccd574c10afb61d6", + "originHash" : "21ac4fd1223a12d33e21f002cbd77a37f4bf0c1a44dca4be675f88d2d2db4acf", "pins" : [ { "identity" : "eventsource", @@ -108,6 +108,15 @@ "revision" : "704705c5c51156ede21172a38654d522ce487074", "version" : "1.8.0" } + }, + { + "identity" : "swift-toml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/swift-toml", + "state" : { + "revision" : "827506c90475e82d5a7f191f950fb3025cbdc0d6", + "version" : "2.0.0" + } } ], "version" : 3 diff --git a/tests/test_cli_codex_hook_trust.py b/tests/test_cli_codex_hook_trust.py new file mode 100644 index 00000000..0384b063 --- /dev/null +++ b/tests/test_cli_codex_hook_trust.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Behavioral contracts for installing Codex hooks and their trust state.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +import tomllib +import unittest +from pathlib import Path +from typing import Any + + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +OWNED_MARKER = "programa codex-hook" +FOREIGN_MARKER_COMMAND = "echo 'programa codex-hook'" +MISMATCHED_EVENT_COMMAND = "programa codex-hook stop" +OWNED_COMMAND_EVENT_BY_HOOK_EVENT = { + "SessionStart": "session-start", + "UserPromptSubmit": "prompt-submit", + "Stop": "stop", + "PermissionRequest": "notification", + "Notification": "notification", + "SessionEnd": "session-end", +} +EVENT_LABELS = { + "SessionStart": "session_start", + "UserPromptSubmit": "user_prompt_submit", + "Stop": "stop", + "PermissionRequest": "permission_request", + "SessionEnd": "session_end", +} + + +def foreign_handler(name: str) -> dict[str, Any]: + return {"type": "command", "command": f"foreign-{name}", "timeout": 7} + + +def owned_handler(event: str) -> dict[str, Any]: + return { + "type": "command", + "command": f"programa codex-hook {event}", + "timeout": 9, + } + + +def is_programa_handler(hook_event: str, command: str) -> bool: + command_event = OWNED_COMMAND_EVENT_BY_HOOK_EVENT.get(hook_event) + if command_event is None: + return False + bare = f"programa codex-hook {command_event}" + wrapped = ( + '[ -n "$PROGRAMA_SURFACE_ID" ] && command -v programa >/dev/null 2>&1 && ' + f"{bare} || echo '{{}}'" + ) + return command in (bare, wrapped) + + +def initial_hooks() -> dict[str, Any]: + """Represent user hooks plus stale Programa entries from an older install.""" + return { + "owner": "user", + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [foreign_handler("start-a")]}, + {"hooks": [{"type": "command", "command": FOREIGN_MARKER_COMMAND}]}, + { + "hooks": [ + foreign_handler("start-b"), + owned_handler("session-start"), + ] + }, + ], + "UserPromptSubmit": [ + {"hooks": [foreign_handler("prompt-a")]}, + {"hooks": [foreign_handler("prompt-b")]}, + {"hooks": [owned_handler("prompt-submit")]}, + ], + "Stop": [ + {"hooks": [foreign_handler("stop")]}, + {"hooks": [owned_handler("stop")]}, + ], + "PermissionRequest": [ + {"hooks": [foreign_handler("permission-request")]}, + ], + "SessionEnd": [ + {"hooks": [foreign_handler("end-a"), foreign_handler("end-b")]}, + {"hooks": [owned_handler("session-end")]}, + ], + "Notification": [ + {"hooks": [foreign_handler("notification")]}, + {"hooks": [owned_handler("notification")]}, + ], + "CustomEvent": [{ + "hooks": [ + foreign_handler("custom"), + {"type": "command", "command": MISMATCHED_EVENT_COMMAND}, + ] + }], + }, + } + + +def foreign_commands(root: dict[str, Any]) -> set[str]: + commands: set[str] = set() + for event, groups in root.get("hooks", {}).items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, dict): + continue + for handler in group.get("hooks", []): + command = handler.get("command") if isinstance(handler, dict) else None + if isinstance(command, str) and not is_programa_handler(event, command): + commands.add(command) + return commands + + +def expected_trust_hash(event_label: str, handler: dict[str, Any]) -> str: + timeout = handler.get("timeout", 600) + if event_label == "session_end": + timeout = min(max(timeout, 1), 3) + else: + timeout = max(timeout, 1) + identity = { + "event_name": event_label, + "hooks": [ + { + "async": handler.get("async", False), + "command": handler["command"], + "timeout": timeout, + "type": "command", + } + ], + } + canonical = json.dumps( + identity, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return f"sha256:{hashlib.sha256(canonical).hexdigest()}" + + +class CodexHookTrustTests(unittest.TestCase): + maxDiff = None + + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory( + prefix="programa-codex-hooks-", + dir="/tmp", + ) + self.root = Path(self.temporary_directory.name) + self.codex_home = self.root / "codex-home" + self.user_home = self.root / "user-home" + self.codex_home.mkdir() + self.user_home.mkdir() + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def run_cli(self, *arguments: str) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.update( + { + "CODEX_HOME": str(self.codex_home), + "HOME": str(self.user_home), + "CFFIXED_USER_HOME": str(self.user_home), + } + ) + for name in ( + "PROGRAMA_SOCKET", + "PROGRAMA_SOCKET_PATH", + "PROGRAMA_SOCKET_PASSWORD", + "PROGRAMA_WORKSPACE_ID", + "PROGRAMA_SURFACE_ID", + ): + env.pop(name, None) + return subprocess.run( + [CLI_PATH, "codex", *arguments, "--yes"], + capture_output=True, + text=True, + check=False, + timeout=8, + env=env, + ) + + def write_hooks(self, value: dict[str, Any]) -> bytes: + data = json.dumps(value, indent=2, sort_keys=True).encode("utf-8") + b"\n" + (self.codex_home / "hooks.json").write_bytes(data) + return data + + def read_hooks(self) -> dict[str, Any]: + return json.loads((self.codex_home / "hooks.json").read_text(encoding="utf-8")) + + def read_config(self) -> dict[str, Any]: + return tomllib.loads((self.codex_home / "config.toml").read_text(encoding="utf-8")) + + def assert_succeeded(self, process: subprocess.CompletedProcess[str]) -> None: + self.assertEqual( + process.returncode, + 0, + f"stdout={process.stdout!r}\nstderr={process.stderr!r}", + ) + + def test_install_trusts_the_real_merged_hook_positions_and_is_idempotent(self) -> None: + """Codex must execute Programa hooks without displacing user automation.""" + original = initial_hooks() + expected_foreign = foreign_commands(original) + self.write_hooks(original) + foreign_key = "manual/hooks.json:stop:4:2" + (self.codex_home / "config.toml").write_text( + "# user config\n" + 'model = "gpt-5.6"\n\n' + "[features]\n" + "codex_hooks = true\n" + "foreign_feature = true\n\n" + f'[hooks.state.{json.dumps(foreign_key)}]\n' + 'trusted_hash = "sha256:foreign"\n' + "enabled = false\n", + encoding="utf-8", + ) + + install = self.run_cli("install-hooks") + self.assert_succeeded(install) + + hooks = self.read_hooks() + self.assertEqual(hooks.get("owner"), "user") + self.assertEqual(foreign_commands(hooks), expected_foreign) + self.assertIn( + FOREIGN_MARKER_COMMAND, + foreign_commands(hooks), + "ownership requires a known Programa command, not a marker substring", + ) + self.assertIn( + MISMATCHED_EVENT_COMMAND, + foreign_commands(hooks), + "ownership requires the command to match the hook event where it is installed", + ) + self.assertFalse( + any( + is_programa_handler(event, command) + for event, command in self._all_hook_commands(hooks) + if event == "Notification" + ), + "Codex has no Notification trust-state label, so the obsolete Programa hook cannot execute", + ) + + config = self.read_config() + self.assertEqual(config.get("model"), "gpt-5.6") + self.assertTrue(config["features"]["foreign_feature"]) + self.assertNotIn( + "codex_hooks", + config["features"], + "Codex 0.151 treats hooks as stable and no longer recognizes this feature flag", + ) + state = config["hooks"]["state"] + self.assertEqual(state[foreign_key]["trusted_hash"], "sha256:foreign") + self.assertFalse(state[foreign_key]["enabled"]) + + canonical_hooks_path = self.codex_home.resolve() / "hooks.json" + owned_keys: set[str] = set() + for event, label in EVENT_LABELS.items(): + found: list[tuple[int, int, dict[str, Any]]] = [] + for group_index, group in enumerate(hooks["hooks"].get(event, [])): + for handler_index, handler in enumerate(group.get("hooks", [])): + if is_programa_handler(event, handler.get("command", "")): + found.append((group_index, handler_index, handler)) + self.assertEqual(len(found), 1, f"{event} must have one Programa handler: {found!r}") + group_index, handler_index, handler = found[0] + if event == "PermissionRequest": + self.assertIn( + "programa codex-hook notification", + handler["command"], + "Codex permission requests reuse Programa's notification handler", + ) + key = f"{canonical_hooks_path}:{label}:{group_index}:{handler_index}" + owned_keys.add(key) + self.assertIn( + key, + state, + "Codex looks up trust by the installed handler's real position", + ) + self.assertEqual(state[key]["trusted_hash"], expected_trust_hash(label, handler)) + + hooks_once = (self.codex_home / "hooks.json").read_bytes() + config_once = (self.codex_home / "config.toml").read_bytes() + reinstall = self.run_cli("install-hooks") + self.assert_succeeded(reinstall) + self.assertEqual((self.codex_home / "hooks.json").read_bytes(), hooks_once) + self.assertEqual((self.codex_home / "config.toml").read_bytes(), config_once) + + uninstall = self.run_cli("uninstall-hooks") + self.assert_succeeded(uninstall) + uninstalled_hooks = self.read_hooks() + self.assertEqual(foreign_commands(uninstalled_hooks), expected_foreign) + self.assertFalse( + any( + is_programa_handler(event, command) + for event, command in self._all_hook_commands(uninstalled_hooks) + ) + ) + self.assertEqual(uninstalled_hooks.get("owner"), "user") + self.assertIn( + FOREIGN_MARKER_COMMAND, + foreign_commands(uninstalled_hooks), + "uninstall must not delete a foreign command that only quotes Programa's marker", + ) + self.assertIn( + MISMATCHED_EVENT_COMMAND, + foreign_commands(uninstalled_hooks), + "uninstall must preserve a Programa command installed under a foreign event", + ) + self.assertIn( + "foreign-permission-request", + foreign_commands(uninstalled_hooks), + "uninstall must preserve the user's PermissionRequest automation", + ) + + uninstalled_config = self.read_config() + uninstalled_state = uninstalled_config["hooks"]["state"] + self.assertEqual(uninstalled_state[foreign_key]["trusted_hash"], "sha256:foreign") + self.assertTrue(uninstalled_config["features"]["foreign_feature"]) + self.assertNotIn("codex_hooks", uninstalled_config["features"]) + for key in owned_keys: + self.assertNotIn(key, uninstalled_state) + + @staticmethod + def _all_hook_commands(root: dict[str, Any]) -> list[tuple[str, str]]: + commands: list[tuple[str, str]] = [] + for event, groups in root.get("hooks", {}).items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, dict): + continue + commands.extend( + (event, handler["command"]) + for handler in group.get("hooks", []) + if isinstance(handler, dict) and isinstance(handler.get("command"), str) + ) + return commands + + def test_invalid_config_fails_before_hooks_are_mutated(self) -> None: + """A failed trust preflight must not leave newly installed hooks disabled.""" + for config_content in ("not = valid = toml\n", "hooks = true\n"): + with self.subTest(config=config_content): + original_hooks = self.write_hooks(initial_hooks()) + original_config = config_content.encode("utf-8") + (self.codex_home / "config.toml").write_bytes(original_config) + + install = self.run_cli("install-hooks") + + self.assertNotEqual( + install.returncode, + 0, + "invalid syntax or a scalar hooks key must fail trust setup: " + f"stdout={install.stdout!r} stderr={install.stderr!r}", + ) + self.assertEqual( + (self.codex_home / "hooks.json").read_bytes(), + original_hooks, + ) + self.assertEqual( + (self.codex_home / "config.toml").read_bytes(), + original_config, + ) + + def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> None: + """Removing a legacy group must not shift foreign trust onto Programa's replacement.""" + hooks = { + "hooks": { + "Stop": [ + {"hooks": [owned_handler("stop")]}, + {"hooks": [foreign_handler("position-sensitive-stop")]}, + ] + } + } + original_hooks = self.write_hooks(hooks) + foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:1:0" + original_config = ( + f'[hooks.state.{json.dumps(foreign_key)}]\n' + 'trusted_hash = "sha256:foreign-position"\n' + ).encode("utf-8") + (self.codex_home / "config.toml").write_bytes(original_config) + + install = self.run_cli("install-hooks") + + self.assertNotEqual( + install.returncode, + 0, + "install must refuse a rewrite that would reuse a foreign positional trust key", + ) + self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks) + self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config) + + def test_uninstall_refuses_to_delete_a_user_comment_with_stale_trust(self) -> None: + """A positional trust table cannot own comments that follow its assignments.""" + hooks = {"hooks": {"SessionStart": [{"hooks": [owned_handler("session-start")]}]}} + original_hooks = self.write_hooks(hooks) + owned_key = f"{self.codex_home.resolve() / 'hooks.json'}:session_start:0:0" + owned_hash = expected_trust_hash("session_start", owned_handler("session-start")) + original_config = ( + f'[hooks.state.{json.dumps(owned_key)}]\n' + f'trusted_hash = "{owned_hash}"\n' + "# user note: keep this explanation for the following table\n" + "[history]\n" + 'persistence = "save-all"\n' + ).encode("utf-8") + (self.codex_home / "config.toml").write_bytes(original_config) + + uninstall = self.run_cli("uninstall-hooks") + + self.assertNotEqual( + uninstall.returncode, + 0, + "uninstall must refuse when deleting a stale table would also delete a user comment", + ) + self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks) + self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config) + + def test_unrecognized_comment_inside_managed_trust_block_fails_closed(self) -> None: + """Programa markers do not authorize deleting user additions inside the block.""" + hooks = {"hooks": {"SessionStart": [{"hooks": [owned_handler("session-start")]}]}} + owned_key = f"{self.codex_home.resolve() / 'hooks.json'}:session_start:0:0" + owned_hash = expected_trust_hash("session_start", owned_handler("session-start")) + for operation in ("install-hooks", "uninstall-hooks"): + with self.subTest(operation=operation): + original_hooks = self.write_hooks(hooks) + original_config = ( + "# >>> programa codex hook trust >>>\n" + f'[hooks.state.{json.dumps(owned_key)}]\n' + f'trusted_hash = "{owned_hash}"\n' + "# user note: preserve this customization\n" + "# <<< programa codex hook trust <<<\n" + ).encode("utf-8") + (self.codex_home / "config.toml").write_bytes(original_config) + + process = self.run_cli(operation) + + self.assertNotEqual( + process.returncode, + 0, + f"{operation} must refuse to erase an unrecognized managed-block comment", + ) + self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks) + self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config) + + def test_multiline_toml_content_is_neutral_to_trust_editing(self) -> None: + """Config-like text inside a multiline value must remain user data, not edit syntax.""" + multiline_source = ( + '[hooks.state."pretend/hooks.json:stop:0:0"]\n' + 'trusted_hash = "sha256:not-a-real-table"\n' + "[features]\n" + "codex_hooks = true\n" + ) + original_config = ( + 'instructions = """\n' + + multiline_source + + '"""\n\n' + + 'model = "gpt-5.6"\n\n' + + "[features]\n" + + "foreign_feature = true\n" + ) + expected_instructions = tomllib.loads(original_config)["instructions"] + (self.codex_home / "config.toml").write_text(original_config, encoding="utf-8") + + install = self.run_cli("install-hooks") + + self.assert_succeeded(install) + rendered_text = (self.codex_home / "config.toml").read_text(encoding="utf-8") + rendered = tomllib.loads(rendered_text) + self.assertEqual(rendered["instructions"], expected_instructions) + self.assertEqual(rendered["model"], "gpt-5.6") + self.assertTrue(rendered["features"]["foreign_feature"]) + self.assertIn(multiline_source, rendered_text) + + def test_uninstall_from_empty_codex_home_is_a_non_creating_noop(self) -> None: + """Removing an absent integration must not materialize Codex configuration.""" + hooks_path = self.codex_home / "hooks.json" + config_path = self.codex_home / "config.toml" + self.assertFalse(hooks_path.exists()) + self.assertFalse(config_path.exists()) + + uninstall = self.run_cli("uninstall-hooks") + + self.assert_succeeded(uninstall) + self.assertFalse(hooks_path.exists()) + self.assertFalse(config_path.exists()) + + def test_symlinked_config_updates_its_target_without_replacing_the_link(self) -> None: + """Dotfile-managed Codex configuration must remain connected after lifecycle changes.""" + target = self.root / "managed-config.toml" + foreign_key = "managed/hooks.json:session_start:0:0" + target.write_text( + 'model = "gpt-5.6"\n\n' + f'[hooks.state.{json.dumps(foreign_key)}]\n' + 'trusted_hash = "sha256:managed"\n', + encoding="utf-8", + ) + config_link = self.codex_home / "config.toml" + config_link.symlink_to(target) + + install = self.run_cli("install-hooks") + self.assert_succeeded(install) + self.assertTrue(config_link.is_symlink()) + installed_target = target.read_bytes() + installed_config = tomllib.loads(installed_target.decode("utf-8")) + self.assertEqual(installed_config["model"], "gpt-5.6") + self.assertEqual( + installed_config["hooks"]["state"][foreign_key]["trusted_hash"], + "sha256:managed", + ) + self.assertGreater(len(installed_config["hooks"]["state"]), 1) + + reinstall = self.run_cli("install-hooks") + self.assert_succeeded(reinstall) + self.assertTrue(config_link.is_symlink()) + self.assertEqual(target.read_bytes(), installed_target) + + uninstall = self.run_cli("uninstall-hooks") + self.assert_succeeded(uninstall) + self.assertTrue(config_link.is_symlink()) + final_config = tomllib.loads(target.read_text(encoding="utf-8")) + self.assertEqual(final_config["model"], "gpt-5.6") + self.assertEqual( + final_config["hooks"]["state"][foreign_key]["trusted_hash"], + "sha256:managed", + ) + self.assertEqual(len(final_config["hooks"]["state"]), 1) + + +if __name__ == "__main__": + if not CLI_PATH or not Path(CLI_PATH).is_file() or not os.access(CLI_PATH, os.X_OK): + print("FAIL: PROGRAMA_CLI_BIN must point to an executable programa CLI") + raise SystemExit(1) + unittest.main(verbosity=2)