diff --git a/CLAUDE.md b/CLAUDE.md index 34c7593..38f5c12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ 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 +- `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/EngramModel.swift b/Engram/Engram/EngramModel.swift index b10a053..e9f8845 100644 --- a/Engram/Engram/EngramModel.swift +++ b/Engram/Engram/EngramModel.swift @@ -440,6 +440,13 @@ final class EngramModel { /// When set, the app presents the install confirmation sheet for this action. var pendingInstall: InstallKind? + /// 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 + } + /// 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) { diff --git a/Engram/Engram/InstallSheet.swift b/Engram/Engram/InstallSheet.swift index f2a1c61..04dfafb 100644 --- a/Engram/Engram/InstallSheet.swift +++ b/Engram/Engram/InstallSheet.swift @@ -32,7 +32,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 +42,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", + "Asks for your password to write there", ] case .integration: return [ @@ -56,6 +56,16 @@ enum InstallKind: Identifiable, Equatable { } } + /// CLI install writes to root-owned /usr/local/bin, so it goes through the + /// 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 + case .integration: return false + } + } + var arguments: [String] { switch self { case .cli: return ["install"] @@ -170,11 +180,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 .cancelled: + phase = .confirm + 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..023a317 --- /dev/null +++ b/Engram/Engram/PrivilegedInstaller.swift @@ -0,0 +1,85 @@ +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 +/// `/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) + /// The user dismissed the authentication dialog. + case cancelled + 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 + } + + 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" + + 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() + } 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)") + } + let message = String( + data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // osascript reports a user-cancelled auth dialog as error -128 (userCancelledErr). + if message.contains("-128") { + return .cancelled + } + return .failed(message.isEmpty ? "Install 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. `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. + 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 diff --git a/README.md b/README.md index 088a81b..2125788 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 with one authenticated prompt — a +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/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 { 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..249c1bb --- /dev/null +++ b/docs/adr/0022-privileged-helper-for-cli-install.md @@ -0,0 +1,89 @@ +# 22. Privileged CLI install via a one-shot authenticated `osascript` + +- **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 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 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. + +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}: + +| 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` | password prompt | **no¹** | nothing | + +¹ 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 +user through a Login Items toggle (macOS Ventura's Background Task Management +consent), leaving a daemon registered forever for a once-ever action. + +## Decision + +Install the symlink with a single authenticated command, run through the +Apple-signed `osascript`: + +``` +/usr/bin/osascript -e 'do shell script + "/bin/mkdir -p /usr/local/bin && /bin/ln -sfn /usr/local/bin/engram" + with administrator privileges' +``` + +- The app (`PrivilegedInstaller`) spawns `/usr/bin/osascript` as a subprocess. + 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. +- 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 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 + 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 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. (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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3e71d0e..5793417 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 CLI install via a one-shot authenticated `osascript` | Accepted | ## Writing a new ADR