Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# they are deleted and the target exits 0. Run it again on a clean tree
# and assert it still exits 0 (idempotent).

.PHONY: check clean-runtime e2e-docker install-search-signed install-mpm-signed install-agents-signed publish-check version-parity-check publish-dry-run-order
.PHONY: check clean-runtime e2e-docker install-search-signed install-mpm-signed install-agents-signed install-memory-signed publish-check version-parity-check publish-dry-run-order

# Remove per-crate runtime artefacts that accumulate during development.
#
Expand Down Expand Up @@ -147,6 +147,33 @@ install-mpm-signed:
install-agents-signed:
bash scripts/install-trusty-agents-signed.sh

# Install trusty-memory (trusty-memory + trusty-bm25-daemon +
# trusty-memory-mcp-bridge) with Developer ID signing for persistent macOS TCC
# grants.
#
# Why: owner ruling 2026-08-06 — trusty-memory was missing from the
# `SIGNABLE_BINARIES` table for no recorded reason, so every `cargo install`
# minted a fresh cdhash and revoked its TCC grants. `trusty-memory setup` /
# `migrate` walk $HOME to depth 8 for `.claude/settings*.json` and so touch
# ~/Desktop, ~/Documents, and ~/Downloads — the macOS Files-and-Folders
# categories, keyed by cdhash exactly like FDA (#873) and App Data (#2721).
#
# What: delegates to scripts/install-trusty-memory-signed.sh, which runs
# `cargo install --path crates/trusty-memory --locked` then signs all three
# installed binaries via `tctl sign trusty-memory`. No certificate present
# degrades to an unsigned install with a clear warning — never a hard failure.
#
# Test: run `make install-memory-signed`; assert all three binaries are signed
# with a Developer ID (not ad-hoc) and `codesign --verify --strict` passes for
# each. Run with TRUSTY_CODESIGN_DRY_RUN=1 (or --dry-run) for guidance paths.
#
# Overrides (passed as env vars, not make vars):
# TRUSTY_SIGN_IDENTITY=... override the auto-detected signing identity
# TRUSTY_INSTALL_PATH=... install from an explicit repo checkout path
# TRUSTY_CODESIGN_DRY_RUN=1 skip cargo install + codesign, test guidance only
install-memory-signed:
bash scripts/install-trusty-memory-signed.sh

# Publish-only-from-merged-main preflight guard (issue #2227).
#
# Why: issue #2209 published from an unmerged branch, making a P0-missing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Fixed

