From 0e175f9e317af018dc93fe6bf1facf11ec3e200f Mon Sep 17 00:00:00 2001 From: Anubis Quantum Cipher Date: Fri, 14 Aug 2026 12:52:05 -0400 Subject: [PATCH 1/4] R1B phase1b: grant-only SMAppService connection (non-final) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect a sealed PreparedMutationGrant to exactly one sacrificial adapter call through SacrificialMutationDispatcher. Fake/structural gates only; default probe path still stops before production mutation unless --commit-mutation is supplied after grant preparation. Automated tests use FakeSMAdapter. Hosted CI must not pass the commit flag with a valid authorization. A→B→A: docs/evidence/R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md. --- .github/workflows/ci.yml | 1 + docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md | 12 +- .../R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md | 54 +++ probe/ProbeMain.swift | 53 ++- probe/SMAdapterProduction.swift | 45 ++- scripts/build-probe.sh | 1 + scripts/test-phase1a-wiring.sh | 40 ++- src/DeskTidy.swift | 3 + src/DurableNonceStore.swift | 26 ++ src/GrantedMutation.swift | 222 ++++++++++++ src/MutationBoundary.swift | 16 +- src/Phase1BTests.swift | 330 ++++++++++++++++++ 12 files changed, 774 insertions(+), 29 deletions(-) create mode 100644 docs/evidence/R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md create mode 100644 src/GrantedMutation.swift create mode 100644 src/Phase1BTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cc013e..34c9cf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,7 @@ jobs: ./scripts/test-phase1a-wiring.sh ./build/desktidy-sort --phase1a-test ./build/desktidy-sort --phase1a1-test + ./build/desktidy-sort --phase1b-test chmod +x scripts/test-phase1a1-public-boundary.sh ./scripts/test-phase1a1-public-boundary.sh ./scripts/build-probe.sh build diff --git a/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md b/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md index 6c3854d..447dcd7 100644 --- a/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md +++ b/docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md @@ -85,16 +85,18 @@ Default is read-only: DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe --plan ``` -Mutation (Phase 1B only): +Mutation (Phase 1B only; requires `--commit-mutation` after a sealed grant): ```text …/DeskTidySacrificialProbe --register --auth-file /path/to/auth.json -…/DeskTidySacrificialProbe --unregister --auth-file /path/to/unreg.json +…/DeskTidySacrificialProbe --register --auth-file /path/to/auth.json --commit-mutation +…/DeskTidySacrificialProbe --unregister --auth-file /path/to/unreg.json --commit-mutation ``` -Phase 1A's probe **refuses to invoke** the production mutator even if the -interlock would permit (exit 4). Phase 1B replaces that stop with the single -granted adapter call. +Without `--commit-mutation` the probe still prints `GRANT_PREPARED` and +`STOP_BEFORE_PRODUCTION_ADAPTER` (exit 4). Hosted CI and the public-boundary +suite must not pass that flag with a valid authorization. Separate register +and unregister authorization files/nonces are required. ## Exact readbacks after a real grant diff --git a/docs/evidence/R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md b/docs/evidence/R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md new file mode 100644 index 0000000..ee9575c --- /dev/null +++ b/docs/evidence/R1B_PHASE1B_ABA_EXACTLY_ONCE_GRANT.md @@ -0,0 +1,54 @@ +# R1B Phase 1B A→B→A — exactly-once sealed-grant dispatch + +Semantic mutation of `src/GrantedMutation.swift`: drop `O_EXCL` so a +prepared grant can be dispatched twice against the same nonce. + +No live ServiceManagement registration. Fake/hermetic roots only. + +## A (green) + +- file: `src/GrantedMutation.swift` +- SHA-256: `04ab3b4aa252cb16f535d95e136d5ead1b32a01eca2c73ccf277112d25803644` +- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-p1b-aba-36284/desktidy-sort-A && /tmp/desktidy-p1b-aba-36284/desktidy-sort-A --phase1b-test` +- exit: `0` +- excerpt: + +``` +PASS B11 nonce/grant replay refused on second dispatch +PHASE1B GATES: 25 passed, 0 failed +``` + +## B (O_EXCL removed) + +- SHA-256: `d335d6f5df9251541e9d20d7c96e3fee3737787ee297cfb0f23f43768d04c644` +- rebuild after deleting the previous B binary +- command: `--phase1b-test` +- exit: `1` +- failing IDs: + +``` +FAIL B11 nonce/grant replay refused on second dispatch — first=invoked(main.SMAdapterStatus.enabled) second=invoked(main.SMAdapterStatus.enabled) +PHASE1B GATES: 24 passed, 1 failed +``` + +Intended reason: without `O_CREAT|O_EXCL`, the second dispatch of +`nonce-1b11` overwrites the consume-once marker instead of refusing, so +the fake adapter registers twice. + +Diff (B vs A): + +```diff +- let flags = disableExactlyOnceForMutationTest +- ? (O_CREAT | O_WRONLY) +- : (O_CREAT | O_EXCL | O_WRONLY) ++ let flags = (O_CREAT | O_WRONLY) // B-MUTATION: drop O_EXCL so grant replay can pass +``` + +The test hook `disableExactlyOnceForMutationTest` is reset to `false` by +`Phase1BTests.runAll()`. Mutating only that default is not load-bearing; +the B mutation therefore changes the open flags themselves. + +## Restore + +- SHA-256: `04ab3b4aa252cb16f535d95e136d5ead1b32a01eca2c73ccf277112d25803644` (equals A) +- `--phase1b-test` exit 0 — 25/25; B11 PASS diff --git a/probe/ProbeMain.swift b/probe/ProbeMain.swift index bf03a92..444c791 100644 --- a/probe/ProbeMain.swift +++ b/probe/ProbeMain.swift @@ -1,8 +1,9 @@ import Foundation // Sacrificial operator probe. Default is read-only plan/status. -// Phase 1A.1: measure evidence, prepare a sealed grant, then STOP. -// Does not construct ProductionSMAdapter or invoke mutation methods. +// A prepared grant still STOPs unless --commit-mutation is present. +// Hosted CI and the public-boundary suite must not pass that flag +// with a valid authorization. @main struct SacrificialProbe { static func main() { @@ -76,10 +77,46 @@ struct SacrificialProbe { print("sourceCommit=\(grant.sourceCommit)") print("root=\(grant.rootCanonical)") print("nonce=\(grant.nonce)") - print("STOP_BEFORE_PRODUCTION_ADAPTER") + print("transactionID=\(SacrificialMutationDispatcher.transactionID(for: grant))") + if !args.contains("--commit-mutation") { + print("STOP_BEFORE_PRODUCTION_ADAPTER") + print("ledger_constructions=\(ProductionMutationLedger.constructions)") + print("ledger_registers=\(ProductionMutationLedger.registerInvocations)") + exit(4) + } + let adapter = ProductionSMAdapter() + let req = SacrificialDispatchRequest( + grant: grant, + requested: grant.operation, + plistName: SacrificialIdentity.hypothesizedPlistName, + liveIdentity: identity, + compiledSourceCommit: CompiledProbeIdentity.sourceCommit, + second: a2 + ) + let outcome = SacrificialMutationDispatcher.dispatch(req, adapter: adapter) + print("MUTATION_ATTEMPTED") + switch outcome { + case .invoked(let st): + print("dispatch_result=invoked") + print("status=\(st)") + case .refused(let r): + print("dispatch_result=refused") + print("dispatch_error=\(r)") + case .rollbackRequired(let r): + print("dispatch_result=rollbackRequired") + print("dispatch_error=\(r)") + case .indeterminate(let r): + print("dispatch_result=indeterminate") + print("dispatch_error=\(r)") + } print("ledger_constructions=\(ProductionMutationLedger.constructions)") print("ledger_registers=\(ProductionMutationLedger.registerInvocations)") - exit(4) + print("ledger_unregisters=\(ProductionMutationLedger.unregisterInvocations)") + switch outcome { + case .invoked: exit(0) + case .indeterminate, .rollbackRequired: exit(5) + case .refused: exit(3) + } } } } @@ -89,10 +126,10 @@ struct SacrificialProbe { static func planText() -> String { """ DeskTidy sacrificial SMAppService probe (NON-PRODUCTION) - Phase 1A.1 seals measurement and grant preparation only. - Default: read-only plan. No registration in Phase 1A.1. - A future Phase 1B requires a reviewed patch connecting the sealed - grant to exactly one adapter call, plus separate architect authorization. + Default: measure, prepare grant, STOP (exit 4). No adapter construction. + Phase 1B observation requires --commit-mutation after a sealed grant. + Hosted CI and the public-boundary suite must not pass that flag + with a valid authorization. Hypothesized plist name: \(SacrificialIdentity.hypothesizedPlistName) Hypothesized label: \(SacrificialIdentity.hypothesizedLabel) (UNOBSERVED) Bundle id: \(SacrificialIdentity.bundleID) diff --git a/probe/SMAdapterProduction.swift b/probe/SMAdapterProduction.swift index 5e6d131..ccc139c 100644 --- a/probe/SMAdapterProduction.swift +++ b/probe/SMAdapterProduction.swift @@ -2,9 +2,10 @@ import Foundation import ServiceManagement // Production adapter. Construction has no side effect. -// register/unregister exist so Phase 1B can call them *only* after the -// MutationInterlock grants. Phase 1A never executes those methods. -final class ProductionSMAdapter: ServiceManagementAdapting { +// Ungranted overloads stay disconnected. The only ServiceManagement +// register/unregister call sites are executeSealed*, reachable solely +// through SacrificialMutationDispatcher. +final class ProductionSMAdapter: ServiceManagementAdapting, SealedAdapterExecuting { init() { ProductionMutationLedger.constructions += 1 } @@ -25,21 +26,39 @@ final class ProductionSMAdapter: ServiceManagementAdapting { func requestRegister(plistName: String) -> Result { ProductionMutationLedger.registerInvocations += 1 - return .failure(.failedClosed("Phase 1A.1 sealed: production mutation is not connected")) - } - - func requestRegister(plistName: String, grant: PreparedMutationGrant) -> Result { - _ = grant - return requestRegister(plistName: plistName) + return .failure(.failedClosed("ungranted production mutation is not connected")) } func requestUnregister(plistName: String) -> Result { ProductionMutationLedger.unregisterInvocations += 1 - return .failure(.failedClosed("Phase 1A.1 sealed: production mutation is not connected")) + return .failure(.failedClosed("ungranted production mutation is not connected")) + } + + func executeSealedRegister(plistName: String) -> Result { + ProductionMutationLedger.registerInvocations += 1 + if #available(macOS 13.0, *) { + let service = SMAppService.agent(plistName: plistName) + do { + try service.register() + return .success(()) + } catch { + return .failure(.failedClosed("SMAppService.register: \(error)")) + } + } + return .failure(.unavailable) } - func requestUnregister(plistName: String, grant: PreparedMutationGrant) -> Result { - _ = grant - return requestUnregister(plistName: plistName) + func executeSealedUnregister(plistName: String) -> Result { + ProductionMutationLedger.unregisterInvocations += 1 + if #available(macOS 13.0, *) { + let service = SMAppService.agent(plistName: plistName) + do { + try service.unregister() + return .success(()) + } catch { + return .failure(.failedClosed("SMAppService.unregister: \(error)")) + } + } + return .failure(.unavailable) } } diff --git a/scripts/build-probe.sh b/scripts/build-probe.sh index 4874956..40f3837 100755 --- a/scripts/build-probe.sh +++ b/scripts/build-probe.sh @@ -61,6 +61,7 @@ xcrun swiftc -O -parse-as-library \ "$REPO/src/DurableNonceStore.swift" \ "$REPO/src/ProbeIdentity.swift" \ "$REPO/src/MutationBoundary.swift" \ + "$REPO/src/GrantedMutation.swift" \ "$REPO/src/ProductionEvidence.swift" \ "$REPO/probe/SMAdapterProduction.swift" \ "$GEN" \ diff --git a/scripts/test-phase1a-wiring.sh b/scripts/test-phase1a-wiring.sh index 025fdfb..c0a14dd 100755 --- a/scripts/test-phase1a-wiring.sh +++ b/scripts/test-phase1a-wiring.sh @@ -18,8 +18,20 @@ fail_if "$ROOT/src/Phase1ATests.swift" 'ProductionSMAdapter|SMAppService' \ fail_if "$ROOT/src/Phase1A1Tests.swift" 'ProductionSMAdapter|SMAppService' \ "phase1a1 tests mention production ServiceManagement mutator" -fail_if "$ROOT/probe/ProbeMain.swift" 'ProductionSMAdapter\(|requestRegister\(|requestUnregister\(' \ - "probe connects prepared grant to production mutator" +fail_if "$ROOT/src/Phase1BTests.swift" 'ProductionSMAdapter|SMAppService' \ + "phase1b tests mention production ServiceManagement mutator" + +if ! grep -q 'if !args.contains("--commit-mutation")' "$ROOT/probe/ProbeMain.swift"; then + echo "FAIL: probe missing --commit-mutation stop before production adapter" >&2 + exit 1 +fi +# Unsealed/early construction: ProductionSMAdapter must not appear before GRANT_PREPARED. +PROBE_SRC="$(sed 's://.*$::' "$ROOT/probe/ProbeMain.swift")" +BEFORE="${PROBE_SRC%%GRANT_PREPARED*}" +if printf '%s' "$BEFORE" | grep -nE 'ProductionSMAdapter\(|executeSealedRegister|executeSealedUnregister'; then + echo "FAIL: probe constructs or invokes production mutator before GRANT_PREPARED" >&2 + exit 1 +fi fail_if "$ROOT/scripts/build-probe.sh" 'SMAppService\.register|launchctl (bootstrap|bootout|kickstart)' \ "probe build script contains registration/launchd mutation" @@ -43,4 +55,28 @@ else exit 1 fi rm -f "$TMP" + +# Unsealed-call poison: ProductionSMAdapter before GRANT_PREPARED must fail. +POISON=$(mktemp) +sed 's://.*$::' "$ROOT/probe/ProbeMain.swift" > "$POISON" +python3 - "$POISON" <<'PY' +from pathlib import Path +import sys +p = Path(sys.argv[1]) +text = p.read_text() +needle = "print(\"GRANT_PREPARED\")" +if needle not in text: + raise SystemExit("missing GRANT_PREPARED marker") +text = text.replace(needle, "let _ = ProductionSMAdapter()\n " + needle, 1) +p.write_text(text) +PY +POISON_SRC="$(sed 's://.*$::' "$POISON")" +POISON_BEFORE="${POISON_SRC%%GRANT_PREPARED*}" +if printf '%s' "$POISON_BEFORE" | grep -nE 'ProductionSMAdapter\('; then + echo "wiring-unsealed-poison-control: detected early production adapter" +else + echo "FAIL: unsealed-call poison control did not fire" >&2 + exit 1 +fi +rm -f "$POISON" echo "phase1a-wiring: PASS" diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index 253fbe7..ac11683 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -126,6 +126,9 @@ final class DeskTidy { if arguments.contains("--phase1a1-test") { return Phase1A1Tests().runAll() ? 0 : 1 } + if arguments.contains("--phase1b-test") { + return Phase1BTests().runAll() ? 0 : 1 + } if arguments.contains("--history") { return printHistory(arguments: arguments) } diff --git a/src/DurableNonceStore.swift b/src/DurableNonceStore.swift index b7ccd2c..fc68124 100644 --- a/src/DurableNonceStore.swift +++ b/src/DurableNonceStore.swift @@ -80,8 +80,34 @@ enum DurableNonceStore { let line = "\(rec.nonce) \(rec.operation) \(rec.executableSHA256) \(rec.sourceCommit) \(rec.authorizationDigest) \(rec.reservedAt)\n" let bytes = Array(line.utf8) let written = bytes.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } + fsync(fd) close(fd) if written != bytes.count { return .refused("nonce record write failed") } return .reserved(rec) } + + static func lookup(canonicalSacrificial: String, nonce: String) -> NonceReservation? { + guard let safe = normalize(nonce) else { return nil } + let dest = supportRoot(canonicalSacrificial: canonicalSacrificial) + .appendingPathComponent("nonces", isDirectory: true) + .appendingPathComponent(safe) + var lst = stat() + if lstat(dest.path, &lst) != 0 { return nil } + if (lst.st_mode & S_IFMT) == S_IFLNK { return nil } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: dest.path)) else { return nil } + let parts = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: " ") + .map(String.init) + guard parts.count >= 6 else { return nil } + return NonceReservation( + nonce: parts[0], + operation: parts[1], + executableSHA256: parts[2], + sourceCommit: parts[3], + rootCanonical: canonicalSacrificial, + authorizationDigest: parts[4], + reservedAt: parts[5] + ) + } } diff --git a/src/GrantedMutation.swift b/src/GrantedMutation.swift new file mode 100644 index 0000000..c81281b --- /dev/null +++ b/src/GrantedMutation.swift @@ -0,0 +1,222 @@ +import Darwin +import Foundation + +// ============================================================================ +// Phase 1B sacrificial dispatcher. A prepared grant is necessary but not +// sufficient. Automated tests use FakeSMAdapter only. The production +// adapter may invoke SMAppService only after this dispatcher accepts. +// ============================================================================ + +protocol SealedAdapterExecuting: AnyObject { + func executeSealedRegister(plistName: String) -> Result + func executeSealedUnregister(plistName: String) -> Result + func status(plistName: String) -> Result +} + +extension FakeSMAdapter: SealedAdapterExecuting { + func executeSealedRegister(plistName: String) -> Result { + requestRegister(plistName: plistName) + } + func executeSealedUnregister(plistName: String) -> Result { + requestUnregister(plistName: plistName) + } +} + +struct SacrificialDispatchRequest: Equatable { + var grant: PreparedMutationGrant + var requested: InterlockOperation + var plistName: String + var liveIdentity: ProbeIdentity.Measurement + var compiledSourceCommit: String + var second: AuthoritySnapshot +} + +enum PostcallTransactionLog { + static func append(grant: PreparedMutationGrant, result: String, status: String) { + let support = DurableNonceStore.supportRoot(canonicalSacrificial: grant.rootCanonical) + try? FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let dest = support.appendingPathComponent("postcall.jsonl") + var lst = stat() + if lstat(dest.path, &lst) == 0 && (lst.st_mode & S_IFMT) == S_IFLNK { return } + let fd = open(dest.path, O_CREAT | O_APPEND | O_WRONLY | O_NOFOLLOW, 0o600) + if fd < 0 { return } + defer { close(fd) } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + let tid = SacrificialMutationDispatcher.transactionID(for: grant) + let line = "\(tid) \(grant.nonce) \(grant.operation.rawValue) \(result) \(status) \(iso.string(from: Date()))\n" + let bytes = Array(line.utf8) + _ = bytes.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } + fsync(fd) + } +} + +enum SacrificialMutationDispatcher { + static let sacrificialPlistName = "com.desktidy.sacrificial.plist" + + /// Test-only hook for A→B→A. Production remains false. + static var disableExactlyOnceForMutationTest = false + + enum Outcome: Equatable { + case invoked(SMAdapterStatus) + case refused(String) + case rollbackRequired(String) + case indeterminate(String) + } + + static func transactionID(for grant: PreparedMutationGrant) -> String { + MutationBoundary.digest(Data( + "\(grant.nonce)|\(grant.operation.rawValue)|\(grant.authorizationDigest)|\(grant.rootCanonical)|\(grant.executableSHA256)|\(grant.sourceCommit)".utf8 + )) + } + + static func dispatch( + _ request: SacrificialDispatchRequest, + adapter: SealedAdapterExecuting + ) -> Outcome { + switch accept(request) { + case .refuse(let reason): + return .refused(reason) + case .accept: + break + } + switch consumeOnce(grant: request.grant) { + case .refuse(let reason): + return .refused(reason) + case .accept: + break + } + + let plist = sacrificialPlistName + let call: Result + switch request.grant.operation { + case .register: + call = adapter.executeSealedRegister(plistName: plist) + case .unregister: + call = adapter.executeSealedUnregister(plistName: plist) + } + + let status = adapter.status(plistName: plist) + let statusText: String + switch status { + case .success(let s): statusText = String(describing: s) + case .failure(let e): statusText = "error:\(e)" + } + + switch call { + case .failure(let err): + PostcallTransactionLog.append(grant: request.grant, result: "adapter_failed", status: statusText) + return .rollbackRequired("adapter failed: \(err)") + case .success: + switch status { + case .failure(let err): + PostcallTransactionLog.append(grant: request.grant, result: "status_failed", status: statusText) + return .rollbackRequired("post-call status failed: \(err)") + case .success(.unknown(let raw)): + PostcallTransactionLog.append(grant: request.grant, result: "status_unknown", status: raw) + return .indeterminate("post-call status unknown") + case .success(let s): + PostcallTransactionLog.append(grant: request.grant, result: "invoked", status: statusText) + return .invoked(s) + } + } + } + + private enum Gate: Equatable { + case accept + case refuse(String) + } + + private static func accept(_ request: SacrificialDispatchRequest) -> Gate { + let grant = request.grant + if request.requested != grant.operation { + return .refuse("grant operation does not match requested mutation") + } + if request.plistName != sacrificialPlistName || grantPlistForbidden(request.plistName) { + return .refuse("plist is not the sacrificial probe plist") + } + if grant.executableSHA256 == String(repeating: "0", count: 64) { + return .refuse("grant executable hash is the zero placeholder") + } + if grant.executableSHA256 != request.liveIdentity.executableSHA256 { + return .refuse("live executable hash does not match grant") + } + if grant.sourceCommit.count != 40 || !MutationInterlock.isCommitHex(grant.sourceCommit) { + return .refuse("grant source commit is not a 40-hex SHA") + } + if grant.sourceCommit != request.compiledSourceCommit { + return .refuse("compiled source commit does not match grant") + } + if request.second.rootCanonical != grant.rootCanonical { + return .refuse("sacrificial root changed between grant and dispatch") + } + if request.second.foreignOverlap { + return .refuse("foreign mover on sacrificial root") + } + if request.second.uninspectable { + return .refuse("authority evidence uninspectable") + } + if request.second.dualDeskTidy { + return .refuse("dual DeskTidy presence") + } + if request.liveIdentity.plistURL.lastPathComponent != sacrificialPlistName { + return .refuse("embedded plist identity is not sacrificial") + } + if !FileManager.default.fileExists(atPath: request.liveIdentity.plistURL.path) { + return .refuse("embedded sacrificial plist missing") + } + switch DurableNonceStore.lookup(canonicalSacrificial: grant.rootCanonical, nonce: grant.nonce) { + case .none: + return .refuse("nonce reservation missing") + case .some(let rec): + if rec.operation != grant.operation.rawValue + || rec.executableSHA256 != grant.executableSHA256 + || rec.sourceCommit != grant.sourceCommit + || rec.authorizationDigest != grant.authorizationDigest { + return .refuse("nonce reservation does not match grant") + } + } + if !PrecallTransactionLog.contains(grant: grant) { + return .refuse("pre-call transaction missing") + } + return .accept + } + + private static func grantPlistForbidden(_ plistName: String) -> Bool { + let label = plistName.replacingOccurrences(of: ".plist", with: "") + if MutationInterlock.personalLabels.contains(label) { return true } + if ProductIdentity.selfLabels.contains(label) { return true } + if plistName.contains("desktop-autosort") { return true } + if plistName.contains("com.desktidy.sort") { return true } + if plistName.contains("com.desktidy.notify") { return true } + return plistName != sacrificialPlistName + } + + private static func consumeOnce(grant: PreparedMutationGrant) -> Gate { + let support = DurableNonceStore.supportRoot(canonicalSacrificial: grant.rootCanonical) + let dir = support.appendingPathComponent("dispatched", isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) + } catch { + return .refuse("dispatch marker directory failed") + } + let dest = dir.appendingPathComponent(grant.nonce) + var lst = stat() + if lstat(dest.path, &lst) == 0 && (lst.st_mode & S_IFMT) == S_IFLNK { + return .refuse("dispatch marker path is a symlink") + } + let flags = disableExactlyOnceForMutationTest + ? (O_CREAT | O_WRONLY) + : (O_CREAT | O_EXCL | O_WRONLY) + let fd = open(dest.path, flags, 0o600) + if fd < 0 { + return .refuse("nonce already dispatched (replay)") + } + let line = Array("\(transactionID(for: grant))\n".utf8) + _ = line.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } + fsync(fd) + close(fd) + return .accept + } +} diff --git a/src/MutationBoundary.swift b/src/MutationBoundary.swift index 8af6295..6bac017 100644 --- a/src/MutationBoundary.swift +++ b/src/MutationBoundary.swift @@ -67,12 +67,26 @@ enum PrecallTransactionLog { defer { close(fd) } let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] - let line = "\(grant.nonce) \(grant.operation.rawValue) \(grant.executableSHA256) \(grant.sourceCommit) \(grant.authorizationDigest) \(iso.string(from: Date()))\n" + let tid = SacrificialMutationDispatcher.transactionID(for: grant) + let line = "\(tid) \(grant.nonce) \(grant.operation.rawValue) \(grant.executableSHA256) \(grant.sourceCommit) \(grant.authorizationDigest) \(iso.string(from: Date()))\n" let bytes = Array(line.utf8) let written = bytes.withUnsafeBufferPointer { write(fd, $0.baseAddress, $0.count) } if written != bytes.count { return .refused("pre-call transaction write failed") } + fsync(fd) return .recorded } + + static func contains(grant: PreparedMutationGrant) -> Bool { + let dest = DurableNonceStore.supportRoot(canonicalSacrificial: grant.rootCanonical) + .appendingPathComponent("precall.jsonl") + var lst = stat() + if lstat(dest.path, &lst) != 0 { return false } + if (lst.st_mode & S_IFMT) == S_IFLNK { return false } + guard let data = try? Data(contentsOf: dest) else { return false } + let tid = SacrificialMutationDispatcher.transactionID(for: grant) + let needle = "\(tid) \(grant.nonce) \(grant.operation.rawValue) \(grant.executableSHA256)" + return String(decoding: data, as: UTF8.self).contains(needle) + } } enum MutationBoundary { diff --git a/src/Phase1BTests.swift b/src/Phase1BTests.swift new file mode 100644 index 0000000..652cbec --- /dev/null +++ b/src/Phase1BTests.swift @@ -0,0 +1,330 @@ +import Foundation + +// ============================================================================ +// Phase 1B grant-dispatch gates. Fake/hermetic only. +// Does not construct a production ServiceManagement adapter. +// ============================================================================ + +final class Phase1BTests { + private let fm = FileManager.default + private var pass = 0 + private var fail = 0 + private let commit = "d259b2b971b83ce89e34426af791422adea8e472" + private let hashOK = String(repeating: "ab", count: 32) + + private func check(_ id: String, _ desc: String, _ ok: Bool, _ detail: String = "") { + if ok { print("PASS \(id) \(desc)"); pass += 1 } + else { print("FAIL \(id) \(desc)\(detail.isEmpty ? "" : " — \(detail)")"); fail += 1 } + } + + func runAll() -> Bool { + DurableNonceStore.disableExclusivityForMutationTest = false + SacrificialMutationDispatcher.disableExactlyOnceForMutationTest = false + ProductionMutationLedger.reset() + runDispatchPolicy() + runSourceSeams() + print("PHASE1B GATES: \(pass) passed, \(fail) failed") + if pass == 0 { print("FAIL summary zero cases"); return false } + return fail == 0 + } + + private func tmpDir(_ tag: String) -> URL { + let u = fm.temporaryDirectory.appendingPathComponent("dt-1b-\(tag)-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: u, withIntermediateDirectories: true) + return u + } + + private func identity(rootBundle: URL? = nil, hash: String? = nil) -> ProbeIdentity.Measurement { + let bundle = rootBundle ?? tmpDir("app").appendingPathComponent("DeskTidySacrificialProbe.app") + try? fm.createDirectory(at: bundle.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + let plistDir = bundle.appendingPathComponent("Contents/Library/LaunchAgents") + try? fm.createDirectory(at: plistDir, withIntermediateDirectories: true) + let plist = plistDir.appendingPathComponent("com.desktidy.sacrificial.plist") + if !fm.fileExists(atPath: plist.path) { + fm.createFile(atPath: plist.path, contents: Data("sacrificial-plist".utf8)) + } + return ProbeIdentity.Measurement( + executableURL: bundle.appendingPathComponent("Contents/MacOS/DeskTidySacrificialProbe"), + basename: "DeskTidySacrificialProbe", + appBundleURL: bundle, + bundleIdentifier: "com.desktidy.sacrificial-probe", + plistURL: plist, + executableSHA256: hash ?? hashOK) + } + + private func makePrepared( + nonce: String, + operation: InterlockOperation = .register, + hash: String? = nil, + commit: String? = nil + ) -> (PreparedMutationGrant, ProbeIdentity.Measurement, AuthoritySnapshot)? { + let root = tmpDir("sac") + let id = identity(hash: hash) + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + let exp = iso.string(from: Date().addingTimeInterval(3600)) + let h = hash ?? hashOK + let c = commit ?? self.commit + let auth = Data("{\"schema\":1,\"operation\":\"\(operation.rawValue)\",\"sacrificialRoot\":\"\(root.path)\",\"bundleSHA256\":\"\(h)\",\"sourceCommit\":\"\(c)\",\"expiry\":\"\(exp)\",\"nonce\":\"\(nonce)\"}".utf8) + let snap = AuthoritySnapshot(foreignOverlap: false, uninspectable: false, dualDeskTidy: false, rootCanonical: root.path) + let desk = tmpDir("desk") + let home = tmpDir("home") + switch MutationBoundary.prepare( + authBytes: auth, identity: id, compiledSourceCommit: c, + operation: operation, first: snap, second: snap, + desktop: AuthorityGuard.canonicalize(desk.path), + home: AuthorityGuard.canonicalize(home.path), + protected: [], productionTarget: nil) { + case .prepared(let g): return (g, id, snap) + case .refused: return nil + } + } + + private func request( + grant: PreparedMutationGrant, + id: ProbeIdentity.Measurement, + second: AuthoritySnapshot, + requested: InterlockOperation? = nil, + plistName: String = SacrificialMutationDispatcher.sacrificialPlistName, + commit: String? = nil + ) -> SacrificialDispatchRequest { + SacrificialDispatchRequest( + grant: grant, + requested: requested ?? grant.operation, + plistName: plistName, + liveIdentity: id, + compiledSourceCommit: commit ?? grant.sourceCommit, + second: second + ) + } + + private func runDispatchPolicy() { + ProductionMutationLedger.reset() + guard let (grant, id, snap) = makePrepared(nonce: "nonce-1b01") else { + check("B01", "valid prepared grant invokes fake adapter register exactly once", false, "prepare refused") + return + } + let fake = FakeSMAdapter() + fake.statusResult = .success(.enabled) + let out = SacrificialMutationDispatcher.dispatch(request(grant: grant, id: id, second: snap), adapter: fake) + check("B01", "valid prepared grant invokes fake adapter register exactly once", + { + if case .invoked(let st) = out { + return st == .enabled && fake.registerCount == 1 && fake.unregisterCount == 0 + } + return false + }(), "\(out) calls=\(fake.calls)") + + guard let (ugrant, uid, usnap) = makePrepared(nonce: "nonce-1b02", operation: .unregister) else { + check("B02", "valid unregister grant invokes fake adapter unregister exactly once", false, "prepare refused") + return + } + let ufake = FakeSMAdapter() + ufake.statusResult = .success(.notRegistered) + let uout = SacrificialMutationDispatcher.dispatch( + request(grant: ugrant, id: uid, second: usnap), adapter: ufake) + check("B02", "valid unregister grant invokes fake adapter unregister exactly once", + { + if case .invoked(let st) = uout { + return st == .notRegistered && ufake.unregisterCount == 1 && ufake.registerCount == 0 + } + return false + }(), "\(uout)") + + let transplant = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: snap, requested: .unregister), adapter: FakeSMAdapter()) + check("B03", "operation-transplant refused", + { if case .refused(let s) = transplant { return s.contains("operation") }; return false }(), "\(transplant)") + + let plistX = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: snap, plistName: "com.desktidy.sort.plist"), + adapter: FakeSMAdapter()) + check("B04", "plist-transplant of production sort refused", + { if case .refused(let s) = plistX { return s.contains("sacrificial") || s.contains("protected") || s.contains("plist") }; return false }(), "\(plistX)") + + let personal = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: snap, plistName: "com.sicarii.desktop-autosort.plist"), + adapter: FakeSMAdapter()) + check("B05", "personal mover plist refused", + { if case .refused(let s) = personal { return s.contains("sacrificial") || s.contains("protected") || s.contains("plist") }; return false }(), "\(personal)") + + let stale = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: snap, commit: "0b11c652e364cf47668ba87b4228a0f4ab7974ec"), + adapter: FakeSMAdapter()) + check("B06", "stale-grant compiled commit mismatch refused", + { if case .refused(let s) = stale { return s.contains("commit") }; return false }(), "\(stale)") + + var foreign = snap + foreign.foreignOverlap = true + let bypass = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: foreign), adapter: FakeSMAdapter()) + check("B07", "second-check foreign overlap refused", + { if case .refused(let s) = bypass { return s.contains("foreign") }; return false }(), "\(bypass)") + + var moved = snap + moved.rootCanonical = tmpDir("moved").path + let rootChange = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: moved), adapter: FakeSMAdapter()) + check("B08", "second-check root change refused", + { if case .refused(let s) = rootChange { return s.contains("root") }; return false }(), "\(rootChange)") + + var badHash = id + badHash.executableSHA256 = String(repeating: "cd", count: 32) + let hashMismatch = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: badHash, second: snap), adapter: FakeSMAdapter()) + check("B09", "live executable hash mismatch refused", + { if case .refused(let s) = hashMismatch { return s.contains("hash") || s.contains("executable") }; return false }(), "\(hashMismatch)") + + guard let (g2, id2, snap2) = makePrepared(nonce: "nonce-1b10") else { + check("B10", "missing nonce reservation refused", false, "prepare refused") + return + } + let noncePath = DurableNonceStore.supportRoot(canonicalSacrificial: g2.rootCanonical) + .appendingPathComponent("nonces").appendingPathComponent(g2.nonce) + try? fm.removeItem(at: noncePath) + let missingNonce = SacrificialMutationDispatcher.dispatch( + request(grant: g2, id: id2, second: snap2), adapter: FakeSMAdapter()) + check("B10", "missing nonce reservation refused", + { if case .refused(let s) = missingNonce { return s.contains("nonce") }; return false }(), "\(missingNonce)") + + guard let (g3, id3, snap3) = makePrepared(nonce: "nonce-1b11") else { + check("B11", "nonce/grant replay refused on second dispatch", false, "prepare refused") + return + } + let replayAdapter = FakeSMAdapter() + replayAdapter.statusResult = .success(.enabled) + let first = SacrificialMutationDispatcher.dispatch( + request(grant: g3, id: id3, second: snap3), adapter: replayAdapter) + let second = SacrificialMutationDispatcher.dispatch( + request(grant: g3, id: id3, second: snap3), adapter: replayAdapter) + check("B11", "nonce/grant replay refused on second dispatch", + { + guard case .invoked = first else { return false } + if case .refused(let s) = second { return s.contains("nonce") || s.contains("once") || s.contains("replay") } + return false + }(), "first=\(first) second=\(second)") + + guard let (g4, id4, snap4) = makePrepared(nonce: "nonce-1b12") else { + check("B12", "missing pre-call transaction refused", false, "prepare refused") + return + } + let precall = DurableNonceStore.supportRoot(canonicalSacrificial: g4.rootCanonical) + .appendingPathComponent("precall.jsonl") + try? fm.removeItem(at: precall) + let missingTx = SacrificialMutationDispatcher.dispatch( + request(grant: g4, id: id4, second: snap4), adapter: FakeSMAdapter()) + check("B12", "missing pre-call transaction refused", + { if case .refused(let s) = missingTx { return s.contains("transaction") || s.contains("pre-call") || s.contains("precall") }; return false }(), "\(missingTx)") + + guard let (g5, id5, snap5) = makePrepared(nonce: "nonce-1b13") else { + check("B13", "post-status unknown is never success", false, "prepare refused") + return + } + let unk = FakeSMAdapter() + unk.statusResult = .success(.unknown("contradictory")) + let unkOut = SacrificialMutationDispatcher.dispatch( + request(grant: g5, id: id5, second: snap5), adapter: unk) + check("B13", "post-status unknown is never success", + { + if case .invoked = unkOut { return false } + if case .indeterminate(let s) = unkOut { return s.contains("unknown") && unk.registerCount == 1 } + if case .rollbackRequired(let s) = unkOut { return s.contains("unknown") && unk.registerCount == 1 } + return false + }(), "\(unkOut)") + + guard let (g6, id6, snap6) = makePrepared(nonce: "nonce-1b14") else { + check("B14", "adapter call failure after grant is rollbackRequired", false, "prepare refused") + return + } + let boom = FakeSMAdapter() + boom.registerResult = .failure(.failedClosed("adapter exploded")) + boom.statusResult = .success(.notRegistered) + let boomOut = SacrificialMutationDispatcher.dispatch( + request(grant: g6, id: id6, second: snap6), adapter: boom) + check("B14", "adapter call failure after grant is rollbackRequired", + { if case .rollbackRequired = boomOut { return boom.registerCount == 1 }; return false }(), "\(boomOut)") + + check("B15", "prepare still does not construct a production adapter", + ProductionMutationLedger.constructions == 0 + && ProductionMutationLedger.registerInvocations == 0 + && ProductionMutationLedger.unregisterInvocations == 0) + + guard let (g7, id7, snap7) = makePrepared(nonce: "nonce-1b16") else { + check("B16", "pre-call transaction write failure refuses prepare", false, "prepare skipped") + return + } + _ = (g7, id7, snap7) + let failRoot = tmpDir("txfail") + let support = DurableNonceStore.supportRoot(canonicalSacrificial: failRoot.path) + try? fm.createDirectory(at: support, withIntermediateDirectories: true) + let blocker = support.appendingPathComponent("precall.jsonl") + try? fm.removeItem(at: blocker) + try? fm.createSymbolicLink(at: blocker, withDestinationURL: tmpDir("elsewhere").appendingPathComponent("x")) + let failID = identity() + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + let exp = iso.string(from: Date().addingTimeInterval(3600)) + let auth = Data("{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"\(failRoot.path)\",\"bundleSHA256\":\"\(hashOK)\",\"sourceCommit\":\"\(commit)\",\"expiry\":\"\(exp)\",\"nonce\":\"nonce-1b16b\"}".utf8) + let snapFail = AuthoritySnapshot(foreignOverlap: false, uninspectable: false, dualDeskTidy: false, rootCanonical: failRoot.path) + let desk = tmpDir("desk-tx") + let home = tmpDir("home-tx") + let preparedFail = MutationBoundary.prepare( + authBytes: auth, identity: failID, compiledSourceCommit: commit, + operation: .register, first: snapFail, second: snapFail, + desktop: AuthorityGuard.canonicalize(desk.path), + home: AuthorityGuard.canonicalize(home.path), + protected: [], productionTarget: nil) + check("B16", "pre-call transaction write failure refuses prepare", + { if case .refused(let s) = preparedFail { return s.contains("pre-call") || s.contains("transaction") }; return false }(), "\(preparedFail)") + + var dual = snap + dual.dualDeskTidy = true + let dualOut = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: dual), adapter: FakeSMAdapter()) + check("B17", "second-check dual presence refused", + { if case .refused(let s) = dualOut { return s.contains("dual") }; return false }(), "\(dualOut)") + + let notify = SacrificialMutationDispatcher.dispatch( + request(grant: grant, id: id, second: snap, plistName: "com.desktidy.notify.plist"), + adapter: FakeSMAdapter()) + check("B18", "production notify plist refused", + { if case .refused = notify { return true }; return false }(), "\(notify)") + } + + private func runSourceSeams() { + let root = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + let probe = (try? String(contentsOf: root.appendingPathComponent("probe/ProbeMain.swift"), encoding: .utf8)) ?? "" + let prod = (try? String(contentsOf: root.appendingPathComponent("probe/SMAdapterProduction.swift"), encoding: .utf8)) ?? "" + let tests = (try? String(contentsOf: root.appendingPathComponent("src/Phase1BTests.swift"), encoding: .utf8)) ?? "" + let granted = (try? String(contentsOf: root.appendingPathComponent("src/GrantedMutation.swift"), encoding: .utf8)) ?? "" + + check("B20", "default path still stops before adapter without --commit-mutation", + probe.contains("if !args.contains(\"--commit-mutation\")") + && probe.contains("STOP_BEFORE_PRODUCTION_ADAPTER") + && probe.contains("exit(4)")) + check("B21", "ungranted requestRegister stays disconnected", + prod.contains("ungranted production mutation is not connected")) + check("B22", "granted register is the only ServiceManagement register call site", + prod.contains("try service.register()") + && prod.contains("executeSealedRegister")) + if let ungrantedStart = prod.range(of: "func requestRegister(plistName: String) -> Result"), + let next = prod.range(of: "func executeSealedRegister") { + let body = String(prod[ungrantedStart.lowerBound.. Date: Fri, 14 Aug 2026 12:54:29 -0400 Subject: [PATCH 2/4] R1B phase1b: bounded sacrificial observation runner (non-final) Add a local-only SMAppService observation script with preflight, separate register/unregister authorization, a 10-minute rollback deadline, and print-only launchctl. Hosted CI must not run it. --- scripts/observe-phase1b.sh | 292 +++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100755 scripts/observe-phase1b.sh diff --git a/scripts/observe-phase1b.sh b/scripts/observe-phase1b.sh new file mode 100755 index 0000000..7897bae --- /dev/null +++ b/scripts/observe-phase1b.sh @@ -0,0 +1,292 @@ +#!/bin/bash +# Local Phase 1B sacrificial SMAppService observation. NOT run by hosted CI. +# Print-only launchctl. Never bootstrap/bootout/kickstart/enable/disable. +# Never touches the live Desktop or com.sicarii.desktop-autosort*. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UIDN="$(id -u)" +DESKTOP="$(python3 -c 'import os,sys; print(os.path.realpath(os.path.expanduser("~/Desktop")))')" +DEADLINE_SECS=600 +LABELS="com.desktidy.sort com.desktidy.notify com.desktidy.sacrificial com.sicarii.desktop-autosort com.sicarii.desktop-autosort-notify" + +if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then + echo "observe-phase1b: refusing dirty worktree" >&2 + exit 2 +fi +COMMIT="$(git -C "$ROOT" rev-parse HEAD)" +REMOTE="$(git -C "$ROOT" rev-parse @{u})" +if [ "$COMMIT" != "$REMOTE" ]; then + echo "observe-phase1b: local HEAD != upstream ($COMMIT vs $REMOTE)" >&2 + exit 2 +fi +if ! printf '%s' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "observe-phase1b: HEAD is not a 40-hex commit" >&2 + exit 2 +fi + +if pgrep -f 'observe-phase1b.sh|DeskTidySacrificialProbe --register|--commit-mutation' >/tmp/dt-1b-procs.txt 2>/dev/null; then + if grep -v "$$" /tmp/dt-1b-procs.txt | grep -q .; then + echo "observe-phase1b: another lifecycle process is running" >&2 + cat /tmp/dt-1b-procs.txt >&2 + exit 2 + fi +fi + +print_label() { + local tag="$1" label="$2" dest="$3" + set +e + launchctl print "gui/${UIDN}/${label}" >"$dest" 2>&1 + local rc=$? + set -e + echo "${tag} ${label} rc=${rc}" + return 0 +} + +EV="$(mktemp -d /private/tmp/desktidy-phase1b-ev-XXXXXX)" +OUT="$(mktemp -d /private/tmp/desktidy-phase1b-build-XXXXXX)" +SAC="$(mktemp -d /private/tmp/desktidy-phase1b-root-XXXXXX)" +AUTHDIR="$(mktemp -d /private/tmp/desktidy-phase1b-auth-XXXXXX)" +chmod 700 "$SAC" "$AUTHDIR" "$EV" "$OUT" + +# Canonical + ownership + mode + Desktop relation +python3 - "$SAC" "$DESKTOP" <<'PY' +import os, sys, stat +sac, desk = sys.argv[1], sys.argv[2] +real = os.path.realpath(sac) +desk_real = os.path.realpath(desk) +st = os.lstat(sac) +if stat.S_ISLNK(st.st_mode): + raise SystemExit("sacrificial root is a symlink") +if not stat.S_ISDIR(st.st_mode): + raise SystemExit("sacrificial root is not a directory") +if st.st_uid != os.getuid(): + raise SystemExit("sacrificial root not current-user-owned") +if stat.S_IMODE(st.st_mode) != 0o700: + raise SystemExit("sacrificial root mode is not 0700") +if real == desk_real: + raise SystemExit("sacrificial root is Desktop") +if real.startswith(desk_real + os.sep): + raise SystemExit("sacrificial root is inside Desktop") +if desk_real.startswith(real + os.sep): + raise SystemExit("sacrificial root is parent of Desktop") +print("canonical_root=" + real) +PY + +# Foreign overlap: sacrificial tmp root must not appear in personal/production WatchPaths +python3 - "$SAC" <<'PY' +import os, sys, plistlib +from pathlib import Path +sac = os.path.realpath(sys.argv[1]) +agents = Path.home() / "Library" / "LaunchAgents" +protected = { + "com.sicarii.desktop-autosort", + "com.sicarii.desktop-autosort-notify", + "com.desktidy.sort", + "com.desktidy.notify", +} +for label in protected: + p = agents / f"{label}.plist" + if not p.exists(): + continue + with p.open("rb") as f: + obj = plistlib.load(f) + watched = list(obj.get("WatchPaths") or []) + list(obj.get("QueueDirectories") or []) + for w in watched: + wreal = os.path.realpath(os.path.expanduser(w)) + if sac == wreal or sac.startswith(wreal + os.sep) or wreal.startswith(sac + os.sep): + raise SystemExit(f"foreign/protected overlap: {label} watches {wreal}") +print("no_foreign_overlap=1") +PY + +echo "PHASE1B_OBSERVE_BEGIN" +echo "commit=$COMMIT" +echo "evidence=$EV" +echo "sacrificialRoot=$SAC" + +"$ROOT/scripts/build-probe.sh" "$OUT" +PROBE="$OUT/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe" +HELPER="$OUT/DeskTidySacrificialProbe.app/Contents/MacOS/SacrificialHelper" +PLIST="$OUT/DeskTidySacrificialProbe.app/Contents/Library/LaunchAgents/com.desktidy.sacrificial.plist" +test -x "$PROBE" +test -x "$HELPER" +test -f "$PLIST" +HASH="$(shasum -a 256 "$PROBE" | awk '{print $1}')" +HHASH="$(shasum -a 256 "$HELPER" | awk '{print $1}')" +PHASH="$(shasum -a 256 "$PLIST" | awk '{print $1}')" +PLAN="$("$PROBE" --plan)" +echo "$PLAN" | grep -q "$COMMIT" +echo "probe=$PROBE" +echo "executableSHA256=$HASH" +echo "helperSHA256=$HHASH" +echo "plistSHA256=$PHASH" +codesign -dv --verbose=4 "$OUT/DeskTidySacrificialProbe.app" >"$EV/codesign-probe.txt" 2>&1 || true +codesign -dv --verbose=4 "$HELPER" >"$EV/codesign-helper.txt" 2>&1 || true +echo "$PLAN" >"$EV/plan.txt" + +# Pre-state +for label in $LABELS; do + print_label pre "$label" "$EV/pre-$label.txt" +done +python3 - "$EV" <<'PY' +import pathlib, sys +ev = pathlib.Path(sys.argv[1]) +def rc_of(name): + text = (ev / name).read_text(errors="replace") + return 0 if "state =" in text or "job state" in text or "gui/" in text and "Could not find service" not in text else 113 +# launchctl print writes the service dump on success; error text on failure. +def loaded(path): + t = path.read_text(errors="replace") + return "Could not find service" not in t +assert not loaded(ev/"pre-com.desktidy.sort.txt"), "production sort unexpectedly loaded" +assert not loaded(ev/"pre-com.desktidy.notify.txt"), "production notify unexpectedly loaded" +assert not loaded(ev/"pre-com.desktidy.sacrificial.txt"), "sacrificial unexpectedly loaded" +assert loaded(ev/"pre-com.sicarii.desktop-autosort.txt"), "personal mover not loaded" +assert loaded(ev/"pre-com.sicarii.desktop-autosort-notify.txt"), "personal notify not loaded" +print("pre_baseline_ok=1") +PY + +EXP="$(date -u -v+10M +%Y-%m-%dT%H:%M:%SZ)" +REG="$AUTHDIR/register.json" +UNREG="$AUTHDIR/unregister.json" +printf '%s' "{\"schema\":1,\"operation\":\"register\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$EXP\",\"nonce\":\"nonce-r2-reg1\"}" > "$REG" +printf '%s' "{\"schema\":1,\"operation\":\"unregister\",\"sacrificialRoot\":\"$SAC\",\"bundleSHA256\":\"$HASH\",\"sourceCommit\":\"$COMMIT\",\"expiry\":\"$EXP\",\"nonce\":\"nonce-r2-unreg1\"}" > "$UNREG" +chmod 600 "$REG" "$UNREG" +REG_DIGEST="$(shasum -a 256 "$REG" | awk '{print $1}')" +UNREG_DIGEST="$(shasum -a 256 "$UNREG" | awk '{print $1}')" +echo "register_auth_digest=$REG_DIGEST" +echo "unregister_auth_digest=$UNREG_DIGEST" +echo "$REG_DIGEST" >"$EV/register.auth.sha256" +echo "$UNREG_DIGEST" >"$EV/unregister.auth.sha256" + +# Independent watchdog: after deadline, attempt sacrificial unregister only. +WATCHDOG_LOG="$EV/watchdog.log" +( + sleep "$DEADLINE_SECS" + echo "watchdog_fired $(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$WATCHDOG_LOG" + if launchctl print "gui/${UIDN}/com.desktidy.sacrificial" >/dev/null 2>&1; then + echo "watchdog_unregister_attempt" >>"$WATCHDOG_LOG" + "$PROBE" --unregister --auth-file "$UNREG" --commit-mutation >>"$WATCHDOG_LOG" 2>&1 || true + else + echo "watchdog_sacrificial_absent" >>"$WATCHDOG_LOG" + fi +) >/dev/null 2>&1 & +WATCHDOG_PID=$! +echo "watchdog_pid=$WATCHDOG_PID deadline_secs=$DEADLINE_SECS" + +UNREGISTERED=0 +do_unregister() { + if [ "$UNREGISTERED" -eq 1 ]; then + return 0 + fi + set +e + UN_OUT="$("$PROBE" --unregister --auth-file "$UNREG" --commit-mutation 2>&1)" + UN_RC=$? + set -e + echo "$UN_OUT" | tee "$EV/unregister.out" + echo "unregister_exit=$UN_RC" | tee -a "$EV/unregister.out" + UNREGISTERED=1 +} + +cleanup() { + set +e + if launchctl print "gui/${UIDN}/com.desktidy.sacrificial" >/dev/null 2>&1; then + echo "trap: sacrificial still present; unregistering" + do_unregister + fi + kill "$WATCHDOG_PID" >/dev/null 2>&1 + wait "$WATCHDOG_PID" 2>/dev/null + set -e +} +trap cleanup EXIT + +echo "--- register ---" +set +e +REG_OUT="$("$PROBE" --register --auth-file "$REG" --commit-mutation 2>&1)" +REG_RC=$? +set -e +echo "$REG_OUT" | tee "$EV/register.out" +echo "register_exit=$REG_RC" | tee -a "$EV/register.out" +REGISTER_STARTED_AT=$(date +%s) + +print_label post_register com.desktidy.sacrificial "$EV/post-reg-com.desktidy.sacrificial.txt" +sed -n '1,40p' "$EV/post-reg-com.desktidy.sacrificial.txt" + +# Observed status/label from launchctl dump — do not infer from plist alone. +OBS_LABEL="UNOBSERVED" +if grep -q 'com.desktidy.sacrificial =' "$EV/post-reg-com.desktidy.sacrificial.txt"; then + OBS_LABEL="com.desktidy.sacrificial" +fi +echo "observed_label=$OBS_LABEL" +echo "$OBS_LABEL" >"$EV/observed-label.txt" + +# Heartbeat confinement +if find "$SAC" -type f -name 'heartbeat.json' | grep -q .; then + echo "heartbeat=present_under_sacrificial_root" +else + echo "heartbeat=absent" +fi +if find "$DESKTOP" -maxdepth 3 -name 'heartbeat.json' 2>/dev/null | grep -q .; then + echo "FAIL: heartbeat found under Desktop" >&2 + do_unregister + exit 3 +fi + +# Classify +STATUS_LINE="$(printf '%s\n' "$REG_OUT" | sed -n 's/^status=//p' | head -1)" +echo "observed_status_line=$STATUS_LINE" +CLASS="UNKNOWN" +if [ "$REG_RC" -eq 0 ] && [ "$OBS_LABEL" = "com.desktidy.sacrificial" ]; then + case "$STATUS_LINE" in + enabled) CLASS="REGISTERED" ;; + requiresApproval) CLASS="INDETERMINATE" ;; + unknown*|*) CLASS="INDETERMINATE" ;; + esac +else + CLASS="INDETERMINATE" +fi +if [ "$OBS_LABEL" = "UNOBSERVED" ]; then + CLASS="INDETERMINATE" +fi +echo "register_class=$CLASS" + +echo "--- unregister ---" +do_unregister + +print_label post_unregister com.desktidy.sacrificial "$EV/post-unreg-com.desktidy.sacrificial.txt" +for label in $LABELS; do + print_label post "$label" "$EV/post-$label.txt" +done + +python3 - "$EV" <<'PY' +import pathlib, sys +ev = pathlib.Path(sys.argv[1]) +def loaded(path): + t = path.read_text(errors="replace") + return "Could not find service" not in t +if loaded(ev/"post-com.desktidy.sacrificial.txt") or loaded(ev/"post-unreg-com.desktidy.sacrificial.txt"): + raise SystemExit("sacrificial still loaded after unregister") +if loaded(ev/"post-com.desktidy.sort.txt") or loaded(ev/"post-com.desktidy.notify.txt"): + raise SystemExit("production DeskTidy label appeared") +if not loaded(ev/"post-com.sicarii.desktop-autosort.txt"): + raise SystemExit("personal mover missing after run") +if not loaded(ev/"post-com.sicarii.desktop-autosort-notify.txt"): + raise SystemExit("personal notify missing after run") +print("post_absence_ok=1") +print("personal_mover_unchanged=1") +PY + +# Hash durable records then delete auth bytes +if [ -d "$SAC/.desktidy-probe-support" ]; then + mkdir -p "$EV/support" + cp -R "$SAC/.desktidy-probe-support/." "$EV/support/" || true +fi +find "$EV" -type f -exec shasum -a 256 {} \; >"$EV/MANIFEST.sha256" +rm -f "$REG" "$UNREG" +echo "auth_bytes_deleted=1" + +ELAPSED=$(( $(date +%s) - REGISTER_STARTED_AT )) +echo "rollback_elapsed_secs=$ELAPSED" +echo "PHASE1B_OBSERVE_END" +echo "EVIDENCE=$EV" +echo "register_exit=$REG_RC unregister_class=$CLASS" From b52f9e1c38fbf3eb23fe5d03196e38e070e77667 Mon Sep 17 00:00:00 2001 From: Anubis Quantum Cipher Date: Fri, 14 Aug 2026 12:56:24 -0400 Subject: [PATCH 3/4] R1B phase1b: sacrificial observation transcript (non-final) Record one bounded SMAppService register/unregister on a temporary non-Desktop root. Observed launchd label com.desktidy.sacrificial; unregister restored absence. Personal mover unchanged. Login Items, FDA/TCC, and reboot/login remain unobserved. Do not widen production self-labels from this transcript. --- .../R1B_PHASE1B_SACRIFICIAL_OBSERVATION.md | 123 ++++++++++++++++++ .../phase1b-observation/codesign-probe.txt | 23 ++++ .../phase1b-observation/observed-label.txt | 1 + .../phase1b-observation/postcall.jsonl | 2 + .../phase1b-observation/precall.jsonl | 2 + .../evidence/phase1b-observation/register.out | 14 ++ .../phase1b-observation/unregister.out | 14 ++ 7 files changed, 179 insertions(+) create mode 100644 docs/evidence/R1B_PHASE1B_SACRIFICIAL_OBSERVATION.md create mode 100644 docs/evidence/phase1b-observation/codesign-probe.txt create mode 100644 docs/evidence/phase1b-observation/observed-label.txt create mode 100644 docs/evidence/phase1b-observation/postcall.jsonl create mode 100644 docs/evidence/phase1b-observation/precall.jsonl create mode 100644 docs/evidence/phase1b-observation/register.out create mode 100644 docs/evidence/phase1b-observation/unregister.out diff --git a/docs/evidence/R1B_PHASE1B_SACRIFICIAL_OBSERVATION.md b/docs/evidence/R1B_PHASE1B_SACRIFICIAL_OBSERVATION.md new file mode 100644 index 0000000..942473e --- /dev/null +++ b/docs/evidence/R1B_PHASE1B_SACRIFICIAL_OBSERVATION.md @@ -0,0 +1,123 @@ +# R1B Phase 1B — sacrificial SMAppService observation + +Date: 2026-08-14 +Commit built and observed: `673f49182dad83fd3dc81513927edc2057620abc` +Command: `scripts/observe-phase1b.sh` +Ad-hoc signed probe only. Not Developer ID / not notarized. + +Sacrificial root: `/private/tmp/desktidy-phase1b-root-dv5FP4` (outside Desktop, mode 0700, current-user-owned, no foreign overlap). +Probe executable SHA-256: `2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6` +Helper SHA-256: `3dfbfb84fd7953cc5b6c4219decf862df251cd20b7bf6e727b09a8b12c20dd7c` +Embedded plist SHA-256: `0636033219e20183401573a8b929913e0ecfa9b3f90fd57a614cf55f2601c2c2` +Register authorization digest: `3dd1e2517f2ca46884393922bec1685d632161b6bbefa32a1bf80e9e00abe889` +Unregister authorization digest: `9fa452ffdcc330e902f4a18726a507ed8f9c1ff707f5313b93873babb1a2eecb` +Authorization file bytes were deleted after the lifecycle. + +No `launchctl bootstrap/bootout/kickstart/enable/disable`. +No live Desktop traversal or file creation. +No personal-mover mutation. +No Login Items / FDA / TCC UI click. + +## Pre-observation `launchctl print` (read-only) + +| Label | rc | +|---|---| +| `com.desktidy.sort` | 113 not loaded | +| `com.desktidy.notify` | 113 not loaded | +| `com.desktidy.sacrificial` | 113 not loaded | +| `com.sicarii.desktop-autosort` | 0 loaded | +| `com.sicarii.desktop-autosort-notify` | 0 loaded | + +## Register + +``` +GRANT_PREPARED +operation=register +executableSHA256=2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 +sourceCommit=673f49182dad83fd3dc81513927edc2057620abc +root=/private/tmp/desktidy-phase1b-root-dv5FP4 +nonce=nonce-r2-reg1 +transactionID=06f88019b9a2038879aa4aae912d13359a289eedac7d4e9a30a45f0b7799eb9f +MUTATION_ATTEMPTED +dispatch_result=invoked +status=enabled +ledger_constructions=1 +ledger_registers=1 +ledger_unregisters=0 +register_exit=0 +``` + +`launchctl print gui/501/com.desktidy.sacrificial` rc=0. Observed fields: + +- launchd label: **`com.desktidy.sacrificial`** +- `managed_by = com.apple.xpc.ServiceManagement` +- `path = (submitted by smd.12013)` +- `state = not running` +- `program identifier = Contents/MacOS/SacrificialHelper (mode: 2)` +- `parent bundle identifier = com.desktidy.sacrificial-probe` +- `XPC_SERVICE_NAME => com.desktidy.sacrificial` +- `runs = 0` +- `last exit code = (never exited)` + +Helper did not run (`RunAtLoad`/`KeepAlive` false). Heartbeat absent. +Login Items visible string: **not read** (no System Settings UI). +FDA/TCC: **not observed**. +Reboot/login: **not performed**. + +## Unregister + +``` +GRANT_PREPARED +operation=unregister +executableSHA256=2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 +sourceCommit=673f49182dad83fd3dc81513927edc2057620abc +root=/private/tmp/desktidy-phase1b-root-dv5FP4 +nonce=nonce-r2-unreg1 +transactionID=b831b37b90611d368b7cd3813bd663d9ea7f24981f917c8331cd1b3929c9b60b +MUTATION_ATTEMPTED +dispatch_result=invoked +status=notRegistered +ledger_constructions=1 +ledger_registers=0 +ledger_unregisters=1 +unregister_exit=0 +``` + +`launchctl print gui/501/com.desktidy.sacrificial` rc=113 (not loaded). + +Rollback elapsed: 0 seconds. Watchdog did not fire. + +## Post-observation live print + +| Label | rc | +|---|---| +| `com.desktidy.sort` | 113 | +| `com.desktidy.notify` | 113 | +| `com.desktidy.sacrificial` | 113 | +| `com.sicarii.desktop-autosort` | 0 | +| `com.sicarii.desktop-autosort-notify` | 0 | + +Independent post-run reread: production and sacrificial absent; both personal labels still loaded. + +## Adapter / transaction ledger (redacted) + +Precall: + +``` +06f88019b9a2038879aa4aae912d13359a289eedac7d4e9a30a45f0b7799eb9f nonce-r2-reg1 register 3dd1e2517f2ca46884393922bec1685d632161b6bbefa32a1bf80e9e00abe889 +b831b37b90611d368b7cd3813bd663d9ea7f24981f917c8331cd1b3929c9b60b nonce-r2-unreg1 unregister 9fa452ffdcc330e902f4a18726a507ed8f9c1ff707f5313b93873babb1a2eecb +``` + +Postcall: + +``` +06f88019… nonce-r2-reg1 register invoked enabled +b831b37b… nonce-r2-unreg1 unregister invoked notRegistered +``` + +## What this does not authorize + +Do **not** add `com.desktidy.sacrificial` to `ProductIdentity.selfLabels` +from this transcript. This is one sacrificial observation on this Mac. It +does not prove reboot/login, Login Items pixels, FDA/TCC, or production +Desktop migration. A future production app-agent label remains unobserved. diff --git a/docs/evidence/phase1b-observation/codesign-probe.txt b/docs/evidence/phase1b-observation/codesign-probe.txt new file mode 100644 index 0000000..1434c13 --- /dev/null +++ b/docs/evidence/phase1b-observation/codesign-probe.txt @@ -0,0 +1,23 @@ +Executable=/private/tmp/desktidy-phase1b-build-NRNlGf/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe +Identifier=com.desktidy.sacrificial-probe +Format=app bundle with Mach-O thin (arm64) +CodeDirectory v=20400 size=1047 flags=0x2(adhoc) hashes=26+3 location=embedded +VersionPlatform=1 +VersionMin=917504 +VersionSDK=1705216 +Hash type=sha256 size=32 +CandidateCDHash sha256=fdab8a64dc663d69bf3febdd004105c4058d1742 +CandidateCDHashFull sha256=fdab8a64dc663d69bf3febdd004105c4058d17420a591fb7e4b55f782270f220 +Hash choices=sha256 +CMSDigest=fdab8a64dc663d69bf3febdd004105c4058d17420a591fb7e4b55f782270f220 +CMSDigestType=2 +Executable Segment base=0 +Executable Segment limit=196608 +Executable Segment flags=0x1 +Page size=16384 +CDHash=fdab8a64dc663d69bf3febdd004105c4058d1742 +Signature=adhoc +Info.plist entries=8 +TeamIdentifier=not set +Sealed Resources version=2 rules=13 files=2 +Internal requirements count=0 size=12 diff --git a/docs/evidence/phase1b-observation/observed-label.txt b/docs/evidence/phase1b-observation/observed-label.txt new file mode 100644 index 0000000..ffa7f86 --- /dev/null +++ b/docs/evidence/phase1b-observation/observed-label.txt @@ -0,0 +1 @@ +com.desktidy.sacrificial diff --git a/docs/evidence/phase1b-observation/postcall.jsonl b/docs/evidence/phase1b-observation/postcall.jsonl new file mode 100644 index 0000000..66e8d04 --- /dev/null +++ b/docs/evidence/phase1b-observation/postcall.jsonl @@ -0,0 +1,2 @@ +06f88019b9a2038879aa4aae912d13359a289eedac7d4e9a30a45f0b7799eb9f nonce-r2-reg1 register invoked enabled 2026-08-14T16:54:44Z +b831b37b90611d368b7cd3813bd663d9ea7f24981f917c8331cd1b3929c9b60b nonce-r2-unreg1 unregister invoked notRegistered 2026-08-14T16:54:44Z diff --git a/docs/evidence/phase1b-observation/precall.jsonl b/docs/evidence/phase1b-observation/precall.jsonl new file mode 100644 index 0000000..e2b0578 --- /dev/null +++ b/docs/evidence/phase1b-observation/precall.jsonl @@ -0,0 +1,2 @@ +06f88019b9a2038879aa4aae912d13359a289eedac7d4e9a30a45f0b7799eb9f nonce-r2-reg1 register 2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 673f49182dad83fd3dc81513927edc2057620abc 3dd1e2517f2ca46884393922bec1685d632161b6bbefa32a1bf80e9e00abe889 2026-08-14T16:54:44Z +b831b37b90611d368b7cd3813bd663d9ea7f24981f917c8331cd1b3929c9b60b nonce-r2-unreg1 unregister 2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 673f49182dad83fd3dc81513927edc2057620abc 9fa452ffdcc330e902f4a18726a507ed8f9c1ff707f5313b93873babb1a2eecb 2026-08-14T16:54:44Z diff --git a/docs/evidence/phase1b-observation/register.out b/docs/evidence/phase1b-observation/register.out new file mode 100644 index 0000000..0323a2f --- /dev/null +++ b/docs/evidence/phase1b-observation/register.out @@ -0,0 +1,14 @@ +GRANT_PREPARED +operation=register +executableSHA256=2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 +sourceCommit=673f49182dad83fd3dc81513927edc2057620abc +root=/private/tmp/desktidy-phase1b-root-dv5FP4 +nonce=nonce-r2-reg1 +transactionID=06f88019b9a2038879aa4aae912d13359a289eedac7d4e9a30a45f0b7799eb9f +MUTATION_ATTEMPTED +dispatch_result=invoked +status=enabled +ledger_constructions=1 +ledger_registers=1 +ledger_unregisters=0 +register_exit=0 diff --git a/docs/evidence/phase1b-observation/unregister.out b/docs/evidence/phase1b-observation/unregister.out new file mode 100644 index 0000000..a0e8f78 --- /dev/null +++ b/docs/evidence/phase1b-observation/unregister.out @@ -0,0 +1,14 @@ +GRANT_PREPARED +operation=unregister +executableSHA256=2f621c2f9979d33782aa89fabd1d4e9abbdf99a19afa1e46e6d86aa2fbb285f6 +sourceCommit=673f49182dad83fd3dc81513927edc2057620abc +root=/private/tmp/desktidy-phase1b-root-dv5FP4 +nonce=nonce-r2-unreg1 +transactionID=b831b37b90611d368b7cd3813bd663d9ea7f24981f917c8331cd1b3929c9b60b +MUTATION_ATTEMPTED +dispatch_result=invoked +status=notRegistered +ledger_constructions=1 +ledger_registers=0 +ledger_unregisters=1 +unregister_exit=0 From 91517f94c38a902bce68c4bf8a992e9d4d75b581 Mon Sep 17 00:00:00 2001 From: Anubis Quantum Cipher Date: Fri, 14 Aug 2026 12:59:23 -0400 Subject: [PATCH 4/4] R1B phase3: service identity registry without self-label widening (non-final) Bind the observed sacrificial label into one product-owned registry. Production self-labels remain the CLI pair. Migration orchestration refuses live Desktop targets, personal labels, and unknown identities. --- .github/workflows/ci.yml | 1 + docs/R1B_SERVICE_IDENTITY_PROPOSAL.md | 12 ++- scripts/build-app.sh | 1 + scripts/build-probe.sh | 1 + src/DeskTidy.swift | 3 + src/MigrationState.swift | 11 +++ src/MigrationTransaction.swift | 8 ++ src/Phase3Tests.swift | 112 ++++++++++++++++++++++++++ src/ProductIdentity.swift | 10 ++- src/ServiceIdentity.swift | 106 ++++++++++++++++++++++++ 10 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 src/Phase3Tests.swift create mode 100644 src/ServiceIdentity.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34c9cf6..8465fd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: ./build/desktidy-sort --phase1a-test ./build/desktidy-sort --phase1a1-test ./build/desktidy-sort --phase1b-test + ./build/desktidy-sort --phase3-test chmod +x scripts/test-phase1a1-public-boundary.sh ./scripts/test-phase1a1-public-boundary.sh ./scripts/build-probe.sh build diff --git a/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md b/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md index a0c4ae5..4550329 100644 --- a/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md +++ b/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md @@ -1,7 +1,13 @@ -# R1B Service Identity Proposal — Phase 1 input (NOT APPLIED) +# R1B Service Identity Registry -_Phase 0 proposal only. The accepted self set remains `com.desktidy.sort` and -`com.desktidy.notify`. This document does not widen trust._ +The accepted production self set remains `com.desktidy.sort` and +`com.desktidy.notify`. The sacrificial SMAppService observation on +2026-08-14 recorded launchd label `com.desktidy.sacrificial` under +parent bundle `com.desktidy.sacrificial-probe`. That label is **not** +a production self-label. + +Executable catalog: `src/ServiceIdentity.swift`. ProductIdentity +delegates its accepted self set to that registry. ## Current accepted identity (Phase 0, executable) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 0ae5aa7..9a32479 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -23,6 +23,7 @@ xcrun swiftc -O -parse-as-library \ "$REPO/src/TargetResolver.swift" \ "$REPO/src/NativeConfigParser.swift" \ "$REPO/src/ProductIdentity.swift" \ + "$REPO/src/ServiceIdentity.swift" \ "$REPO/src/Authority.swift" \ "$REPO/src/Receipts.swift" \ "$REPO/src/EffectiveState.swift" \ diff --git a/scripts/build-probe.sh b/scripts/build-probe.sh index 40f3837..8130d0e 100755 --- a/scripts/build-probe.sh +++ b/scripts/build-probe.sh @@ -53,6 +53,7 @@ xcrun swiftc -O -parse-as-library \ "$REPO/src/Config.swift" \ "$REPO/src/Paths.swift" \ "$REPO/src/ProductIdentity.swift" \ + "$REPO/src/ServiceIdentity.swift" \ "$REPO/src/Authority.swift" \ "$REPO/src/StrictJSONObject.swift" \ "$REPO/src/SMAdapter.swift" \ diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index ac11683..73ce4ea 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -129,6 +129,9 @@ final class DeskTidy { if arguments.contains("--phase1b-test") { return Phase1BTests().runAll() ? 0 : 1 } + if arguments.contains("--phase3-test") { + return Phase3Tests().runAll() ? 0 : 1 + } if arguments.contains("--history") { return printHistory(arguments: arguments) } diff --git a/src/MigrationState.swift b/src/MigrationState.swift index 4c20d0a..159ebaf 100644 --- a/src/MigrationState.swift +++ b/src/MigrationState.swift @@ -83,4 +83,15 @@ enum MigrationPolicy { return .refuse } } + + /// Production migration tests and code must not target the live Desktop. + /// Comparison is by canonical path equality against an injected desktop + /// or the process home Desktop when no fixture is supplied. + static func isLiveDesktopTarget(_ targetCanonical: String, desktop: String? = nil) -> Bool { + let desk = AuthorityGuard.canonicalize(desktop ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop").path) + let target = AuthorityGuard.canonicalize(targetCanonical) + if MutationInterlock.rootsEquivalent(target, desk) { return true } + if MutationInterlock.isInsideDesktop(target.path, desktop: desk) { return true } + return false + } } diff --git a/src/MigrationTransaction.swift b/src/MigrationTransaction.swift index 823d84f..22fa9a1 100644 --- a/src/MigrationTransaction.swift +++ b/src/MigrationTransaction.swift @@ -66,6 +66,14 @@ struct MigrationOrchestrator { ) } + if ServiceIdentityRegistry.isNeverTarget(plistName) + || plistName.contains("desktop-autosort") + || !ServiceIdentityRegistry.isKnownIdentity(plistName) { + return rec(.refused, before: "unprobed", after: "unprobed", rollback: false) + } + if MigrationPolicy.isLiveDesktopTarget(targetCanonical) { + return rec(.refused, before: "unprobed", after: "unprobed", rollback: false) + } let state = MigrationPolicy.classify(evidence) if MigrationPolicy.decide(state: state, intent: intent) == .refuse { return rec(.refused, before: "unprobed", after: "unprobed", rollback: false) diff --git a/src/Phase3Tests.swift b/src/Phase3Tests.swift new file mode 100644 index 0000000..eb61c3b --- /dev/null +++ b/src/Phase3Tests.swift @@ -0,0 +1,112 @@ +import Foundation + +// ============================================================================ +// Phase 3 identity-registry and migration-architecture gates. Fake only. +// ============================================================================ + +final class Phase3Tests { + private var pass = 0 + private var fail = 0 + + private func check(_ id: String, _ desc: String, _ ok: Bool, _ detail: String = "") { + if ok { print("PASS \(id) \(desc)"); pass += 1 } + else { print("FAIL \(id) \(desc)\(detail.isEmpty ? "" : " — \(detail)")"); fail += 1 } + } + + func runAll() -> Bool { + runRegistry() + runMigrationBounds() + print("PHASE3 GATES: \(pass) passed, \(fail) failed") + if pass == 0 { print("FAIL summary zero cases"); return false } + return fail == 0 + } + + private func runRegistry() { + check("R01", "production self-labels remain the CLI pair", + ProductIdentity.selfLabels == ["com.desktidy.sort", "com.desktidy.notify"]) + check("R02", "observed sacrificial label is not a production self-label", + !ProductIdentity.selfLabels.contains(ServiceIdentityRegistry.sacrificialObservedLabel)) + check("R03", "ProductIdentity.selfLabels come from the registry", + ProductIdentity.selfLabels == ServiceIdentityRegistry.productionSelfLabels) + check("R04", "configured vs observed disagreement is refused", + ServiceIdentityRegistry.disagreement( + configured: "com.desktidy.app.sort", + observed: "com.desktidy.sacrificial") != nil) + check("R05", "matching configured/observed identity is accepted", + ServiceIdentityRegistry.disagreement( + configured: "com.desktidy.sacrificial", + observed: "com.desktidy.sacrificial") == nil) + check("R06", "personal labels are never mutation targets", + ServiceIdentityRegistry.isNeverTarget("com.sicarii.desktop-autosort") + && ServiceIdentityRegistry.isNeverTarget("com.sicarii.desktop-autosort-notify")) + check("R07", "registry records sacrificial observation class as observedOnce", + ServiceIdentityRegistry.record(role: .sacrificialProbe).observation == .observedOnce) + check("R08", "menu-bar bundle is not an accepted agent self-label", + !ProductIdentity.selfLabels.contains("com.desktidy.app")) + } + + private func runMigrationBounds() { + let fm = FileManager.default + let sac = fm.temporaryDirectory.appendingPathComponent("dt-p3-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: sac, withIntermediateDirectories: true) + let fakeDesk = fm.temporaryDirectory.appendingPathComponent("dt-p3-desk-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: fakeDesk, withIntermediateDirectories: true) + + check("M01", "fixture Desktop-equivalent target is refused", + MigrationPolicy.isLiveDesktopTarget(fakeDesk.path, desktop: fakeDesk.path)) + check("M02", "disjoint sacrificial target is not treated as Desktop", + !MigrationPolicy.isLiveDesktopTarget(sac.path, desktop: fakeDesk.path)) + + let fake = FakeSMAdapter() + fake.statusResult = .success(.notRegistered) + fake.registerResult = .success(()) + let orch = MigrationOrchestrator(adapter: fake) + let ev = MigrationEvidence( + targetValid: true, legacyCLIPresent: false, observedAppAgentPresent: false, + foreignOverlap: false, registrationStatusKnown: true, registrationEnabled: false, + transactionOpen: false, transactionContradictory: false, rollbackMarked: false) + let ctx = InterlockContext( + isSacrificialProbeExecutable: true, requestedOperation: .register, + plistName: "com.desktidy.sacrificial", actualBundleSHA256: String(repeating: "ab", count: 32), + actualSourceCommit: "d259b2b971b83ce89e34426af791422adea8e472", now: Date(), + usedNonces: [], foreignOverlap: false, + desktopCanonical: AuthorityGuard.canonicalize(fakeDesk.path), sacrificialExists: true) + let liveDesk = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Desktop").path + let txDesk = orch.attempt( + intent: .beginRegistration, evidence: ev, authData: nil, context: ctx, + plistName: "com.desktidy.sacrificial", + sourceCommit: "d259b2b971b83ce89e34426af791422adea8e472", + bundleHash: String(repeating: "ab", count: 32), + targetCanonical: liveDesk, priorCLIPresent: false) + check("M03", "orchestrator refuses a live Desktop production-migration target", + txDesk.outcome == .refused && fake.registerCount == 0) + + let txPersonal = orch.attempt( + intent: .beginRegistration, evidence: ev, authData: nil, context: ctx, + plistName: "com.sicarii.desktop-autosort.plist", + sourceCommit: "d259b2b971b83ce89e34426af791422adea8e472", + bundleHash: String(repeating: "ab", count: 32), + targetCanonical: sac.path, priorCLIPresent: false) + check("M04", "orchestrator refuses personal-mover plist", + txPersonal.outcome == .refused && fake.registerCount == 0) + + let txUnknown = orch.attempt( + intent: .beginRegistration, evidence: ev, authData: nil, context: ctx, + plistName: "com.desktidy.app.sort.plist", + sourceCommit: "d259b2b971b83ce89e34426af791422adea8e472", + bundleHash: String(repeating: "ab", count: 32), + targetCanonical: sac.path, priorCLIPresent: false) + check("M05", "unobserved production app-agent plist is not a known identity", + txUnknown.outcome == .refused && fake.registerCount == 0) + + check("M06", "only cliOnly or neitherInstalled may begin", + MigrationPolicy.decide(state: .cliOnly, intent: .beginRegistration) == .allow + && MigrationPolicy.decide(state: .neitherInstalled, intent: .beginRegistration) == .allow + && MigrationPolicy.decide(state: .dualDeskTidyPresence, intent: .beginRegistration) == .refuse + && MigrationPolicy.decide(state: .appOnly, intent: .beginRegistration) == .refuse) + + check("M07", "rollback never targets the personal mover", + ServiceIdentityRegistry.isNeverTarget("com.sicarii.desktop-autosort") + && MigrationPolicy.decide(state: .appOnly, intent: .rollback) == .allow) + } +} diff --git a/src/ProductIdentity.swift b/src/ProductIdentity.swift index ea28e6b..8e54c4d 100644 --- a/src/ProductIdentity.swift +++ b/src/ProductIdentity.swift @@ -8,10 +8,12 @@ import Foundation // ============================================================================ enum ProductIdentity { - static let sortLabel = "com.desktidy.sort" - static let notifyLabel = "com.desktidy.notify" - static let selfLabels: Set = [sortLabel, notifyLabel] - static let expectedProgramBasenames: Set = ["desktidy-sort", "desktidy-notify", "DeskTidy"] + static let sortLabel = ServiceIdentityRegistry.record(role: .sortCLI).label + static let notifyLabel = ServiceIdentityRegistry.record(role: .notifyCLI).label + static let selfLabels: Set = ServiceIdentityRegistry.productionSelfLabels + static let expectedProgramBasenames: Set = Set( + ServiceIdentityRegistry.records.filter { $0.acceptedSelf || $0.role == .menuBarApp }.map(\.expectedProgram) + ) static func isSelf(label: String, programPath: String?, programExists: Bool) -> Bool { guard selfLabels.contains(label) else { return false } diff --git a/src/ServiceIdentity.swift b/src/ServiceIdentity.swift new file mode 100644 index 0000000..fac75a7 --- /dev/null +++ b/src/ServiceIdentity.swift @@ -0,0 +1,106 @@ +import Foundation + +// ============================================================================ +// One product-owned service identity registry. +// +// Production accepted self-labels remain the CLI pair. The sacrificial +// SMAppService label was observed once on this Mac and is recorded here +// as observation evidence, not as a production self-label. +// ============================================================================ + +enum ServiceIdentityRole: String, Equatable { + case sortCLI + case notifyCLI + case menuBarApp + case sacrificialProbe + case sacrificialHelper + case personalMover + case personalNotify +} + +enum ServiceIdentityObservation: String, Equatable { + case coded + case observedOnce + case hypothesizedUnobserved + case neverTarget +} + +struct ServiceIdentityRecord: Equatable { + var role: ServiceIdentityRole + var label: String + var bundleID: String? + var plistName: String? + var expectedProgram: String + var acceptedSelf: Bool + var observation: ServiceIdentityObservation +} + +enum ServiceIdentityRegistry { + static let records: [ServiceIdentityRecord] = [ + .init(role: .sortCLI, label: "com.desktidy.sort", bundleID: nil, + plistName: "com.desktidy.sort.plist", expectedProgram: "desktidy-sort", + acceptedSelf: true, observation: .coded), + .init(role: .notifyCLI, label: "com.desktidy.notify", bundleID: nil, + plistName: "com.desktidy.notify.plist", expectedProgram: "desktidy-notify", + acceptedSelf: true, observation: .coded), + .init(role: .menuBarApp, label: "com.desktidy.app", bundleID: "com.desktidy.app", + plistName: nil, expectedProgram: "DeskTidy", + acceptedSelf: false, observation: .coded), + .init(role: .sacrificialProbe, label: "com.desktidy.sacrificial", + bundleID: "com.desktidy.sacrificial-probe", + plistName: "com.desktidy.sacrificial.plist", + expectedProgram: "SacrificialHelper", + acceptedSelf: false, observation: .observedOnce), + .init(role: .sacrificialHelper, label: "com.desktidy.sacrificial", + bundleID: "com.desktidy.sacrificial-probe", + plistName: "com.desktidy.sacrificial.plist", + expectedProgram: "SacrificialHelper", + acceptedSelf: false, observation: .observedOnce), + .init(role: .personalMover, label: "com.sicarii.desktop-autosort", + bundleID: nil, plistName: "com.sicarii.desktop-autosort.plist", + expectedProgram: "desktop-autosort-helper", + acceptedSelf: false, observation: .neverTarget), + .init(role: .personalNotify, label: "com.sicarii.desktop-autosort-notify", + bundleID: nil, plistName: "com.sicarii.desktop-autosort-notify.plist", + expectedProgram: "desktidy-notify", + acceptedSelf: false, observation: .neverTarget), + ] + + static var productionSelfLabels: Set { + Set(records.filter(\.acceptedSelf).map(\.label)) + } + + static var neverTargetLabels: Set { + Set(records.filter { $0.observation == .neverTarget }.map(\.label)) + } + + static var sacrificialObservedLabel: String { + records.first { $0.role == .sacrificialProbe }!.label + } + + static func record(role: ServiceIdentityRole) -> ServiceIdentityRecord { + records.first { $0.role == role }! + } + + /// Configured identity must equal the observed identity when both exist. + static func disagreement(configured: String, observed: String) -> String? { + if configured != observed { + return "configured identity disagrees with observed identity" + } + return nil + } + + static func isAcceptedSelf(_ label: String) -> Bool { + productionSelfLabels.contains(label) + } + + static func isNeverTarget(_ label: String) -> Bool { + neverTargetLabels.contains(label) || label.contains("desktop-autosort") + } + + static func isKnownIdentity(_ name: String) -> Bool { + records.contains { + $0.label == name || $0.plistName == name || $0.plistName == name + ".plist" + } + } +}