Skip to content
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions Engram/Engram/EngramModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 30 additions & 8 deletions Engram/Engram/InstallSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand All @@ -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 [
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
85 changes: 85 additions & 0 deletions Engram/Engram/PrivilegedInstaller.swift
Original file line number Diff line number Diff line change
@@ -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: "\\\"")
}
}
26 changes: 26 additions & 0 deletions Engram/EngramTests/EngramTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ engram delete <uuid>
engram verify [--json] # cheap deterministic checks; one verdict per active memory
engram verified <uuid> [--confidence 0..1] [--json] # mark a memory verified now
engram supersede <old-uuid> "<new content>" --reason "<why>" [--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
```

Expand Down Expand Up @@ -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

Expand Down
39 changes: 35 additions & 4 deletions Sources/engram/Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
Loading
Loading