- `trusty-memory` and `trusty-analyze` are now signable targets. `tctl sign trusty-memory` signs all three binaries `cargo install --path crates/trusty-memory` produces (`trusty-memory` → `com.trusty.trusty-memory`, `trusty-bm25-daemon` → `com.trusty.trusty-bm25-daemon`, `trusty-memory-mcp-bridge` → `com.trusty.trusty-memory-mcp-bridge`); `tctl sign trusty-analyze` signs its single binary (`com.trusty.trusty-analyze`). Both were absent from `SIGNABLE_BINARIES` for no recorded reason: `trusty-memory setup`/`migrate` and `trusty-analyze setup --global` walk `$HOME` to depth 8 for `.claude/settings*.json`, and that walk skips `Library`/`Applications` but not `~/Desktop`, `~/Documents`, or `~/Downloads` — the same cdhash-keyed TCC exposure that put `trusty-search` ([#873](https://github.com/bobmatnyc/trusty-tools/issues/873)), `tm` ([#2721](https://github.com/bobmatnyc/trusty-tools/issues/2721)), and `tagent` ([#4277](https://github.com/bobmatnyc/trusty-tools/issues/4277)) in the table. `trusty-analyze`'s walk sits behind `--global` rather than its default path, so the exposure is narrower in how often it is reached and identical when it is. New `scripts/install-trusty-memory-signed.sh` / `make install-memory-signed` cover trusty-memory's local-source install; `tctl sign trusty-analyze` is trusty-analyze's whole surface. `trusty-review` was evaluated under the same test and excluded — it makes no `$HOME` walk and reads no other application's files.
- Hardened Runtime follows `trusty-search`'s conservative split (explicit `tctl sign` only, not the automatic hook) for both: `trusty-memory` links `ort`/`fastembed` through `trusty-common`'s `memory-core` and `trusty-analyze` links it directly, so both carry the same unverified ONNX-dylib-under-library-validation exposure.
- Signing establishes a stable designated requirement — the precondition for a durable TCC grant, not proof one was obtained. The script and `docs/reference/release-workflow.md` now say so, and record that macOS attributes Files-and-Folders access to the responsible process (usually the terminal for a shell-invoked CLI, which is exactly the `setup`/`migrate` path), leaving the CLI-path outcome unresolved.
- The `tctl sign` targets named in `signing_persistence_tip` had gone stale after [#4277](https://github.com/bobmatnyc/trusty-tools/issues/4277) (`trusty-agents` was missing). The tip's advertised list is now parsed and compared exactly against `SIGNABLE_BINARIES`, and each `SignTargetArg` variant's clap `#[value(name = …)]` is pinned against its `as_set_name()`, so a set can no longer be added to the table and left unreachable from the CLI by a typo on either side.
29 changes: 23 additions & 6 deletions crates/trusty-installer/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,20 @@ pub enum AnalyzeCoreArg {
/// CLI value for `trusty-installer sign <target>` (#2558).
///
/// Why: A clap-facing enum keeps the `sign` surface declarative and
/// self-documenting (`--help` lists the two valid targets) while
/// self-documenting (`--help` lists the valid targets) while
/// `commands::sign::run` and `commands::macos_signing` work in terms of the
/// plain `&str` set names (`SEARCH_SET` / `MPM_SET`) so that module stays
/// clap-independent.
/// plain `&str` set names (`SEARCH_SET` / `MPM_SET` / …) so that module stays
/// clap-independent. This enum is the REAL gate on `tctl sign <target>` —
/// clap rejects an unlisted value before `commands::sign::run` ever sees it —
/// so a set added to `SIGNABLE_BINARIES` without a variant here is
/// unreachable from the CLI.
///
/// What: `Search` → the `trusty-search` + `trusty-embedderd` set; `Mpm` → the
/// `trusty-mpm` set (owner-authorized scope extension, #2558); `Agents` → the
/// `trusty-agents` set, i.e. `tagent` (owner-authorized scope extension,
/// #4277).
/// #4277); `Memory` → the `trusty-memory` set, i.e. `trusty-memory` +
/// `trusty-bm25-daemon` + `trusty-memory-mcp-bridge`; `Analyze` → the
/// `trusty-analyze` set, a single binary (both owner ruling 2026-08-06).
///
/// Test: `cli_tests::parse_sign` round-trips each value.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
Expand All @@ -143,6 +148,13 @@ pub enum SignTargetArg {
/// `trusty-agents` (`tagent`). (#4277)
#[value(name = "trusty-agents")]
Agents,
/// `trusty-memory` + its bundled `trusty-bm25-daemon` and the deprecated
/// `trusty-memory-mcp-bridge` shim. (owner ruling 2026-08-06)
#[value(name = "trusty-memory")]
Memory,
/// `trusty-analyze` (single binary). (owner ruling 2026-08-06)
#[value(name = "trusty-analyze")]
Analyze,
}

impl SignTargetArg {
Expand All @@ -152,13 +164,18 @@ impl SignTargetArg {
/// `macos_signing::binaries_for_set` / `sign_set_strict`, so that module
/// has no clap dependency.
/// What: `Search` → `"trusty-search"`, `Mpm` → `"trusty-mpm"`, `Agents` →
/// `"trusty-agents"`.
/// Test: `cli_tests::parse_sign` (indirectly, via the round-trip).
/// `"trusty-agents"`, `Memory` → `"trusty-memory"`, `Analyze` →
/// `"trusty-analyze"`.
/// Test: `cli_tests::parse_sign` (indirectly, via the round-trip),
/// `macos_signing::tests::every_sign_target_arg_resolves_to_a_real_set`
/// (every variant maps onto a non-empty `SIGNABLE_BINARIES` set).
pub fn as_set_name(self) -> &'static str {
match self {
SignTargetArg::Search => "trusty-search",
SignTargetArg::Mpm => "trusty-mpm",
SignTargetArg::Agents => "trusty-agents",
SignTargetArg::Memory => "trusty-memory",
SignTargetArg::Analyze => "trusty-analyze",
}
}
}
Expand Down
30 changes: 28 additions & 2 deletions crates/trusty-installer/src/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,9 @@ fn tctl_alias_self_update() {
}

/// `trusty-installer sign trusty-search` / `sign trusty-mpm` / `sign
/// trusty-agents` all parse and round-trip to the right `SignTargetArg`
/// (#2558, #4277).
/// trusty-agents` / `sign trusty-memory` / `sign trusty-analyze` all parse and
/// round-trip to the right `SignTargetArg` (#2558, #4277, owner ruling
/// 2026-08-06).
#[test]
fn parse_sign() {
let search = Cli::try_parse_from(["trusty-installer", "sign", "trusty-search"])
Expand Down Expand Up @@ -536,6 +537,31 @@ fn parse_sign() {
}
other => panic!("expected Sign, got {other:?}"),
}

// Owner ruling 2026-08-06: `trusty-memory` joins as a valid `tctl sign`
// target. This enum is the real gate — clap rejects any value not listed
// here before `commands::sign::run` sees it, so a `SIGNABLE_BINARIES` row
// alone would leave the set unreachable from the CLI.
let memory = Cli::try_parse_from(["trusty-installer", "sign", "trusty-memory"])
.expect("sign trusty-memory parses");
match memory.command {
Commands::Sign { target, .. } => {
assert_eq!(target, SignTargetArg::Memory);
assert_eq!(target.as_set_name(), "trusty-memory");
}
other => panic!("expected Sign, got {other:?}"),
}

// Owner ruling 2026-08-06: `trusty-analyze` promoted in the same ruling.
let analyze = Cli::try_parse_from(["trusty-installer", "sign", "trusty-analyze"])
.expect("sign trusty-analyze parses");
match analyze.command {
Commands::Sign { target, .. } => {
assert_eq!(target, SignTargetArg::Analyze);
assert_eq!(target.as_set_name(), "trusty-analyze");
}
other => panic!("expected Sign, got {other:?}"),
}
}

/// `trusty-installer sign trusty-search --verbose` parses via the existing
Expand Down
125 changes: 118 additions & 7 deletions crates/trusty-installer/src/commands/macos_signing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@
//! bundler (or the install script's best-effort `codesign --deep` pass over
//! an already-built bundle) instead — see that script for both paths.
//!
//! - Owner ruling 2026-08-06: `trusty-memory` joins as its own signable set
//! ([`MEMORY_SET`]). Its absence was never a decision — no comment, doc, ADR,
//! or commit ever gave a reason for it. The table simply grew one reported
//! symptom at a time (#2558 search, #2721 `tm`, #2951 the GUI, #4277
//! `tagent`) and nobody filed the trusty-memory one. It has the same TCC
//! surface the others were added for: `trusty-memory setup` and
//! `trusty-memory migrate` walk `$HOME` to depth 8 via
//! `trusty_common::claude_config::discover_claude_settings` and rewrite other
//! projects' `.claude/settings*.json`. That walk's skip-list excludes
//! `Library`/`Applications` but NOT `~/Desktop`, `~/Documents`, or
//! `~/Downloads`, so a `read_dir` on each is attempted — the macOS
//! Files-and-Folders TCC categories, keyed by cdhash exactly like FDA and App
//! Data. Unlike [`AGENTS_SET`], `trusty-memory` links `ort`/`fastembed`
//! through `trusty-common`'s `memory-core`, so it takes [`SEARCH_SET`]'s
//! conservative Hardened-Runtime policy (explicit signing only), not
//! [`MPM_SET`]'s always-on one — see [`use_hardened_runtime`].
//!
//! - Owner ruling 2026-08-06 (same ruling, second crate): `trusty-analyze`
//! joins as [`ANALYZE_SET`]. It qualifies on the same evidence one step
//! weaker — `trusty-analyze setup --global` makes the identical
//! `discover_claude_settings` walk, but only behind `--global`, where
//! trusty-memory walks `$HOME` on its default `setup` path. It links
//! `fastembed` directly (not via `trusty-common`), so it lands on
//! [`SEARCH_SET`]'s side of the Hardened-Runtime split for the same reason
//! trusty-memory does. `trusty-review` was evaluated under the same test and
//! EXCLUDED: it makes no `$HOME` walk and reads no other app's files, so
//! there is no grant for a stable identity to preserve.
//!
//! What: [`binaries_for_set`] and [`codesign_identifier`] are both derived from
//! the single [`SIGNABLE_BINARIES`] table (binary, set, identifier) so set
//! membership and identifier mapping can never drift apart again.
Expand Down Expand Up @@ -120,6 +148,47 @@ pub const MPM_SET: &str = "trusty-mpm";
/// deliberate deviation from the `trusty-mpm-gui` precedent.
pub const AGENTS_SET: &str = "trusty-agents";

/// The `trusty-memory` signable set: every binary `cargo install --path
/// crates/trusty-memory` produces — `trusty-memory`, the bundled
/// `trusty-bm25-daemon`, and the deprecated `trusty-memory-mcp-bridge` shim.
///
/// Why (owner ruling 2026-08-06): same cdhash-instability class as
/// #873/#2721/#4277. `trusty-memory setup` and `trusty-memory migrate` walk
/// `$HOME` to depth 8 looking for `.claude/settings*.json` and rewrite the ones
/// they find; that walk skips `Library` and `Applications` but not `~/Desktop`,
/// `~/Documents`, or `~/Downloads`, so macOS's Files-and-Folders TCC categories
/// are in play and every `cargo install` mints a fresh cdhash that revokes
/// whatever was granted. All three binaries are listed, not just the primary
/// one: #2721 is the recorded lesson that signing part of an install set leaves
/// the rest ad-hoc and the prompt still recurring.
///
/// Its own store (`~/Library/Application Support/trusty-memory/`) is NOT the
/// reason — an app's own data directory is not TCC-protected. The `$HOME` walk
/// is.
pub const MEMORY_SET: &str = "trusty-memory";

/// The `trusty-analyze` signable set: the single `trusty-analyze` binary
/// (`cargo install --path crates/trusty-analyze`).
///
/// Why (owner ruling 2026-08-06, promoted alongside [`MEMORY_SET`]): it
/// qualifies on the same test, one step weaker.
/// `trusty-analyze setup --global`
/// (`crates/trusty-analyze/src/commands/setup.rs:129`) makes the identical
/// `discover_claude_settings` walk of `$HOME` to depth 8, reaching `~/Desktop`,
/// `~/Documents`, and `~/Downloads` for the same reason trusty-memory does.
///
/// The difference is real and worth keeping: trusty-memory walks `$HOME` on
/// its DEFAULT `setup` path, while trusty-analyze only does so behind
/// `--global` — project mode patches `.mcp.json` in the project root and never
/// leaves it. So the exposure is narrower in how often it is reached, and
/// identical when it is.
///
/// Its own set rather than folded into another, matching every other entry
/// here: a separate crate with its own install path. One binary — the crate's
/// only other target is the `trusty_analyze` **library**, not a second
/// executable.
pub const ANALYZE_SET: &str = "trusty-analyze";

/// The master table: every signable binary → (its set, its codesign identifier).
///
/// Why: PR #2657 review (MEDIUM) — `binaries_for_set` and `codesign_identifier`
Expand Down Expand Up @@ -165,6 +234,30 @@ const SIGNABLE_BINARIES: &[(&str, &str, &str)] = &[
// `cargo install --path crates/trusty-agents`. Own set ([`AGENTS_SET`]) —
// see that constant's doc for why it is not folded into `MPM_SET`.
("tagent", AGENTS_SET, "com.trusty.tagent"),
// `trusty-memory` (owner ruling 2026-08-06): all three binaries `cargo
// install --path crates/trusty-memory` produces. `trusty-memory` is listed
// first so `binaries_for_set(MEMORY_SET).first()` — the binary any guidance
// text names — stays the primary one, matching the MPM_SET ordering rule.
// `trusty-memory-mcp-bridge` is a deprecated re-exec shim slated for
// removal; it costs nothing to carry here because
// `post_install_signed_set`/`sign_set_strict` skip any binary that is not
// on disk, and omitting it would leave an installed binary ad-hoc for
// exactly the #2721 reason.
("trusty-memory", MEMORY_SET, "com.trusty.trusty-memory"),
(
"trusty-bm25-daemon",
MEMORY_SET,
"com.trusty.trusty-bm25-daemon",
),
(
"trusty-memory-mcp-bridge",
MEMORY_SET,
"com.trusty.trusty-memory-mcp-bridge",
),
// `trusty-analyze` (owner ruling 2026-08-06): one binary — the crate's
// only other target is a library. See [`ANALYZE_SET`] for why it
// qualifies and how its exposure differs from trusty-memory's.
("trusty-analyze", ANALYZE_SET, "com.trusty.trusty-analyze"),
];

/// Resolve the binaries that make up a named Developer-ID-signable set.
Expand Down Expand Up @@ -235,12 +328,23 @@ pub fn codesign_identifier(binary: &str) -> &'static str {
/// ONNX/embedding runtime — that's trusty-search/trusty-embedderd's job), so
/// it joins [`MPM_SET`] in always being hardened, same reasoning.
///
/// [`MEMORY_SET`] and [`ANALYZE_SET`] go the other way, and this is the one
/// place where adding either required a decision rather than a table row. Both
/// link `ort`/`fastembed` — `trusty-memory` transitively through
/// `trusty-common`'s `memory-core` feature, `trusty-analyze` as a direct
/// dependency (`cargo tree -p <crate> -i fastembed` confirms each separately;
/// neither was inferred from the other) — so both carry the same unverified
/// ONNX-dylib-under-library-validation exposure that keeps [`SEARCH_SET`] off
/// automatic Hardened Runtime. Neither takes a special case: falling through
/// to plain `explicit` already gives exactly [`SEARCH_SET`]'s policy, which is
/// what `tests::hardened_runtime_policy` pins for each.
///
/// What: `explicit` (the operator directly ran `tctl sign <target>`, or the
/// wrapper script which shells out to it) → always `true`. Automatic
/// (`tctl install`'s fail-soft hook, `explicit = false`) → `true` for
/// [`MPM_SET`] and [`AGENTS_SET`]; `false` for [`SEARCH_SET`] until
/// ONNX-under-Hardened-Runtime is verified (see the tracking issue referenced
/// in PR #2657).
/// [`MPM_SET`] and [`AGENTS_SET`]; `false` for [`SEARCH_SET`], [`MEMORY_SET`],
/// and [`ANALYZE_SET`] until ONNX-under-Hardened-Runtime is verified (see the
/// tracking issue referenced in PR #2657).
///
/// Test: `tests::hardened_runtime_policy`.
pub fn use_hardened_runtime(set: &str, explicit: bool) -> bool {
Expand Down Expand Up @@ -550,7 +654,7 @@ impl std::fmt::Display for SignSetError {
match self {
SignSetError::UnknownSet(set) => write!(
f,
"unknown signable set '{set}' (expected 'trusty-search', 'trusty-mpm', or 'trusty-agents')"
"unknown signable set '{set}' (expected 'trusty-search', 'trusty-mpm', 'trusty-memory', 'trusty-analyze', or 'trusty-agents')"
),
SignSetError::NoCertificate => {
write!(f, "no Developer ID Application certificate found")
Expand Down Expand Up @@ -742,11 +846,18 @@ pub fn app_data_guidance(binary_path: &str, existed_before: bool) -> String {
/// `TRUSTY_SIGN_IDENTITY` tip inside every component's block; a two-component
/// install (trusty-search + trusty-mpm) printed it twice back to back.
///
/// Test: `tests::signing_persistence_tip_mentions_sign_identity_and_tctl_sign`.
/// The target list was hand-maintained and had already gone stale — it still
/// named only search and mpm after [`AGENTS_SET`] shipped in #4277.
/// `tests::signing_persistence_tip_names_every_signable_set` now derives the
/// expected names from [`SIGNABLE_BINARIES`], so a new set cannot be added
/// without this string being updated with it.
///
/// Test: `tests::signing_persistence_tip_mentions_sign_identity_and_tctl_sign`,
/// `tests::signing_persistence_tip_names_every_signable_set`.
pub fn signing_persistence_tip() -> &'static str {
"Tip: install a Developer ID Application certificate (or set TRUSTY_SIGN_IDENTITY) \
and run `tctl sign <trusty-search|trusty-mpm>` to make these grants persist across \
every future reinstall."
and run `tctl sign <trusty-search|trusty-mpm|trusty-memory|trusty-analyze|trusty-agents>` to make \
these grants persist across every future reinstall."
}

/// Run the fail-soft post-install signing hook for a named set.
Expand Down
Loading
Loading