From 7814acb315d689480f77f730991efdc9e9e5f4b4 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 19:57:37 +0200 Subject: [PATCH 1/7] feat(install): privileged helper for one-click CLI install (#4) The app's "Install CLI" button writes /usr/local/bin/engram, which is root-owned on Apple Silicon and fresh macOS, so it failed with permission denied. Add an SMAppService + XPC privileged helper that creates the symlink as root: the bundled engram CLI doubles as the daemon via a hidden _helper-daemon subcommand, registered from a LaunchDaemon plist and reached over a Mach service. The daemon takes no client-supplied paths and validates the caller's code signature before acting. On first use macOS routes the user to System Settings -> Login Items; declining falls back to sudo in Terminal. Long-term fix for #4; see ADR 0022. Runtime behaviour (daemon registration, approval, XPC code-sign check) only exercises on a signed, notarized build. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 7 +- Engram/Engram/EngramModel.swift | 7 ++ Engram/Engram/InstallSheet.swift | 63 ++++++++-- Engram/Engram/PrivilegedInstaller.swift | 117 ++++++++++++++++++ Engram/org.klevan.Engram.helper.plist | 29 +++++ Engram/scripts/bundle-cli.sh | 8 ++ Package.swift | 3 +- README.md | 11 +- Sources/EngramCore/HelperDaemon.swift | 96 ++++++++++++++ Sources/EngramCore/HelperProtocol.swift | 34 +++++ Sources/engram/main.swift | 5 + .../0022-privileged-helper-for-cli-install.md | 94 ++++++++++++++ docs/adr/README.md | 1 + 13 files changed, 461 insertions(+), 14 deletions(-) create mode 100644 Engram/Engram/PrivilegedInstaller.swift create mode 100644 Engram/org.klevan.Engram.helper.plist create mode 100644 Sources/EngramCore/HelperDaemon.swift create mode 100644 Sources/EngramCore/HelperProtocol.swift create mode 100644 docs/adr/0022-privileged-helper-for-cli-install.md diff --git a/CLAUDE.md b/CLAUDE.md index 34c7593..6cdc049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,10 @@ and recall content. See `README.md` for architecture and build instructions. - `Sources/engram-eval` — offline retrieval eval harness (`swift run engram-eval`): seeds a temp store from `Resources/corpus.json` + `queries.json`, runs each prompt through `fetch`, applies `RecallGate` configs, and prints a current-vs-tightened comparison (ADR 0021). `--distances` dumps per-kind distance separability; `--record` appends a per-run JSON file (git sha + embedder signature + host + metrics) under `eval/runs/`. Numbers are embedder/machine-dependent — it's a relative A/B, not a benchmark. - `Sources/engram` — the `engram` CLI (store / fetch / stats / activity / hook) - `Sources/CSQLite` — vendored SQLite + sqlite-vec (static C target) -- `Sources/engram/Setup.swift` — install logic (`engram install` / `engram setup`); the single source of truth for installing the CLI, hook, and skills +- `Sources/engram/Setup.swift` — install logic (`engram install` / `engram setup`); the single source of truth for installing the CLI, hook, and skills. `engram install` symlinks `/usr/local/bin/engram` → the running binary +- `Sources/EngramCore/HelperProtocol.swift` + `HelperDaemon.swift` — the privileged helper (ADR 0022): the shared XPC contract/constants and the root daemon (NSXPCListener + client code-sign validation + symlink install) the bundled CLI runs as `engram _helper-daemon` +- `Engram/Engram/PrivilegedInstaller.swift` — app-side driver for the helper: `SMAppService` daemon registration (+ Login Items approval flow) and the XPC call that installs the CLI; backs the toolbar **Install CLI** button (ADR 0022) +- `Engram/org.klevan.Engram.helper.plist` — the LaunchDaemon plist, copied into `Contents/Library/LaunchDaemons/` by `bundle-cli.sh` - `Engram/` — the Xcode SwiftUI app (thin shell over `EngramCore`); not sandboxed (ADR 0003) - `Engram/Engram/SettingsView.swift` — Settings window (⌘,) with the Sparkle-backed Updates pane (ADR 0010) - `Engram/Engram/ContentView.swift` — the native `NavigationSplitView` shell: sidebar (lenses + facet filters), detail container, one toolbar, trailing inspector (ADR 0016) @@ -25,7 +28,7 @@ and recall content. See `README.md` for architecture and build instructions. - `Engram/Engram/ActivityView.swift` — the Activity lens: a unified timeline of reads (recall/search/fetch/…) **and** writes (store/update/delete) as a native sortable `Table` backed by `MemoryStore.activity()`; lookback lives in the toolbar (ADR 0015/0016/0017/0020) - `Sources/EngramCore/Facets.swift` — pure parser splitting tags into `key:value` facets vs freeform; folds `source` into `project` (ADR 0013) - `Engram/Info.plist` — partial plist merged into the generated one; carries the Sparkle `SU*` keys (custom keys can't go through `INFOPLIST_KEY_*`) -- `Engram/scripts/bundle-cli.sh` — build phase that bundles the CLI into the app +- `Engram/scripts/bundle-cli.sh` — build phase that bundles the CLI (and the helper LaunchDaemon plist, ADR 0022) into the app - `scripts/release.sh` + `scripts/bump_version.py` — local `make release-*` flow: gate, bump, tag, push (ADR 0010) - `scripts/update_appcast.py` — prepends a release entry to `docs/appcast.xml` (run by CI; stdlib-only so the runner needs no uv) - `.github/workflows/release.yml` + `.github/ExportOptions.plist` — CI that signs, notarizes, and publishes a release (ADR 0010) diff --git a/Engram/Engram/EngramModel.swift b/Engram/Engram/EngramModel.swift index b10a053..2eba9f2 100644 --- a/Engram/Engram/EngramModel.swift +++ b/Engram/Engram/EngramModel.swift @@ -442,6 +442,13 @@ final class EngramModel { /// Runs the CLI shipped inside the app bundle. `nonisolated static` so the /// install sheet can run it off the main actor without blocking the UI. + /// Absolute path to the engram CLI bundled inside the app. Used both to run + /// it and to show a runnable `sudo … install` fallback (the CLI isn't on + /// $PATH until installed, so the bare name wouldn't resolve). + nonisolated static var bundledEngramPath: String { + Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/engram").path + } + nonisolated static func runBundledEngram(_ arguments: [String]) -> (output: String, success: Bool) { // Bundled at Contents/Helpers/engram (not Contents/MacOS — "engram" would // collide with the app binary "Engram" on case-insensitive APFS). diff --git a/Engram/Engram/InstallSheet.swift b/Engram/Engram/InstallSheet.swift index f2a1c61..1f71d65 100644 --- a/Engram/Engram/InstallSheet.swift +++ b/Engram/Engram/InstallSheet.swift @@ -1,3 +1,4 @@ +import ServiceManagement import SwiftUI /// The two install actions the app can perform, with the copy + CLI arguments @@ -32,7 +33,7 @@ enum InstallKind: Identifiable, Equatable { var summary: String { switch self { case .cli: - return "Copies the bundled engram command-line tool to your PATH." + return "Symlinks the bundled engram command-line tool into your PATH." case .integration: return "Sets up Engram's Claude Code integration." } @@ -42,9 +43,9 @@ enum InstallKind: Identifiable, Equatable { switch self { case .cli: return [ - "Installs to /usr/local/bin/engram", + "Symlinks /usr/local/bin/engram to the bundled CLI", "Lets Claude Code and your terminal run engram", - "Replaces any existing engram there", + "May ask you to approve Engram in System Settings the first time", ] case .integration: return [ @@ -56,6 +57,16 @@ enum InstallKind: Identifiable, Equatable { } } + /// CLI install writes to root-owned /usr/local/bin, so it goes through the + /// privileged helper (ADR 0022); integration install only edits files under + /// the user's home and shells out to the bundled CLI. + var usesPrivilegedHelper: Bool { + switch self { + case .cli: return true + case .integration: return false + } + } + var arguments: [String] { switch self { case .cli: return ["install"] @@ -74,6 +85,9 @@ struct InstallSheet: View { enum Phase: Equatable { case confirm case running + /// The privileged helper needs the user to enable Engram in System + /// Settings → Login Items before it can install (ADR 0022). + case needsApproval case done(output: String, success: Bool) } @@ -126,6 +140,19 @@ struct InstallSheet: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) + case .needsApproval: + VStack(alignment: .leading, spacing: 12) { + Label("Approval needed", systemImage: "lock.shield") + .font(.headline) + .foregroundStyle(kind.tint) + Text(""" + macOS needs your OK to let Engram install the command-line tool. \ + I've opened System Settings → Login Items — switch Engram on there, \ + then click Install again. + """) + .font(.callout) + .foregroundStyle(.secondary) + } case let .done(output, success): VStack(alignment: .leading, spacing: 12) { Label(success ? "Done" : "Something went wrong", @@ -158,6 +185,14 @@ struct InstallSheet: View { .tint(kind.tint) case .running: Button("Install") {}.disabled(true).buttonStyle(.borderedProminent) + case .needsApproval: + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() } + Button("Install") { run() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .tint(kind.tint) case .done: Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) @@ -170,11 +205,23 @@ struct InstallSheet: View { private func run() { phase = .running Task { - let result = await Task.detached(priority: .userInitiated) { - EngramModel.runBundledEngram(kind.arguments) - }.value - phase = .done(output: result.output, success: result.success) - if kind == .cli && result.success { model.refresh() } + if kind.usesPrivilegedHelper { + switch await PrivilegedInstaller.install() { + case let .installed(message): + phase = .done(output: message, success: true) + model.refresh() + case .needsApproval: + phase = .needsApproval + case let .failed(message): + phase = .done(output: message + "\n\nOr install it from Terminal:\n sudo " + + EngramModel.bundledEngramPath + " install", success: false) + } + } else { + let result = await Task.detached(priority: .userInitiated) { + EngramModel.runBundledEngram(kind.arguments) + }.value + phase = .done(output: result.output, success: result.success) + } } } } diff --git a/Engram/Engram/PrivilegedInstaller.swift b/Engram/Engram/PrivilegedInstaller.swift new file mode 100644 index 0000000..8a90423 --- /dev/null +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -0,0 +1,117 @@ +import EngramCore +import Foundation +import ServiceManagement + +/// Drives the privileged helper (ADR 0022): registers the bundled LaunchDaemon +/// with `SMAppService`, then calls it over XPC to create the +/// `/usr/local/bin/engram` symlink as root. Replaces the user-writable-only +/// `engram install` path for the app's "Install CLI" button. +enum PrivilegedInstaller { + enum Outcome { + case installed(String) + /// The daemon needs the user to enable Engram in System Settings → Login + /// Items before it can run. System Settings has been opened. + case needsApproval + case failed(String) + } + + private static var service: SMAppService { + SMAppService.daemon(plistName: HelperConstants.daemonPlistName) + } + + /// Registers + (if needed) prompts approval for the daemon, then asks it to + /// install the symlink. + static func install() async -> Outcome { + switch ensureRegistered() { + case .ready: + break + case .needsApproval: + return .needsApproval + case let .failed(message): + return .failed(message) + } + return await callHelper() + } + + private enum Registration { + case ready + case needsApproval + case failed(String) + } + + private static func ensureRegistered() -> Registration { + let service = self.service + switch service.status { + case .enabled: + return .ready + case .requiresApproval: + SMAppService.openSystemSettingsLoginItems() + return .needsApproval + case .notRegistered, .notFound: + do { + try service.register() + } catch { + if service.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + return .needsApproval + } + return .failed("Couldn't register the privileged helper: \(error.localizedDescription)") + } + // Registration on a fresh machine lands in requiresApproval until the + // user toggles Engram on in Login Items. + if service.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + return .needsApproval + } + return service.status == .enabled + ? .ready + : .failed("Helper didn't enable (status \(service.status.rawValue)).") + @unknown default: + return .failed("Unexpected helper status (\(service.status.rawValue)).") + } + } + + private static func callHelper() async -> Outcome { + await withCheckedContinuation { continuation in + let connection = NSXPCConnection(machServiceName: HelperConstants.machServiceName, + options: .privileged) + connection.remoteObjectInterface = NSXPCInterface(with: EngramHelperProtocol.self) + connection.resume() + + // The error handler and the reply both run on XPC's queue and are + // mutually exclusive in practice, but guard against a double resume. + let once = Once() + let finish: (Outcome) -> Void = { outcome in + once.run { + connection.invalidate() + continuation.resume(returning: outcome) + } + } + + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + finish(.failed("Couldn't reach the helper: \(error.localizedDescription)")) + } + guard let helper = proxy as? EngramHelperProtocol else { + finish(.failed("Couldn't create the helper proxy.")) + return + } + helper.installCLI { success, message in + finish(success ? .installed(message) : .failed(message)) + } + } + } +} + +/// One-shot guard so a continuation is resumed exactly once across the XPC +/// reply and error-handler callbacks. +private final class Once: @unchecked Sendable { + private var done = false + private let lock = NSLock() + func run(_ block: () -> Void) { + lock.lock() + defer { lock.unlock() } + guard !done else { return } + done = true + block() + } +} diff --git a/Engram/org.klevan.Engram.helper.plist b/Engram/org.klevan.Engram.helper.plist new file mode 100644 index 0000000..ddfc9e6 --- /dev/null +++ b/Engram/org.klevan.Engram.helper.plist @@ -0,0 +1,29 @@ + + + + + + Label + org.klevan.Engram.helper + BundleProgram + Contents/Helpers/engram + ProgramArguments + + Contents/Helpers/engram + _helper-daemon + + MachServices + + org.klevan.Engram.helper + + + AssociatedBundleIdentifiers + + org.klevan.Engram + + + diff --git a/Engram/scripts/bundle-cli.sh b/Engram/scripts/bundle-cli.sh index b375995..43a9735 100755 --- a/Engram/scripts/bundle-cli.sh +++ b/Engram/scripts/bundle-cli.sh @@ -29,3 +29,11 @@ if [ "${CODE_SIGNING_ALLOWED:-YES}" = "YES" ] && [ -n "${EXPANDED_CODE_SIGN_IDEN fi echo "note: bundled engram → $DEST_DIR/engram" + +# Bundle the privileged-helper LaunchDaemon plist (ADR 0022). SMAppService loads +# it from Contents/Library/LaunchDaemons/; the plist itself isn't signed (the +# enclosing app bundle is), so just copy it into place. +DAEMON_DIR="$CODESIGNING_FOLDER_PATH/Contents/Library/LaunchDaemons" +mkdir -p "$DAEMON_DIR" +cp -f "$SRCROOT/org.klevan.Engram.helper.plist" "$DAEMON_DIR/org.klevan.Engram.helper.plist" +echo "note: bundled helper daemon plist → $DAEMON_DIR/org.klevan.Engram.helper.plist" diff --git a/Package.swift b/Package.swift index 03eadfd..8f9452f 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,8 @@ let package = Package( name: "EngramCore", dependencies: ["CSQLite"], linkerSettings: [ - .linkedFramework("NaturalLanguage") + .linkedFramework("NaturalLanguage"), + .linkedFramework("Security"), ] ), .executableTarget( diff --git a/README.md b/README.md index 088a81b..2bf7008 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ engram delete engram verify [--json] # cheap deterministic checks; one verdict per active memory engram verified [--confidence 0..1] [--json] # mark a memory verified now engram supersede "" --reason "" [--tags a,b] [--source repo] [--json] # replace, keeping history -engram install # copy this binary to /usr/local/bin/engram +engram install # symlink /usr/local/bin/engram → this binary engram setup [--hooks] [--skills] # install the recall hook and/or the skills ``` @@ -212,11 +212,16 @@ CLI** (→ `/usr/local/bin/engram`) and **Install Hooks & Skills** — or from t terminal: ```bash -engram install # copy the CLI to /usr/local/bin +engram install # symlink the CLI into /usr/local/bin engram setup # write the recall (UserPromptSubmit) + verify-context (SessionStart) hooks and the /remember skill ``` -Both are idempotent and back up `~/.claude/settings.json` before editing it. +`/usr/local/bin` is root-owned on Apple Silicon and on fresh macOS, so the app's +**Install CLI** button writes the symlink through a privileged helper +(`SMAppService` + XPC; ADR 0022) — macOS asks you to enable Engram in System +Settings → Login Items the first time. If you decline, `sudo engram install` +from the terminal does the same thing. Both are idempotent; `engram setup` backs +up `~/.claude/settings.json` before editing it. ## Embedding quality diff --git a/Sources/EngramCore/HelperDaemon.swift b/Sources/EngramCore/HelperDaemon.swift new file mode 100644 index 0000000..596fe3c --- /dev/null +++ b/Sources/EngramCore/HelperDaemon.swift @@ -0,0 +1,96 @@ +import Foundation +import Security + +/// The privileged helper daemon (ADR 0022). Runs as root, launched by +/// `SMAppService` as the hidden `engram _helper-daemon` subcommand. It vends a +/// single XPC method that installs the CLI symlink, and only after validating +/// that the connecting client is our signed app. +public final class HelperDaemon: NSObject, NSXPCListenerDelegate, EngramHelperProtocol { + private let listener: NSXPCListener + + public override init() { + listener = NSXPCListener(machServiceName: HelperConstants.machServiceName) + super.init() + listener.delegate = self + } + + /// Services XPC connections forever. Call from the daemon process; never returns. + public func run() -> Never { + listener.resume() + dispatchMain() + } + + // MARK: - NSXPCListenerDelegate + + public func listener(_ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + guard HelperDaemon.isClientTrusted(newConnection) else { return false } + newConnection.exportedInterface = NSXPCInterface(with: EngramHelperProtocol.self) + newConnection.exportedObject = self + newConnection.resume() + return true + } + + // MARK: - EngramHelperProtocol + + /// Installs `/usr/local/bin/engram` → the daemon's own bundled binary. Both + /// paths are fixed/derived here, never taken from the client (ADR 0022), so + /// the helper can only ever install itself. + public func installCLI(withReply reply: @escaping (Bool, String) -> Void) { + guard let source = Bundle.main.executablePath else { + reply(false, "could not locate the bundled engram binary") + return + } + let dest = HelperConstants.installDestination + let fileManager = FileManager.default + do { + try fileManager.createDirectory(atPath: (dest as NSString).deletingLastPathComponent, + withIntermediateDirectories: true) + if fileManager.fileExists(atPath: dest) || HelperDaemon.isSymlink(atPath: dest) { + try fileManager.removeItem(atPath: dest) + } + try fileManager.createSymbolicLink(atPath: dest, withDestinationPath: source) + reply(true, "installed engram → \(dest)") + } catch { + reply(false, "\(error)") + } + } + + // MARK: - Validation + + /// `fileExists` follows symlinks, so a dangling link reports false; check the + /// symbolic-link attribute directly to catch that case too. + private static func isSymlink(atPath path: String) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: path)[.type] as? FileAttributeType) == .typeSymbolicLink + } + + /// Validates the connecting client's code signature against our app's + /// requirement (ADR 0022) via the connection's audit token. Reject anything + /// that isn't our notarized app, so the root helper can't be driven by other + /// processes. + static func isClientTrusted(_ connection: NSXPCConnection) -> Bool { + guard let token = auditToken(of: connection) else { return false } + let tokenData = withUnsafeBytes(of: token) { Data($0) } as CFData + let attributes = [kSecGuestAttributeAudit: tokenData] as CFDictionary + + var code: SecCode? + guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess, + let guestCode = code else { return false } + + var requirement: SecRequirement? + guard SecRequirementCreateWithString(HelperConstants.clientCodeRequirement as CFString, + [], &requirement) == errSecSuccess, + let clientRequirement = requirement else { return false } + + return SecCodeCheckValidity(guestCode, [], clientRequirement) == errSecSuccess + } + + /// NSXPCConnection doesn't expose its audit token publicly; read it via KVC. + /// This is the established pattern for secure XPC peer validation. + private static func auditToken(of connection: NSXPCConnection) -> audit_token_t? { + guard let value = connection.value(forKey: "auditToken") as? NSValue else { return nil } + var token = audit_token_t() + value.getValue(&token, size: MemoryLayout.size) + return token + } +} diff --git a/Sources/EngramCore/HelperProtocol.swift b/Sources/EngramCore/HelperProtocol.swift new file mode 100644 index 0000000..21f53c4 --- /dev/null +++ b/Sources/EngramCore/HelperProtocol.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Shared constants for the privileged helper (ADR 0022). Both the daemon (the +/// bundled `engram` binary run as `engram _helper-daemon`) and the app's XPC +/// client refer to these, so they live in EngramCore — the one target both link. +public enum HelperConstants { + /// The Mach service the daemon vends and the app connects to. Must match the + /// `MachServices` key and `Label` in the bundled LaunchDaemon plist. + public static let machServiceName = "org.klevan.Engram.helper" + + /// The LaunchDaemon plist `SMAppService.daemon(plistName:)` registers. Must + /// match the file bundled at `Contents/Library/LaunchDaemons/`. + public static let daemonPlistName = "org.klevan.Engram.helper.plist" + + /// Where the CLI symlink is installed. Hard-coded — the helper never takes a + /// destination from the client (ADR 0022). + public static let installDestination = "/usr/local/bin/engram" + + /// Code-signing requirement every connecting client must satisfy: our app, + /// signed by our team (ADR 0022). Team ID from `.github/ExportOptions.plist`. + public static let clientCodeRequirement = + "anchor apple generic and identifier \"org.klevan.Engram\" " + + "and certificate leaf[subject.OU] = \"M2RXQJGK5A\"" +} + +/// The XPC contract between the app and the privileged helper. One method: the +/// daemon installs the CLI symlink as root and reports back. Reply types are +/// Objective-C bridgeable so the interface is `@objc`-compatible. +@objc public protocol EngramHelperProtocol { + /// Creates the `/usr/local/bin/engram` symlink pointing at the daemon's own + /// (bundled) binary. `success` reports the outcome; `message` is a + /// human-readable result or error suitable for showing in the app. + func installCLI(withReply reply: @escaping (_ success: Bool, _ message: String) -> Void) +} diff --git a/Sources/engram/main.swift b/Sources/engram/main.swift index 8d8d098..fc14331 100644 --- a/Sources/engram/main.swift +++ b/Sources/engram/main.swift @@ -157,6 +157,11 @@ switch command { case "install": do { print(try Setup.installCLI()) } catch { fail("\(error)") } exit(0) +case "_helper-daemon": + // Hidden: the privileged helper entry point (ADR 0022). Launched as root by + // SMAppService via the bundled LaunchDaemon plist, not run by users. Blocks + // forever servicing XPC connections. + HelperDaemon().run() case "setup": // Default installs both; --hooks / --skills narrow it. let onlyHooks = flags.contains("--hooks") diff --git a/docs/adr/0022-privileged-helper-for-cli-install.md b/docs/adr/0022-privileged-helper-for-cli-install.md new file mode 100644 index 0000000..dd95a58 --- /dev/null +++ b/docs/adr/0022-privileged-helper-for-cli-install.md @@ -0,0 +1,94 @@ +# 22. Privileged helper (SMAppService + XPC) for installing the CLI symlink + +- **Status:** Accepted +- **Date:** 2026-06-22 +- **Deciders:** Daniel Klevebring, Claude +- **Relates to:** ADR 0003 (non-sandboxed dev-tool app), issue #4 + +## Context + +The app's "Install the engram CLI" button calls `Setup.installCLI()`, which +writes `/usr/local/bin/engram`. `/usr/local/bin` is on `/etc/paths` on every +macOS machine, so it's the correct install target — but it is only +user-writable on Intel Macs where Homebrew has `chown`ed `/usr/local` to the +user. On Apple Silicon (Homebrew lives in `/opt/homebrew`) and on fresh macOS +installs, `/usr/local` stays root-owned and the write fails with +`NSCocoaErrorDomain 513` / `EACCES`. `~/.local/bin` is not a viable fallback — +it isn't on the default `$PATH`, so the installed hooks (which reference the +absolute binary path) would silently break. + +A first, short-term mitigation (issue #4 "short-term fix", separate change) +switched the copy to a symlink — fixing post-update version drift — and +surfaced a clear "run `sudo engram install` in Terminal" message when the write +is denied. That keeps the failure legible but still drops the user to a +terminal; the button does not actually install. + +For a one-click install with no Terminal, the app must perform the write with +elevated privileges. The supported post-macOS-13 mechanism is a +`SMAppService`-registered **LaunchDaemon** that the app drives over **XPC**; +the deprecated `AuthorizationExecuteWithPrivileges` and the older +`SMJobBless` flow are explicitly avoided. + +## Decision + +Add a privileged helper that creates the `/usr/local/bin/engram` symlink as +root, reached from the app over XPC and registered with `SMAppService`. + +1. **The daemon is the already-bundled `engram` binary**, not a new build + product. It is invoked as a hidden `engram _helper-daemon` subcommand. This + avoids a second signed executable and a second Xcode target: the CLI is + already bundled at `Contents/Helpers/engram` and signed with the app's + identity by `bundle-cli.sh`. The daemon code itself lives in `EngramCore` + (`HelperProtocol.swift`, `HelperDaemon.swift`) so both the CLI and the app + share one definition of the XPC contract. + +2. **A LaunchDaemon plist** (`org.klevan.Engram.helper.plist`) is bundled at + `Contents/Library/LaunchDaemons/` (where `SMAppService.daemon(plistName:)` + requires it), with `BundleProgram` → `Contents/Helpers/engram`, + `ProgramArguments` ending in `_helper-daemon`, and a `MachServices` entry for + the Mach service `org.klevan.Engram.helper`. + +3. **The app** registers the daemon via `SMAppService.daemon(plistName:)`. On + first use macOS routes the user to System Settings → Login Items for + approval (`.requiresApproval`); once enabled the app opens an + `NSXPCConnection(machServiceName:options:.privileged)` and calls the one + helper method. + +4. **The helper accepts no client-controlled paths.** Both ends of the symlink + are fixed/derived inside the daemon: the destination is the constant + `/usr/local/bin/engram`, and the source is the daemon's own + `Bundle.main.executablePath` (i.e. the bundled CLI). So the helper is not a + general root-symlink primitive — it can only install *itself*. + +5. **The daemon validates every connecting client's code signature** before + vending the object: it reads the connection's audit token, copies the + guest `SecCode`, and checks it against the requirement `anchor apple generic + and identifier "org.klevan.Engram" and certificate leaf[subject.OU] = + "M2RXQJGK5A"` (team ID `M2RXQJGK5A`, from `.github/ExportOptions.plist`). + Connections that fail validation are rejected. + +The short-term Terminal fallback (`sudo engram install`) remains as a secondary +path for users who decline the System Settings approval. + +## Consequences + +- One-click privileged install on every Mac, no Homebrew assumption, no manual + Terminal step in the happy path. +- The symlink keeps the CLI tracking the current app version across Sparkle + updates (the short-term symlink change, now performed with privilege). +- **Signing-dependent and not locally verifiable.** Daemon registration, the + System Settings approval, and the XPC code-signing check only work on a + Developer-ID-signed, notarized build (ADR 0010); `swift build` / `make test` + cannot exercise them. The team ID and bundle identifier are baked into the + requirement string, so a re-org of either must update `HelperConstants`. +- New surface to keep signed: the daemon is the same `engram` binary, so no + extra signing step, but the `Contents/Library/LaunchDaemons/` plist must be + bundled (a Copy Files build phase) and the binary must remain signed with the + team identity for the requirement check to pass. +- Mixing a root daemon entry point into the user-facing CLI binary widens that + binary's role; it is mitigated by (4) and (5) — the daemon path takes no + external input and refuses unverified callers — but it is a deliberate + trade against shipping a second executable. +- Reversible: if the helper proves troublesome, the app falls back to the + Terminal path and the daemon plist/subcommand can be dropped without touching + storage or the integration model. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3e71d0e..500be8f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ supersedes the old one (and update the old one's status to `Superseded by NNNN`) | [0019](0019-tag-centric-exploration.md) | Tag-centric exploration: Tags list + bipartite tag-graph (supersedes 0018 Map/Structure) | Accepted | | [0020](0020-unified-activity-timeline.md) | Unified Activity timeline: reads + writes in one stream (extends 0015) | Accepted | | [0021](0021-embedder-relative-recall-gate.md) | Embedder-relative recall gate, calibrated by offline eval (refines 0005's gate) | Accepted | +| [0022](0022-privileged-helper-for-cli-install.md) | Privileged helper (SMAppService + XPC) for installing the CLI symlink | Accepted | ## Writing a new ADR From 08ae311e95d7707c92f2171112b98c4fe0dfd5de Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 20:25:36 +0200 Subject: [PATCH 2/7] refactor(install): use one-shot authenticated osascript, drop daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SMAppService LaunchDaemon was the wrong shape for a one-shot symlink: it registers a persistent root daemon and forces a System Settings → Login Items toggle. Replace it with a single authenticated command run through the Apple-signed /usr/bin/osascript (do shell script … with administrator privileges), which shows one native Touch ID / password dialog and leaves no daemon, login item, or helper tool behind. Because the requesting process (osascript) is Apple-signed, the auth dialog offers Touch ID when enabled. Removes the XPC protocol/daemon, the LaunchDaemon plist, the hidden _helper-daemon subcommand, and the Login Items approval UI. Rewrites ADR 0022 to record the decision (the daemon was prototyped, then rejected as overkill). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 +- Engram/Engram/InstallSheet.swift | 31 +--- Engram/Engram/PrivilegedInstaller.swift | 152 ++++++------------ Engram/org.klevan.Engram.helper.plist | 29 ---- Engram/scripts/bundle-cli.sh | 8 - Package.swift | 3 +- README.md | 10 +- Sources/EngramCore/HelperDaemon.swift | 96 ----------- Sources/EngramCore/HelperProtocol.swift | 34 ---- Sources/engram/main.swift | 22 ++- .../0022-privileged-helper-for-cli-install.md | 133 +++++++-------- docs/adr/README.md | 2 +- 12 files changed, 141 insertions(+), 385 deletions(-) delete mode 100644 Engram/org.klevan.Engram.helper.plist delete mode 100644 Sources/EngramCore/HelperDaemon.swift delete mode 100644 Sources/EngramCore/HelperProtocol.swift diff --git a/CLAUDE.md b/CLAUDE.md index 6cdc049..04051d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,7 @@ and recall content. See `README.md` for architecture and build instructions. - `Sources/engram` — the `engram` CLI (store / fetch / stats / activity / hook) - `Sources/CSQLite` — vendored SQLite + sqlite-vec (static C target) - `Sources/engram/Setup.swift` — install logic (`engram install` / `engram setup`); the single source of truth for installing the CLI, hook, and skills. `engram install` symlinks `/usr/local/bin/engram` → the running binary -- `Sources/EngramCore/HelperProtocol.swift` + `HelperDaemon.swift` — the privileged helper (ADR 0022): the shared XPC contract/constants and the root daemon (NSXPCListener + client code-sign validation + symlink install) the bundled CLI runs as `engram _helper-daemon` -- `Engram/Engram/PrivilegedInstaller.swift` — app-side driver for the helper: `SMAppService` daemon registration (+ Login Items approval flow) and the XPC call that installs the CLI; backs the toolbar **Install CLI** button (ADR 0022) -- `Engram/org.klevan.Engram.helper.plist` — the LaunchDaemon plist, copied into `Contents/Library/LaunchDaemons/` by `bundle-cli.sh` +- `Engram/Engram/PrivilegedInstaller.swift` — app-side privileged install (ADR 0022): runs the symlink through the Apple-signed `/usr/bin/osascript` (`do shell script … with administrator privileges`) for one Touch-ID/password dialog, no persistent helper; backs the toolbar **Install CLI** button - `Engram/` — the Xcode SwiftUI app (thin shell over `EngramCore`); not sandboxed (ADR 0003) - `Engram/Engram/SettingsView.swift` — Settings window (⌘,) with the Sparkle-backed Updates pane (ADR 0010) - `Engram/Engram/ContentView.swift` — the native `NavigationSplitView` shell: sidebar (lenses + facet filters), detail container, one toolbar, trailing inspector (ADR 0016) @@ -28,7 +26,7 @@ and recall content. See `README.md` for architecture and build instructions. - `Engram/Engram/ActivityView.swift` — the Activity lens: a unified timeline of reads (recall/search/fetch/…) **and** writes (store/update/delete) as a native sortable `Table` backed by `MemoryStore.activity()`; lookback lives in the toolbar (ADR 0015/0016/0017/0020) - `Sources/EngramCore/Facets.swift` — pure parser splitting tags into `key:value` facets vs freeform; folds `source` into `project` (ADR 0013) - `Engram/Info.plist` — partial plist merged into the generated one; carries the Sparkle `SU*` keys (custom keys can't go through `INFOPLIST_KEY_*`) -- `Engram/scripts/bundle-cli.sh` — build phase that bundles the CLI (and the helper LaunchDaemon plist, ADR 0022) into the app +- `Engram/scripts/bundle-cli.sh` — build phase that bundles the CLI into the app - `scripts/release.sh` + `scripts/bump_version.py` — local `make release-*` flow: gate, bump, tag, push (ADR 0010) - `scripts/update_appcast.py` — prepends a release entry to `docs/appcast.xml` (run by CI; stdlib-only so the runner needs no uv) - `.github/workflows/release.yml` + `.github/ExportOptions.plist` — CI that signs, notarizes, and publishes a release (ADR 0010) diff --git a/Engram/Engram/InstallSheet.swift b/Engram/Engram/InstallSheet.swift index 1f71d65..fd0e139 100644 --- a/Engram/Engram/InstallSheet.swift +++ b/Engram/Engram/InstallSheet.swift @@ -1,4 +1,3 @@ -import ServiceManagement import SwiftUI /// The two install actions the app can perform, with the copy + CLI arguments @@ -45,7 +44,7 @@ enum InstallKind: Identifiable, Equatable { return [ "Symlinks /usr/local/bin/engram to the bundled CLI", "Lets Claude Code and your terminal run engram", - "May ask you to approve Engram in System Settings the first time", + "Asks for your password (or Touch ID) to write there", ] case .integration: return [ @@ -85,9 +84,6 @@ struct InstallSheet: View { enum Phase: Equatable { case confirm case running - /// The privileged helper needs the user to enable Engram in System - /// Settings → Login Items before it can install (ADR 0022). - case needsApproval case done(output: String, success: Bool) } @@ -140,19 +136,6 @@ struct InstallSheet: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) - case .needsApproval: - VStack(alignment: .leading, spacing: 12) { - Label("Approval needed", systemImage: "lock.shield") - .font(.headline) - .foregroundStyle(kind.tint) - Text(""" - macOS needs your OK to let Engram install the command-line tool. \ - I've opened System Settings → Login Items — switch Engram on there, \ - then click Install again. - """) - .font(.callout) - .foregroundStyle(.secondary) - } case let .done(output, success): VStack(alignment: .leading, spacing: 12) { Label(success ? "Done" : "Something went wrong", @@ -185,14 +168,6 @@ struct InstallSheet: View { .tint(kind.tint) case .running: Button("Install") {}.disabled(true).buttonStyle(.borderedProminent) - case .needsApproval: - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() } - Button("Install") { run() } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .tint(kind.tint) case .done: Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) @@ -210,8 +185,8 @@ struct InstallSheet: View { case let .installed(message): phase = .done(output: message, success: true) model.refresh() - case .needsApproval: - phase = .needsApproval + case .cancelled: + phase = .confirm case let .failed(message): phase = .done(output: message + "\n\nOr install it from Terminal:\n sudo " + EngramModel.bundledEngramPath + " install", success: false) diff --git a/Engram/Engram/PrivilegedInstaller.swift b/Engram/Engram/PrivilegedInstaller.swift index 8a90423..53b5636 100644 --- a/Engram/Engram/PrivilegedInstaller.swift +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -1,117 +1,71 @@ -import EngramCore import Foundation -import ServiceManagement -/// Drives the privileged helper (ADR 0022): registers the bundled LaunchDaemon -/// with `SMAppService`, then calls it over XPC to create the -/// `/usr/local/bin/engram` symlink as root. Replaces the user-writable-only -/// `engram install` path for the app's "Install CLI" button. +/// Installs the `/usr/local/bin/engram` symlink with one authenticated prompt +/// (ADR 0022). `/usr/local/bin` is root-owned on Apple Silicon and fresh macOS, +/// so the write needs privilege — we get it by running the symlink through the +/// **Apple-signed `/usr/bin/osascript`** binary's `do shell script … with +/// administrator privileges`. Because the *requesting* process is Apple-signed, +/// the system auth dialog offers Touch ID (when enabled); a non-Apple-signed +/// requester — e.g. calling NSAppleScript in-process from this app — would fall +/// back to a password prompt. No persistent helper, no Login Items, nothing left +/// registered afterward. enum PrivilegedInstaller { enum Outcome { case installed(String) - /// The daemon needs the user to enable Engram in System Settings → Login - /// Items before it can run. System Settings has been opened. - case needsApproval + /// The user dismissed the authentication dialog. + case cancelled case failed(String) } - private static var service: SMAppService { - SMAppService.daemon(plistName: HelperConstants.daemonPlistName) + static func install(source: String = EngramModel.bundledEngramPath) async -> Outcome { + await Task.detached(priority: .userInitiated) { runOSAScript(source: source) }.value } - /// Registers + (if needed) prompts approval for the daemon, then asks it to - /// install the symlink. - static func install() async -> Outcome { - switch ensureRegistered() { - case .ready: - break - case .needsApproval: - return .needsApproval - case let .failed(message): - return .failed(message) - } - return await callHelper() - } - - private enum Registration { - case ready - case needsApproval - case failed(String) - } + private static func runOSAScript(source: String) -> Outcome { + let dest = "/usr/local/bin/engram" + // -sfn: replace any existing file/symlink atomically; -n so an existing + // symlink-to-dir is treated as a file, not followed into. + let shellCommand = "/bin/mkdir -p /usr/local/bin && /bin/ln -sfn " + + shellQuoted(source) + " " + shellQuoted(dest) + let appleScript = "do shell script \"" + appleScriptEscaped(shellCommand) + + "\" with administrator privileges" - private static func ensureRegistered() -> Registration { - let service = self.service - switch service.status { - case .enabled: - return .ready - case .requiresApproval: - SMAppService.openSystemSettingsLoginItems() - return .needsApproval - case .notRegistered, .notFound: - do { - try service.register() - } catch { - if service.status == .requiresApproval { - SMAppService.openSystemSettingsLoginItems() - return .needsApproval - } - return .failed("Couldn't register the privileged helper: \(error.localizedDescription)") - } - // Registration on a fresh machine lands in requiresApproval until the - // user toggles Engram on in Login Items. - if service.status == .requiresApproval { - SMAppService.openSystemSettingsLoginItems() - return .needsApproval - } - return service.status == .enabled - ? .ready - : .failed("Helper didn't enable (status \(service.status.rawValue)).") - @unknown default: - return .failed("Unexpected helper status (\(service.status.rawValue)).") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", appleScript] + let errorPipe = Pipe() + process.standardOutput = Pipe() + process.standardError = errorPipe + do { + try process.run() + process.waitUntilExit() + } catch { + return .failed("Couldn't run the installer: \(error.localizedDescription)") + } + if process.terminationStatus == 0 { + return .installed("installed engram → \(dest)") } + let message = String( + data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // osascript reports a user-cancelled auth dialog as error -128. + if message.contains("-128") || message.localizedCaseInsensitiveContains("cancel") { + return .cancelled + } + return .failed(message.isEmpty ? "Install failed." : message) } - private static func callHelper() async -> Outcome { - await withCheckedContinuation { continuation in - let connection = NSXPCConnection(machServiceName: HelperConstants.machServiceName, - options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: EngramHelperProtocol.self) - connection.resume() - - // The error handler and the reply both run on XPC's queue and are - // mutually exclusive in practice, but guard against a double resume. - let once = Once() - let finish: (Outcome) -> Void = { outcome in - once.run { - connection.invalidate() - continuation.resume(returning: outcome) - } - } - - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - finish(.failed("Couldn't reach the helper: \(error.localizedDescription)")) - } - guard let helper = proxy as? EngramHelperProtocol else { - finish(.failed("Couldn't create the helper proxy.")) - return - } - helper.installCLI { success, message in - finish(success ? .installed(message) : .failed(message)) - } - } + /// Single-quotes a string for /bin/sh, escaping embedded single quotes. Both + /// paths here are app-derived, not user text, but quote anyway so an unusual + /// install location can't break (or inject into) the command. + private static func shellQuoted(_ string: String) -> String { + "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'" } -} -/// One-shot guard so a continuation is resumed exactly once across the XPC -/// reply and error-handler callbacks. -private final class Once: @unchecked Sendable { - private var done = false - private let lock = NSLock() - func run(_ block: () -> Void) { - lock.lock() - defer { lock.unlock() } - guard !done else { return } - done = true - block() + /// Escapes a string to sit inside an AppleScript double-quoted literal. + private static func appleScriptEscaped(_ string: String) -> String { + string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") } } diff --git a/Engram/org.klevan.Engram.helper.plist b/Engram/org.klevan.Engram.helper.plist deleted file mode 100644 index ddfc9e6..0000000 --- a/Engram/org.klevan.Engram.helper.plist +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Label - org.klevan.Engram.helper - BundleProgram - Contents/Helpers/engram - ProgramArguments - - Contents/Helpers/engram - _helper-daemon - - MachServices - - org.klevan.Engram.helper - - - AssociatedBundleIdentifiers - - org.klevan.Engram - - - diff --git a/Engram/scripts/bundle-cli.sh b/Engram/scripts/bundle-cli.sh index 43a9735..b375995 100755 --- a/Engram/scripts/bundle-cli.sh +++ b/Engram/scripts/bundle-cli.sh @@ -29,11 +29,3 @@ if [ "${CODE_SIGNING_ALLOWED:-YES}" = "YES" ] && [ -n "${EXPANDED_CODE_SIGN_IDEN fi echo "note: bundled engram → $DEST_DIR/engram" - -# Bundle the privileged-helper LaunchDaemon plist (ADR 0022). SMAppService loads -# it from Contents/Library/LaunchDaemons/; the plist itself isn't signed (the -# enclosing app bundle is), so just copy it into place. -DAEMON_DIR="$CODESIGNING_FOLDER_PATH/Contents/Library/LaunchDaemons" -mkdir -p "$DAEMON_DIR" -cp -f "$SRCROOT/org.klevan.Engram.helper.plist" "$DAEMON_DIR/org.klevan.Engram.helper.plist" -echo "note: bundled helper daemon plist → $DAEMON_DIR/org.klevan.Engram.helper.plist" diff --git a/Package.swift b/Package.swift index 8f9452f..03eadfd 100644 --- a/Package.swift +++ b/Package.swift @@ -24,8 +24,7 @@ let package = Package( name: "EngramCore", dependencies: ["CSQLite"], linkerSettings: [ - .linkedFramework("NaturalLanguage"), - .linkedFramework("Security"), + .linkedFramework("NaturalLanguage") ] ), .executableTarget( diff --git a/README.md b/README.md index 2bf7008..b9774c3 100644 --- a/README.md +++ b/README.md @@ -217,11 +217,11 @@ engram setup # write the recall (UserPromptSubmit) + verify-context (Session ``` `/usr/local/bin` is root-owned on Apple Silicon and on fresh macOS, so the app's -**Install CLI** button writes the symlink through a privileged helper -(`SMAppService` + XPC; ADR 0022) — macOS asks you to enable Engram in System -Settings → Login Items the first time. If you decline, `sudo engram install` -from the terminal does the same thing. Both are idempotent; `engram setup` backs -up `~/.claude/settings.json` before editing it. +**Install CLI** button writes the symlink with one authenticated prompt — a +single Touch-ID/password dialog via `osascript … with administrator privileges`, +leaving no daemon or login item behind (ADR 0022). If you decline, `sudo engram +install` from the terminal does the same thing. Both are idempotent; `engram +setup` backs up `~/.claude/settings.json` before editing it. ## Embedding quality diff --git a/Sources/EngramCore/HelperDaemon.swift b/Sources/EngramCore/HelperDaemon.swift deleted file mode 100644 index 596fe3c..0000000 --- a/Sources/EngramCore/HelperDaemon.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation -import Security - -/// The privileged helper daemon (ADR 0022). Runs as root, launched by -/// `SMAppService` as the hidden `engram _helper-daemon` subcommand. It vends a -/// single XPC method that installs the CLI symlink, and only after validating -/// that the connecting client is our signed app. -public final class HelperDaemon: NSObject, NSXPCListenerDelegate, EngramHelperProtocol { - private let listener: NSXPCListener - - public override init() { - listener = NSXPCListener(machServiceName: HelperConstants.machServiceName) - super.init() - listener.delegate = self - } - - /// Services XPC connections forever. Call from the daemon process; never returns. - public func run() -> Never { - listener.resume() - dispatchMain() - } - - // MARK: - NSXPCListenerDelegate - - public func listener(_ listener: NSXPCListener, - shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - guard HelperDaemon.isClientTrusted(newConnection) else { return false } - newConnection.exportedInterface = NSXPCInterface(with: EngramHelperProtocol.self) - newConnection.exportedObject = self - newConnection.resume() - return true - } - - // MARK: - EngramHelperProtocol - - /// Installs `/usr/local/bin/engram` → the daemon's own bundled binary. Both - /// paths are fixed/derived here, never taken from the client (ADR 0022), so - /// the helper can only ever install itself. - public func installCLI(withReply reply: @escaping (Bool, String) -> Void) { - guard let source = Bundle.main.executablePath else { - reply(false, "could not locate the bundled engram binary") - return - } - let dest = HelperConstants.installDestination - let fileManager = FileManager.default - do { - try fileManager.createDirectory(atPath: (dest as NSString).deletingLastPathComponent, - withIntermediateDirectories: true) - if fileManager.fileExists(atPath: dest) || HelperDaemon.isSymlink(atPath: dest) { - try fileManager.removeItem(atPath: dest) - } - try fileManager.createSymbolicLink(atPath: dest, withDestinationPath: source) - reply(true, "installed engram → \(dest)") - } catch { - reply(false, "\(error)") - } - } - - // MARK: - Validation - - /// `fileExists` follows symlinks, so a dangling link reports false; check the - /// symbolic-link attribute directly to catch that case too. - private static func isSymlink(atPath path: String) -> Bool { - (try? FileManager.default.attributesOfItem(atPath: path)[.type] as? FileAttributeType) == .typeSymbolicLink - } - - /// Validates the connecting client's code signature against our app's - /// requirement (ADR 0022) via the connection's audit token. Reject anything - /// that isn't our notarized app, so the root helper can't be driven by other - /// processes. - static func isClientTrusted(_ connection: NSXPCConnection) -> Bool { - guard let token = auditToken(of: connection) else { return false } - let tokenData = withUnsafeBytes(of: token) { Data($0) } as CFData - let attributes = [kSecGuestAttributeAudit: tokenData] as CFDictionary - - var code: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess, - let guestCode = code else { return false } - - var requirement: SecRequirement? - guard SecRequirementCreateWithString(HelperConstants.clientCodeRequirement as CFString, - [], &requirement) == errSecSuccess, - let clientRequirement = requirement else { return false } - - return SecCodeCheckValidity(guestCode, [], clientRequirement) == errSecSuccess - } - - /// NSXPCConnection doesn't expose its audit token publicly; read it via KVC. - /// This is the established pattern for secure XPC peer validation. - private static func auditToken(of connection: NSXPCConnection) -> audit_token_t? { - guard let value = connection.value(forKey: "auditToken") as? NSValue else { return nil } - var token = audit_token_t() - value.getValue(&token, size: MemoryLayout.size) - return token - } -} diff --git a/Sources/EngramCore/HelperProtocol.swift b/Sources/EngramCore/HelperProtocol.swift deleted file mode 100644 index 21f53c4..0000000 --- a/Sources/EngramCore/HelperProtocol.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// Shared constants for the privileged helper (ADR 0022). Both the daemon (the -/// bundled `engram` binary run as `engram _helper-daemon`) and the app's XPC -/// client refer to these, so they live in EngramCore — the one target both link. -public enum HelperConstants { - /// The Mach service the daemon vends and the app connects to. Must match the - /// `MachServices` key and `Label` in the bundled LaunchDaemon plist. - public static let machServiceName = "org.klevan.Engram.helper" - - /// The LaunchDaemon plist `SMAppService.daemon(plistName:)` registers. Must - /// match the file bundled at `Contents/Library/LaunchDaemons/`. - public static let daemonPlistName = "org.klevan.Engram.helper.plist" - - /// Where the CLI symlink is installed. Hard-coded — the helper never takes a - /// destination from the client (ADR 0022). - public static let installDestination = "/usr/local/bin/engram" - - /// Code-signing requirement every connecting client must satisfy: our app, - /// signed by our team (ADR 0022). Team ID from `.github/ExportOptions.plist`. - public static let clientCodeRequirement = - "anchor apple generic and identifier \"org.klevan.Engram\" " - + "and certificate leaf[subject.OU] = \"M2RXQJGK5A\"" -} - -/// The XPC contract between the app and the privileged helper. One method: the -/// daemon installs the CLI symlink as root and reports back. Reply types are -/// Objective-C bridgeable so the interface is `@objc`-compatible. -@objc public protocol EngramHelperProtocol { - /// Creates the `/usr/local/bin/engram` symlink pointing at the daemon's own - /// (bundled) binary. `success` reports the outcome; `message` is a - /// human-readable result or error suitable for showing in the app. - func installCLI(withReply reply: @escaping (_ success: Bool, _ message: String) -> Void) -} diff --git a/Sources/engram/main.swift b/Sources/engram/main.swift index fc14331..a9759bc 100644 --- a/Sources/engram/main.swift +++ b/Sources/engram/main.swift @@ -157,11 +157,6 @@ switch command { case "install": do { print(try Setup.installCLI()) } catch { fail("\(error)") } exit(0) -case "_helper-daemon": - // Hidden: the privileged helper entry point (ADR 0022). Launched as root by - // SMAppService via the bundled LaunchDaemon plist, not run by users. Blocks - // forever servicing XPC connections. - HelperDaemon().run() case "setup": // Default installs both; --hooks / --skills narrow it. let onlyHooks = flags.contains("--hooks") @@ -455,12 +450,23 @@ do { let gateConfig = RecallGate.config(forEmbedderSignature: await store.embedderSignature) let confident = RecallGate.select(results, query: prompt, config: gateConfig) + // Session-scoped cooldown (ADR 0023): a memory already injected via + // recall earlier in this session is dropped, so the same note doesn't + // re-appear on every on-topic prompt. No session id → nothing suppressed. + let sessionID = payload["session_id"] as? String ?? "" + let suppressed = (try? await store.recentlyInjectedInSession( + confident.map(\.memory.id), sessionID: sessionID, + within: MemoryStore.recallReinjectionCooldown)) ?? [] + let fresh = confident.filter { !suppressed.contains($0.memory.id) } + // Two independent sections: recalled notes (when confident) and a // periodic reflection nudge (every Nth prompt). Either may be empty. var sections: [String] = [] - if !confident.isEmpty { - try? await store.recordRetrieval(memoryIDs: confident.map(\.memory.id), source: .recall, query: prompt) - let bullets = confident.map { "- \($0.memory.content)" }.joined(separator: "\n") + if !fresh.isEmpty { + try? await store.recordRetrieval( + memoryIDs: fresh.map(\.memory.id), source: .recall, query: prompt, + sessionID: sessionID.isEmpty ? nil : sessionID) + let bullets = fresh.map { "- \($0.memory.content)" }.joined(separator: "\n") sections.append(untrustedMemoryBlock( lead: "Possibly relevant notes from Engram (ignore if off-topic):", body: bullets diff --git a/docs/adr/0022-privileged-helper-for-cli-install.md b/docs/adr/0022-privileged-helper-for-cli-install.md index dd95a58..b248502 100644 --- a/docs/adr/0022-privileged-helper-for-cli-install.md +++ b/docs/adr/0022-privileged-helper-for-cli-install.md @@ -1,4 +1,4 @@ -# 22. Privileged helper (SMAppService + XPC) for installing the CLI symlink +# 22. Privileged CLI install via a one-shot authenticated `osascript` - **Status:** Accepted - **Date:** 2026-06-22 @@ -7,88 +7,79 @@ ## Context -The app's "Install the engram CLI" button calls `Setup.installCLI()`, which -writes `/usr/local/bin/engram`. `/usr/local/bin` is on `/etc/paths` on every -macOS machine, so it's the correct install target — but it is only -user-writable on Intel Macs where Homebrew has `chown`ed `/usr/local` to the -user. On Apple Silicon (Homebrew lives in `/opt/homebrew`) and on fresh macOS -installs, `/usr/local` stays root-owned and the write fails with -`NSCocoaErrorDomain 513` / `EACCES`. `~/.local/bin` is not a viable fallback — -it isn't on the default `$PATH`, so the installed hooks (which reference the +The app's "Install the engram CLI" button writes `/usr/local/bin/engram`. +That directory is on `/etc/paths` everywhere (so it's the correct target) but is +only user-writable on Intel Macs where Homebrew has `chown`ed `/usr/local`. On +Apple Silicon and on fresh macOS it stays root-owned, and the write fails with +`NSCocoaErrorDomain 513` / `EACCES`. `~/.local/bin` isn't a viable fallback — +it's not on the default `$PATH`, so the installed hooks (which reference the absolute binary path) would silently break. -A first, short-term mitigation (issue #4 "short-term fix", separate change) -switched the copy to a symlink — fixing post-update version drift — and -surfaced a clear "run `sudo engram install` in Terminal" message when the write -is denied. That keeps the failure legible but still drops the user to a -terminal; the button does not actually install. +A short-term mitigation (separate change) switched the copy to a symlink — fixing +post-update version drift — and surfaced a "run `sudo engram install`" message on +failure. That keeps the failure legible but still drops the user to a terminal. +For a one-click install the app needs to perform the write with privilege. -For a one-click install with no Terminal, the app must perform the write with -elevated privileges. The supported post-macOS-13 mechanism is a -`SMAppService`-registered **LaunchDaemon** that the app drives over **XPC**; -the deprecated `AuthorizationExecuteWithPrivileges` and the older -`SMJobBless` flow are explicitly avoided. +We surveyed the privilege options and found there is **no** modern, +non-deprecated, leftover-free, Touch-ID-capable one-shot API — you pick two of +{modern, no persistent artifact, Touch ID}: -## Decision - -Add a privileged helper that creates the `/usr/local/bin/engram` symlink as -root, reached from the app over XPC and registered with `SMAppService`. +| Mechanism | Prompt | Touch ID | Leaves behind | +|---|---|---|---| +| `SMAppService.daemon` + XPC | Login Items toggle in System Settings | no | a registered root daemon, forever | +| `SMJobBless` + Authorization Services | native auth dialog | yes | a privileged helper tool | +| `AuthorizationExecuteWithPrivileges` | native auth dialog | yes | nothing — but deprecated/removed | +| `osascript … do shell script … with administrator privileges` | native auth dialog | **yes¹** | nothing | -1. **The daemon is the already-bundled `engram` binary**, not a new build - product. It is invoked as a hidden `engram _helper-daemon` subcommand. This - avoids a second signed executable and a second Xcode target: the CLI is - already bundled at `Contents/Helpers/engram` and signed with the app's - identity by `bundle-cli.sh`. The daemon code itself lives in `EngramCore` - (`HelperProtocol.swift`, `HelperDaemon.swift`) so both the CLI and the app - share one definition of the XPC contract. +¹ The SecurityAgent authorization plug-in only offers Touch ID when the process +*requesting* authorization is **Apple-signed**. Running the Apple-signed +`/usr/bin/osascript` binary satisfies that; calling NSAppleScript in-process from +our (Developer-ID-signed) app would not, and would fall back to a password. -2. **A LaunchDaemon plist** (`org.klevan.Engram.helper.plist`) is bundled at - `Contents/Library/LaunchDaemons/` (where `SMAppService.daemon(plistName:)` - requires it), with `BundleProgram` → `Contents/Helpers/engram`, - `ProgramArguments` ending in `_helper-daemon`, and a `MachServices` entry for - the Mach service `org.klevan.Engram.helper`. +An `SMAppService` LaunchDaemon — initially prototyped here — is the wrong shape +for a one-shot symlink: it registers a *persistent* root daemon and forces the +user through a Login Items toggle (macOS Ventura's Background Task Management +consent), leaving a daemon registered forever for a once-ever action. -3. **The app** registers the daemon via `SMAppService.daemon(plistName:)`. On - first use macOS routes the user to System Settings → Login Items for - approval (`.requiresApproval`); once enabled the app opens an - `NSXPCConnection(machServiceName:options:.privileged)` and calls the one - helper method. +## Decision -4. **The helper accepts no client-controlled paths.** Both ends of the symlink - are fixed/derived inside the daemon: the destination is the constant - `/usr/local/bin/engram`, and the source is the daemon's own - `Bundle.main.executablePath` (i.e. the bundled CLI). So the helper is not a - general root-symlink primitive — it can only install *itself*. +Install the symlink with a single authenticated command, run through the +Apple-signed `osascript`: -5. **The daemon validates every connecting client's code signature** before - vending the object: it reads the connection's audit token, copies the - guest `SecCode`, and checks it against the requirement `anchor apple generic - and identifier "org.klevan.Engram" and certificate leaf[subject.OU] = - "M2RXQJGK5A"` (team ID `M2RXQJGK5A`, from `.github/ExportOptions.plist`). - Connections that fail validation are rejected. +``` +/usr/bin/osascript -e 'do shell script + "/bin/mkdir -p /usr/local/bin && /bin/ln -sfn /usr/local/bin/engram" + with administrator privileges' +``` -The short-term Terminal fallback (`sudo engram install`) remains as a secondary -path for users who decline the System Settings approval. +- The app (`PrivilegedInstaller`) spawns `/usr/bin/osascript` as a subprocess. + Because the requester is Apple-signed, macOS shows the standard authentication + dialog **with Touch ID** when the user has it enabled, otherwise a password + field. Cancelling (`osascript` exit, error `-128`) returns the sheet to its + confirm state; other non-zero exits surface the error plus the + `sudo … install` terminal fallback. +- `` is the bundled CLI at `Contents/Helpers/engram`; both paths are + app-derived (never user input) and still shell-quoted + AppleScript-escaped so + an unusual install location can't break or inject into the command. +- Nothing persists: no daemon, no Login Items entry, no helper tool in + `/Library/PrivilegedHelperTools`, no XPC service, no extra entitlement. The app + stays non-sandboxed (ADR 0003), which this approach requires. ## Consequences -- One-click privileged install on every Mac, no Homebrew assumption, no manual - Terminal step in the happy path. +- One authenticated dialog, Touch ID when available, and the machine is left + exactly as before save for the symlink — matching a dev tool's "install once" + mental model. - The symlink keeps the CLI tracking the current app version across Sparkle updates (the short-term symlink change, now performed with privilege). -- **Signing-dependent and not locally verifiable.** Daemon registration, the - System Settings approval, and the XPC code-signing check only work on a - Developer-ID-signed, notarized build (ADR 0010); `swift build` / `make test` - cannot exercise them. The team ID and bundle identifier are baked into the - requirement string, so a re-org of either must update `HelperConstants`. -- New surface to keep signed: the daemon is the same `engram` binary, so no - extra signing step, but the `Contents/Library/LaunchDaemons/` plist must be - bundled (a Copy Files build phase) and the binary must remain signed with the - team identity for the requirement check to pass. -- Mixing a root daemon entry point into the user-facing CLI binary widens that - binary's role; it is mitigated by (4) and (5) — the daemon path takes no - external input and refuses unverified callers — but it is a deliberate - trade against shipping a second executable. -- Reversible: if the helper proves troublesome, the app falls back to the - Terminal path and the daemon plist/subcommand can be dropped without touching - storage or the integration model. +- **Costs of the `osascript` route (accepted):** the auth dialog's wording isn't + customizable and names the requester (`osascript`), so the in-app sheet + explains what's about to happen first; and it's a fork/exec of a system binary + rather than a typed Swift API. Each invocation re-authenticates — fine for a + once-ever install. +- We forgo Touch-ID-free silent re-runs. If Engram ever needs *repeated* silent + privileged operations (e.g. an uninstaller, or re-linking on every app move), a + persistent helper (`SMAppService`/`SMJobBless`) would become justified and + warrant a new ADR; for now it's unjustified complexity. +- Verifiable locally: this is just a subprocess, so the flow runs on any signed + dev build — no notarization or daemon registration required to exercise it. diff --git a/docs/adr/README.md b/docs/adr/README.md index 500be8f..5793417 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,7 +31,7 @@ supersedes the old one (and update the old one's status to `Superseded by NNNN`) | [0019](0019-tag-centric-exploration.md) | Tag-centric exploration: Tags list + bipartite tag-graph (supersedes 0018 Map/Structure) | Accepted | | [0020](0020-unified-activity-timeline.md) | Unified Activity timeline: reads + writes in one stream (extends 0015) | Accepted | | [0021](0021-embedder-relative-recall-gate.md) | Embedder-relative recall gate, calibrated by offline eval (refines 0005's gate) | Accepted | -| [0022](0022-privileged-helper-for-cli-install.md) | Privileged helper (SMAppService + XPC) for installing the CLI symlink | Accepted | +| [0022](0022-privileged-helper-for-cli-install.md) | Privileged CLI install via a one-shot authenticated `osascript` | Accepted | ## Writing a new ADR From 3b2fa83de5a22091ca132f9806ed1b9acdfd9cbd Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 20:47:06 +0200 Subject: [PATCH 3/7] =?UTF-8?q?docs(install):=20correct=20osascript=20prom?= =?UTF-8?q?pt=20=E2=80=94=20password,=20not=20Touch=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device testing showed `do shell script … with administrator privileges` always presents a password dialog, not Touch ID — it doesn't route through the biometric authorization path even though osascript is Apple-signed. Fix the overstated Touch ID claim in ADR 0022, README, the install sheet, and the PrivilegedInstaller doc comment; note that Touch ID would require a privileged helper, which this approach deliberately avoids. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- Engram/Engram/InstallSheet.swift | 2 +- Engram/Engram/PrivilegedInstaller.swift | 13 ++++--- README.md | 8 ++--- .../0022-privileged-helper-for-cli-install.md | 34 +++++++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04051d5..38f5c12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ and recall content. See `README.md` for architecture and build instructions. - `Sources/engram` — the `engram` CLI (store / fetch / stats / activity / hook) - `Sources/CSQLite` — vendored SQLite + sqlite-vec (static C target) - `Sources/engram/Setup.swift` — install logic (`engram install` / `engram setup`); the single source of truth for installing the CLI, hook, and skills. `engram install` symlinks `/usr/local/bin/engram` → the running binary -- `Engram/Engram/PrivilegedInstaller.swift` — app-side privileged install (ADR 0022): runs the symlink through the Apple-signed `/usr/bin/osascript` (`do shell script … with administrator privileges`) for one Touch-ID/password dialog, no persistent helper; backs the toolbar **Install CLI** button +- `Engram/Engram/PrivilegedInstaller.swift` — app-side privileged install (ADR 0022): runs the symlink through the Apple-signed `/usr/bin/osascript` (`do shell script … with administrator privileges`) for one password dialog, no persistent helper; backs the toolbar **Install CLI** button - `Engram/` — the Xcode SwiftUI app (thin shell over `EngramCore`); not sandboxed (ADR 0003) - `Engram/Engram/SettingsView.swift` — Settings window (⌘,) with the Sparkle-backed Updates pane (ADR 0010) - `Engram/Engram/ContentView.swift` — the native `NavigationSplitView` shell: sidebar (lenses + facet filters), detail container, one toolbar, trailing inspector (ADR 0016) diff --git a/Engram/Engram/InstallSheet.swift b/Engram/Engram/InstallSheet.swift index fd0e139..c76b75d 100644 --- a/Engram/Engram/InstallSheet.swift +++ b/Engram/Engram/InstallSheet.swift @@ -44,7 +44,7 @@ enum InstallKind: Identifiable, Equatable { return [ "Symlinks /usr/local/bin/engram to the bundled CLI", "Lets Claude Code and your terminal run engram", - "Asks for your password (or Touch ID) to write there", + "Asks for your password to write there", ] case .integration: return [ diff --git a/Engram/Engram/PrivilegedInstaller.swift b/Engram/Engram/PrivilegedInstaller.swift index 53b5636..281c052 100644 --- a/Engram/Engram/PrivilegedInstaller.swift +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -2,13 +2,12 @@ import Foundation /// Installs the `/usr/local/bin/engram` symlink with one authenticated prompt /// (ADR 0022). `/usr/local/bin` is root-owned on Apple Silicon and fresh macOS, -/// so the write needs privilege — we get it by running the symlink through the -/// **Apple-signed `/usr/bin/osascript`** binary's `do shell script … with -/// administrator privileges`. Because the *requesting* process is Apple-signed, -/// the system auth dialog offers Touch ID (when enabled); a non-Apple-signed -/// requester — e.g. calling NSAppleScript in-process from this app — would fall -/// back to a password prompt. No persistent helper, no Login Items, nothing left -/// registered afterward. +/// so the write needs privilege — we get it by running the symlink through +/// `/usr/bin/osascript`'s `do shell script … with administrator privileges`, +/// which shows the standard admin **password** dialog. (It does not offer Touch +/// ID — `do shell script` doesn't route through the biometric authorization +/// path, confirmed on-device; Touch ID would require a privileged helper, ADR +/// 0022.) No persistent helper, no Login Items, nothing left registered after. enum PrivilegedInstaller { enum Outcome { case installed(String) diff --git a/README.md b/README.md index b9774c3..2125788 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,10 @@ engram setup # write the recall (UserPromptSubmit) + verify-context (Session `/usr/local/bin` is root-owned on Apple Silicon and on fresh macOS, so the app's **Install CLI** button writes the symlink with one authenticated prompt — a -single Touch-ID/password dialog via `osascript … with administrator privileges`, -leaving no daemon or login item behind (ADR 0022). If you decline, `sudo engram -install` from the terminal does the same thing. Both are idempotent; `engram -setup` backs up `~/.claude/settings.json` before editing it. +single password dialog via `osascript … with administrator privileges`, leaving +no daemon or login item behind (ADR 0022). If you decline, `sudo engram install` +from the terminal does the same thing. Both are idempotent; `engram setup` backs +up `~/.claude/settings.json` before editing it. ## Embedding quality diff --git a/docs/adr/0022-privileged-helper-for-cli-install.md b/docs/adr/0022-privileged-helper-for-cli-install.md index b248502..249c1bb 100644 --- a/docs/adr/0022-privileged-helper-for-cli-install.md +++ b/docs/adr/0022-privileged-helper-for-cli-install.md @@ -29,12 +29,15 @@ non-deprecated, leftover-free, Touch-ID-capable one-shot API — you pick two of | `SMAppService.daemon` + XPC | Login Items toggle in System Settings | no | a registered root daemon, forever | | `SMJobBless` + Authorization Services | native auth dialog | yes | a privileged helper tool | | `AuthorizationExecuteWithPrivileges` | native auth dialog | yes | nothing — but deprecated/removed | -| `osascript … do shell script … with administrator privileges` | native auth dialog | **yes¹** | nothing | +| `osascript … do shell script … with administrator privileges` | password prompt | **no¹** | nothing | -¹ The SecurityAgent authorization plug-in only offers Touch ID when the process -*requesting* authorization is **Apple-signed**. Running the Apple-signed -`/usr/bin/osascript` binary satisfies that; calling NSAppleScript in-process from -our (Developer-ID-signed) app would not, and would fall back to a password. +¹ We initially expected Touch ID here (the requesting process, `/usr/bin/osascript`, +is Apple-signed), but on-device testing showed `do shell script … with +administrator privileges` always presents a **password** prompt — it doesn't +route through the biometric-enabled authorization path. Touch ID for a privileged +file op in practice requires a helper (the Authorization Services / `SMJobBless` +route), which we deliberately don't take. The password prompt also names the +requester ("osascript wants to make changes"), which isn't customizable. An `SMAppService` LaunchDaemon — initially prototyped here — is the wrong shape for a one-shot symlink: it registers a *persistent* root daemon and forces the @@ -53,11 +56,10 @@ Apple-signed `osascript`: ``` - The app (`PrivilegedInstaller`) spawns `/usr/bin/osascript` as a subprocess. - Because the requester is Apple-signed, macOS shows the standard authentication - dialog **with Touch ID** when the user has it enabled, otherwise a password - field. Cancelling (`osascript` exit, error `-128`) returns the sheet to its - confirm state; other non-zero exits surface the error plus the - `sudo … install` terminal fallback. + macOS shows the standard admin authentication dialog (a password prompt — not + Touch ID; see the table note). Cancelling (`osascript` exit, error `-128`) + returns the sheet to its confirm state; other non-zero exits surface the error + plus the `sudo … install` terminal fallback. - `` is the bundled CLI at `Contents/Helpers/engram`; both paths are app-derived (never user input) and still shell-quoted + AppleScript-escaped so an unusual install location can't break or inject into the command. @@ -67,9 +69,10 @@ Apple-signed `osascript`: ## Consequences -- One authenticated dialog, Touch ID when available, and the machine is left - exactly as before save for the symlink — matching a dev tool's "install once" - mental model. +- One authenticated dialog (a password prompt) and the machine is left exactly + as before save for the symlink — matching a dev tool's "install once" mental + model. No Touch ID (see the survey table); accepted as a fair trade for zero + persistence. - The symlink keeps the CLI tracking the current app version across Sparkle updates (the short-term symlink change, now performed with privilege). - **Costs of the `osascript` route (accepted):** the auth dialog's wording isn't @@ -77,9 +80,10 @@ Apple-signed `osascript`: explains what's about to happen first; and it's a fork/exec of a system binary rather than a typed Swift API. Each invocation re-authenticates — fine for a once-ever install. -- We forgo Touch-ID-free silent re-runs. If Engram ever needs *repeated* silent +- We forgo silent (no-prompt) re-runs. If Engram ever needs *repeated* silent privileged operations (e.g. an uninstaller, or re-linking on every app move), a persistent helper (`SMAppService`/`SMJobBless`) would become justified and - warrant a new ADR; for now it's unjustified complexity. + warrant a new ADR; for now it's unjustified complexity. (A helper would also be + the only way to get the Touch ID dialog, if that ever becomes a priority.) - Verifiable locally: this is just a subprocess, so the flow runs on any signed dev build — no notarization or daemon registration required to exercise it. From c5d64755967145f2d3de71759184e306de00b5ee Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 21:21:41 +0200 Subject: [PATCH 4/7] fix(install): symlink engram install + clear permission-denied message Brings the terminal `engram install` into line with the app's privileged install: symlink /usr/local/bin/engram (no more copy/version-drift), and on a non-writable dir throw a clear "run sudo engram install" message instead of a raw NSError. This is also the command the app's failure fallback points users at. Ported from the short-term fix branch (claude/issue-1-discussion-33lhp5), which this folds in and supersedes. Co-Authored-By: Claude Opus 4.8 --- Sources/engram/Setup.swift | 39 ++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/Sources/engram/Setup.swift b/Sources/engram/Setup.swift index 54d86fe..8a48cf4 100644 --- a/Sources/engram/Setup.swift +++ b/Sources/engram/Setup.swift @@ -21,7 +21,7 @@ enum Setup { // MARK: - engram install - /// Copies the running binary to `/usr/local/bin/engram`. + /// Symlinks `/usr/local/bin/engram` to the running binary. static func installCLI() throws -> String { guard let source = Bundle.main.executablePath else { throw SetupError("could not locate the running engram binary") @@ -32,12 +32,43 @@ enum Setup { } let fm = FileManager.default try fm.createDirectory(atPath: installPrefix, withIntermediateDirectories: true) - if fm.fileExists(atPath: dest) { try fm.removeItem(atPath: dest) } - try fm.copyItem(atPath: source, toPath: dest) - try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dest) + // Remove any existing file or symlink at the destination first. + if fm.fileExists(atPath: dest) || isSymlink(atPath: dest) { + try fm.removeItem(atPath: dest) + } + do { + try fm.createSymbolicLink(atPath: dest, withDestinationPath: source) + } catch { + if isPermissionDenied(error) { + throw SetupError( + "Permission denied writing to \(installPrefix). Run this in Terminal instead:\n\n sudo engram install") + } + throw error + } return "installed engram → \(dest)" } + /// `fileExists` follows symlinks, so a dangling symlink reports false; check + /// the symbolic link attribute directly to catch that case too. + private static func isSymlink(atPath path: String) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: path)[.type] as? FileAttributeType) == .typeSymbolicLink + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == 513 { + return true + } + if let posix = error as? POSIXError, posix.code == .EACCES || posix.code == .EPERM { + return true + } + if nsError.domain == NSPOSIXErrorDomain + && (nsError.code == Int(EACCES) || nsError.code == Int(EPERM)) { + return true + } + return false + } + // MARK: - engram setup static func runSetup(hooks: Bool, skills: Bool) throws -> String { From 6f52ce82bb5f6c65b6477c829b90bc5cac190b6c Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 21:39:16 +0200 Subject: [PATCH 5/7] fix(install): drop ADR 0023 cooldown refs accidentally swept into main.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first install commit's `git add -A` pulled a concurrent session's ADR 0023 recall-cooldown changes into main.swift, but the matching MemoryStore methods never landed on this branch — breaking the build (no member recentlyInjectedInSession). The install work's net change to main.swift is nil, so restore it to main's version. The 0023 feature stays separate. Co-Authored-By: Claude Opus 4.8 --- Sources/engram/main.swift | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/Sources/engram/main.swift b/Sources/engram/main.swift index a9759bc..8d8d098 100644 --- a/Sources/engram/main.swift +++ b/Sources/engram/main.swift @@ -450,23 +450,12 @@ do { let gateConfig = RecallGate.config(forEmbedderSignature: await store.embedderSignature) let confident = RecallGate.select(results, query: prompt, config: gateConfig) - // Session-scoped cooldown (ADR 0023): a memory already injected via - // recall earlier in this session is dropped, so the same note doesn't - // re-appear on every on-topic prompt. No session id → nothing suppressed. - let sessionID = payload["session_id"] as? String ?? "" - let suppressed = (try? await store.recentlyInjectedInSession( - confident.map(\.memory.id), sessionID: sessionID, - within: MemoryStore.recallReinjectionCooldown)) ?? [] - let fresh = confident.filter { !suppressed.contains($0.memory.id) } - // Two independent sections: recalled notes (when confident) and a // periodic reflection nudge (every Nth prompt). Either may be empty. var sections: [String] = [] - if !fresh.isEmpty { - try? await store.recordRetrieval( - memoryIDs: fresh.map(\.memory.id), source: .recall, query: prompt, - sessionID: sessionID.isEmpty ? nil : sessionID) - let bullets = fresh.map { "- \($0.memory.content)" }.joined(separator: "\n") + if !confident.isEmpty { + try? await store.recordRetrieval(memoryIDs: confident.map(\.memory.id), source: .recall, query: prompt) + let bullets = confident.map { "- \($0.memory.content)" }.joined(separator: "\n") sections.append(untrustedMemoryBlock( lead: "Possibly relevant notes from Engram (ignore if off-topic):", body: bullets From c21ad71a3e9e15d7bcfca3e85741f60ff34702df Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 22 Jun 2026 21:56:20 +0200 Subject: [PATCH 6/7] =?UTF-8?q?refactor(install):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20doc=20comment,=20osascript=20timeout,=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder EngramModel so the bundledEngramPath/runBundledEngram doc comments hug their own declarations (the new property had split them). - Add a 120s watchdog to the osascript Process so a wedged auth dialog can't hang the install sheet's spinner; report a timeout via terminationReason. - Fix stale "privileged helper" wording on InstallKind.usesPrivilegedHelper — it's the authenticated osascript path now, no helper. Co-Authored-By: Claude Opus 4.8 --- Engram/Engram/EngramModel.swift | 4 ++-- Engram/Engram/InstallSheet.swift | 4 ++-- Engram/Engram/PrivilegedInstaller.swift | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Engram/Engram/EngramModel.swift b/Engram/Engram/EngramModel.swift index 2eba9f2..e9f8845 100644 --- a/Engram/Engram/EngramModel.swift +++ b/Engram/Engram/EngramModel.swift @@ -440,8 +440,6 @@ final class EngramModel { /// When set, the app presents the install confirmation sheet for this action. var pendingInstall: InstallKind? - /// Runs the CLI shipped inside the app bundle. `nonisolated static` so the - /// install sheet can run it off the main actor without blocking the UI. /// Absolute path to the engram CLI bundled inside the app. Used both to run /// it and to show a runnable `sudo … install` fallback (the CLI isn't on /// $PATH until installed, so the bare name wouldn't resolve). @@ -449,6 +447,8 @@ final class EngramModel { Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/engram").path } + /// Runs the CLI shipped inside the app bundle. `nonisolated static` so the + /// install sheet can run it off the main actor without blocking the UI. nonisolated static func runBundledEngram(_ arguments: [String]) -> (output: String, success: Bool) { // Bundled at Contents/Helpers/engram (not Contents/MacOS — "engram" would // collide with the app binary "Engram" on case-insensitive APFS). diff --git a/Engram/Engram/InstallSheet.swift b/Engram/Engram/InstallSheet.swift index c76b75d..04dfafb 100644 --- a/Engram/Engram/InstallSheet.swift +++ b/Engram/Engram/InstallSheet.swift @@ -57,8 +57,8 @@ enum InstallKind: Identifiable, Equatable { } /// CLI install writes to root-owned /usr/local/bin, so it goes through the - /// privileged helper (ADR 0022); integration install only edits files under - /// the user's home and shells out to the bundled CLI. + /// authenticated osascript install (ADR 0022); integration install only edits + /// files under the user's home and shells out to the bundled CLI. var usesPrivilegedHelper: Bool { switch self { case .cli: return true diff --git a/Engram/Engram/PrivilegedInstaller.swift b/Engram/Engram/PrivilegedInstaller.swift index 281c052..3028a1b 100644 --- a/Engram/Engram/PrivilegedInstaller.swift +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -16,6 +16,10 @@ enum PrivilegedInstaller { case failed(String) } + /// Watchdog ceiling so a wedged auth dialog can't hang the install sheet on + /// its spinner forever. Generous — the user may take a while to authenticate. + private static let timeout: TimeInterval = 120 + static func install(source: String = EngramModel.bundledEngramPath) async -> Outcome { await Task.detached(priority: .userInitiated) { runOSAScript(source: source) }.value } @@ -37,10 +41,20 @@ enum PrivilegedInstaller { process.standardError = errorPipe do { try process.run() - process.waitUntilExit() } catch { return .failed("Couldn't run the installer: \(error.localizedDescription)") } + // Watchdog: terminate a wedged auth dialog so waitUntilExit() can't block + // the sheet's spinner indefinitely. + let watchdog = DispatchWorkItem { if process.isRunning { process.terminate() } } + DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: watchdog) + process.waitUntilExit() + watchdog.cancel() + + if process.terminationReason == .uncaughtSignal { + return .failed( + "The installer timed out waiting for authorization. Try again, or install from Terminal with sudo.") + } if process.terminationStatus == 0 { return .installed("installed engram → \(dest)") } From 3a6d12e8f1c772cb433173c0cc3a43fcff5f87a8 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Tue, 23 Jun 2026 07:00:59 +0200 Subject: [PATCH 7/7] test(install): cover osascript escaping; tighten cancel detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EngramTests for PrivilegedInstaller.shellQuoted / appleScriptEscaped — the security-relevant, pure, testable piece of the privileged install (single-quote escaping, spaces, backslash-then-quote ordering). Helpers made internal for @testable access. - Drop the locale-fragile "cancel" substring from cancellation detection; rely on osascript's -128 (userCancelledErr) alone. Co-Authored-By: Claude Opus 4.8 --- Engram/Engram/PrivilegedInstaller.swift | 11 ++++++----- Engram/EngramTests/EngramTests.swift | 26 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Engram/Engram/PrivilegedInstaller.swift b/Engram/Engram/PrivilegedInstaller.swift index 3028a1b..023a317 100644 --- a/Engram/Engram/PrivilegedInstaller.swift +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -61,8 +61,8 @@ enum PrivilegedInstaller { let message = String( data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8 )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // osascript reports a user-cancelled auth dialog as error -128. - if message.contains("-128") || message.localizedCaseInsensitiveContains("cancel") { + // osascript reports a user-cancelled auth dialog as error -128 (userCancelledErr). + if message.contains("-128") { return .cancelled } return .failed(message.isEmpty ? "Install failed." : message) @@ -70,13 +70,14 @@ enum PrivilegedInstaller { /// Single-quotes a string for /bin/sh, escaping embedded single quotes. Both /// paths here are app-derived, not user text, but quote anyway so an unusual - /// install location can't break (or inject into) the command. - private static func shellQuoted(_ string: String) -> String { + /// install location can't break (or inject into) the command. `internal` for + /// unit testing (this is the one security-relevant, testable piece). + static func shellQuoted(_ string: String) -> String { "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'" } /// Escapes a string to sit inside an AppleScript double-quoted literal. - private static func appleScriptEscaped(_ string: String) -> String { + static func appleScriptEscaped(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") diff --git a/Engram/EngramTests/EngramTests.swift b/Engram/EngramTests/EngramTests.swift index de62df1..e882d9d 100644 --- a/Engram/EngramTests/EngramTests.swift +++ b/Engram/EngramTests/EngramTests.swift @@ -9,6 +9,32 @@ struct EngramTests { // Write your test here and use APIs like `#expect(...)` to check expected conditions. } + // MARK: - Privileged-install escaping (ADR 0022) + + /// `shellQuoted` must wrap in single quotes and neutralise embedded single + /// quotes via the `'\''` idiom, so a path can't break out of the quoting (or + /// inject) when interpolated into the `do shell script` command. + @Test func shellQuotedWrapsAndEscapesSingleQuotes() { + #expect(PrivilegedInstaller.shellQuoted("/Applications/Engram.app") == "'/Applications/Engram.app'") + // Spaces stay safely inside the quotes. + #expect(PrivilegedInstaller.shellQuoted("/My Apps/Engram.app") == "'/My Apps/Engram.app'") + // A single quote is closed, escaped, and reopened. + #expect(PrivilegedInstaller.shellQuoted("/a'b") == "'/a'\\''b'") + // Double quotes and backslashes are inert inside single quotes — left as-is. + #expect(PrivilegedInstaller.shellQuoted(#"/a"b\c"#) == #"'/a"b\c'"#) + } + + /// `appleScriptEscaped` must escape backslashes first, then double quotes, so + /// the shell command sits safely inside the AppleScript double-quoted literal. + @Test func appleScriptEscapedEscapesBackslashThenQuote() { + #expect(PrivilegedInstaller.appleScriptEscaped("plain") == "plain") + #expect(PrivilegedInstaller.appleScriptEscaped(#"a"b"#) == #"a\"b"#) + // Backslash is doubled. + #expect(PrivilegedInstaller.appleScriptEscaped(#"a\b"#) == #"a\\b"#) + // Order matters: a backslash-before-quote becomes \\ then \" (not \\\"). + #expect(PrivilegedInstaller.appleScriptEscaped(#"a\"b"#) == #"a\\\"b"#) + } + // MARK: - Activity selection (issue #2) /// Regression test: clicking a non-first activity row for a memory that appears