@hasna/recordings 0.2.13 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2078 -277
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +10 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +634 -49
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +140 -56
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +187 -79
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +108 -49
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/build_native_fs_guard.sh +15 -2
  59. package/scripts/ci-linux-suite.ts +403 -0
  60. package/scripts/ci-native-build.ts +402 -0
  61. package/scripts/enforce_identity_migration.sh +110 -0
  62. package/scripts/generate-sdk.ts +1 -1
  63. package/scripts/install_macos_app.sh +138 -24
  64. package/scripts/macos_artifact.ts +205 -33
  65. package/scripts/native/realtime-settle-probe/README.md +39 -0
  66. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  67. package/scripts/policy/local-only-approved-targets.txt +21 -0
  68. package/scripts/read_local_only_targets.sh +203 -0
  69. package/scripts/scan-artifact.ts +40 -0
  70. package/scripts/set-version.ts +145 -0
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
  81. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  82. package/src/native/Recordings/App/SidebarView.swift +1 -1
  83. package/src/native/Recordings/Package.swift +24 -5
  84. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  85. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  86. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  87. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  88. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  89. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  90. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  91. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  92. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  93. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  94. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  95. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  96. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  97. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  98. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  99. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  100. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  101. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  102. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  103. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  104. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  105. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  106. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  107. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  108. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  109. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  110. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  111. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  112. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  113. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  114. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  115. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  116. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  117. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  118. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  119. package/src/native/Recordings/build.sh +75 -9
  120. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -0,0 +1,60 @@
1
+ import Testing
2
+
3
+ /// Positive and negative controls for the test framework itself.
4
+ ///
5
+ /// Package.swift pins swift-testing to a revision that does not match the compiler, because the
6
+ /// matching 6.3.x line hard-links a toolchain library that is not on SwiftPM's search path. The
7
+ /// rule that pin was standing in for — "the shim must track the compiler" — exists because a
8
+ /// mismatched shim once corrupted `#expect` Bool-comparison evaluation and passed false
9
+ /// assertions. A silently-passing `#expect` makes every other test in this package meaningless,
10
+ /// and no green run can reveal it.
11
+ ///
12
+ /// These assert the property directly. The negative controls matter more than the positive ones:
13
+ /// `withKnownIssue` fails when its body records *no* issue, so if `#expect` ever stops evaluating
14
+ /// a false comparison, this suite goes red instead of the whole suite going quietly green.
15
+ struct ExpectationIntegrityTests {
16
+ @Test("#expect admits true Bool comparisons")
17
+ func admitsTrueComparisons() {
18
+ #expect(true == true)
19
+ #expect(false == false)
20
+ #expect(true != false)
21
+ #expect((1 == 1) == true)
22
+ #expect((1 == 2) == false)
23
+ }
24
+
25
+ @Test("#expect records an issue for a false Bool comparison")
26
+ func recordsIssueForFalseEquality() {
27
+ withKnownIssue("a false Bool comparison must be recorded, not passed") {
28
+ #expect(false == true)
29
+ }
30
+ }
31
+
32
+ @Test("#expect records an issue for a false Bool inequality")
33
+ func recordsIssueForFalseInequality() {
34
+ withKnownIssue("an untrue inequality must be recorded, not passed") {
35
+ #expect(true != true)
36
+ }
37
+ }
38
+
39
+ @Test("#expect records an issue for a false comparison behind a variable")
40
+ func recordsIssueForFalseComparisonThroughVariables() {
41
+ // The reported corruption involved comparison *evaluation*, so route the operands
42
+ // through variables rather than literals the optimiser can fold away.
43
+ let left = Int.random(in: 1...1)
44
+ let right = left + 1
45
+ #expect(left != right)
46
+ withKnownIssue("a false comparison between variables must be recorded") {
47
+ #expect(left == right)
48
+ }
49
+ }
50
+
51
+ @Test("#require unwraps a present optional")
52
+ func requireUnwrapsPresentOptional() throws {
53
+ // Only the positive direction is asserted here. The negative direction — `#require(nil)`
54
+ // — throws by design, and wrapping it in `withKnownIssue` additionally records an
55
+ // "API was misused" issue, so it adds noise rather than coverage. The Bool-comparison
56
+ // controls above are what the reported corruption was actually about.
57
+ let present: Int? = 7
58
+ #expect(try #require(present) == 7)
59
+ }
60
+ }
@@ -0,0 +1,113 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import RecordingsUpdateProtocol
5
+
6
+ /// The XPC allowlist is the decoding trust boundary for the privileged updater, so these
7
+ /// assert the *exact* set installed for every argument rather than a subset. A set that
8
+ /// grew silently would widen what the broker is willing to decode; a set that shrank
9
+ /// would reject legitimate replies. Either direction should fail the build.
10
+ ///
11
+ /// Membership is compared by class name because Objective-C class objects cannot be cast
12
+ /// to `AnyHashable` in Swift — `NSDictionary.self as! AnyHashable` traps at runtime.
13
+ struct UpdateXPCInterfaceTests {
14
+ private static let installSelector =
15
+ NSSelectorFromString("installWithArchive:manifest:envelope:reply:")
16
+ private static let statusSelector = NSSelectorFromString("queryStatusWithReply:")
17
+
18
+ private func allowedClassNames(
19
+ _ interface: NSXPCInterface,
20
+ selector: Selector,
21
+ argumentIndex: Int,
22
+ ofReply: Bool
23
+ ) -> Set<String> {
24
+ let classes = interface.classes(for: selector, argumentIndex: argumentIndex, ofReply: ofReply)
25
+ return Set(classes.compactMap { ($0 as? AnyClass).map { NSStringFromClass($0) } })
26
+ }
27
+
28
+ @Test("every install request argument accepts only a file handle")
29
+ func installRequestAcceptsOnlyFileHandles() {
30
+ let interface = makeRecordingsUpdateXPCInterface()
31
+ for index in 0..<3 {
32
+ let allowed = allowedClassNames(
33
+ interface,
34
+ selector: Self.installSelector,
35
+ argumentIndex: index,
36
+ ofReply: false
37
+ )
38
+ #expect(allowed == ["NSFileHandle"], "install request argument \(index)")
39
+ }
40
+ }
41
+
42
+ @Test("install and status replies accept only scalar dictionaries")
43
+ func repliesAcceptOnlyScalarDictionaries() {
44
+ let interface = makeRecordingsUpdateXPCInterface()
45
+ let expected: Set<String> = ["NSDictionary", "NSString", "NSNumber"]
46
+ for selector in [Self.installSelector, Self.statusSelector] {
47
+ let allowed = allowedClassNames(
48
+ interface,
49
+ selector: selector,
50
+ argumentIndex: 0,
51
+ ofReply: true
52
+ )
53
+ #expect(allowed == expected, "reply allowlist for \(selector)")
54
+ }
55
+ }
56
+
57
+ @Test("the reply allowlist admits no container beyond the dictionary itself")
58
+ func replyAllowlistExcludesNestedContainers() {
59
+ let interface = makeRecordingsUpdateXPCInterface()
60
+ let allowed = allowedClassNames(
61
+ interface,
62
+ selector: Self.statusSelector,
63
+ argumentIndex: 0,
64
+ ofReply: true
65
+ )
66
+ for rejected in ["NSArray", "NSData", "NSURL", "NSValue", "NSSet"] {
67
+ #expect(!allowed.contains(rejected), "\(rejected) must not be decodable in a reply")
68
+ }
69
+ }
70
+
71
+ /// The allowlist only matters if it is actually sufficient to decode what the broker
72
+ /// sends. Rather than comparing class names — a native Swift `String` key bridges to
73
+ /// `Swift.__StringStorage`, not `NSString`, so name matching tests the bridge instead of
74
+ /// the contract — this round-trips real replies through secure coding restricted to the
75
+ /// installed allowlist. A reply that grew a type XPC would refuse to decode fails here.
76
+ @Test("the installed allowlist can securely decode the replies the broker actually sends")
77
+ func allowlistDecodesRealReplies() throws {
78
+ let interface = makeRecordingsUpdateXPCInterface()
79
+ let allowedClasses = interface
80
+ .classes(for: Self.statusSelector, argumentIndex: 0, ofReply: true)
81
+ .compactMap { $0 as? AnyClass }
82
+ #expect(allowedClasses.count == 3)
83
+
84
+ let replies = [
85
+ updateSuccessReply(
86
+ transactionID: "t-1",
87
+ releaseID: "r-1",
88
+ installedVersion: "0.2.15"
89
+ ),
90
+ updateFailureReply(.invalidEnvelope, message: "rejected"),
91
+ ]
92
+ for reply in replies {
93
+ let data = try NSKeyedArchiver.archivedData(
94
+ withRootObject: reply,
95
+ requiringSecureCoding: true
96
+ )
97
+ let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
98
+ unarchiver.requiresSecureCoding = true
99
+ let decoded = unarchiver.decodeObject(
100
+ of: allowedClasses,
101
+ forKey: NSKeyedArchiveRootObjectKey
102
+ )
103
+ unarchiver.finishDecoding()
104
+ #expect(unarchiver.error == nil, "reply is not decodable under the allowlist")
105
+ let decodedReply = try #require(decoded as? NSDictionary)
106
+ #expect(decodedReply.count == reply.count)
107
+ #expect(
108
+ decodedReply[RecordingsUpdateReplyKey.lifecycle] as? String
109
+ == RecordingsUpdateConstants.lifecycle
110
+ )
111
+ }
112
+ }
113
+ }
@@ -16,6 +16,7 @@
16
16
  #include <sys/acl.h>
17
17
  #include <sys/resource.h>
18
18
  #include <sys/stat.h>
19
+ #include <sys/sysctl.h>
19
20
  #include <sys/types.h>
20
21
  #include <sys/wait.h>
21
22
  #include <time.h>
@@ -29,6 +30,39 @@
29
30
  #define OUTPUT_PATH_PREFIX "/Library/Application Support/Hasna/Recordings/Updates/transaction-"
30
31
  #define OUTPUT_PATH_SUFFIX "/verifier-output"
31
32
 
33
+ /*
34
+ * Lower bound for the pre-exec descriptor sweep, so a pathologically small
35
+ * RLIMIT_NOFILE can never shrink the sweep below the descriptors we place by hand.
36
+ */
37
+ #define DESCRIPTOR_SWEEP_FLOOR 64
38
+
39
+ /*
40
+ * PRIVATE API, DELIBERATE — RISK ACCEPTED.
41
+ *
42
+ * `sandbox_init_with_parameters` is exported by libSystem (`_sandbox_init_with_parameters`
43
+ * is present in libSystem.tbd) but is declared in no SDK header, so this declaration is
44
+ * the only contract we have. Apple may change its signature or remove it in any macOS
45
+ * update, and nothing checks our prototype against theirs.
46
+ *
47
+ * Why we accept that rather than avoid it: the public `sandbox_init` cannot express this
48
+ * policy. It is API_DEPRECATED since macOS 10.8, and its own header states that `flags`
49
+ * "Must be SANDBOX_NAMED" — it can only select one of the five built-in kSBXProfile*
50
+ * profiles, so it can neither load our profile nor bind the OUTPUT_DIR parameter the
51
+ * profile is written against. The alternative of textually substituting the output path
52
+ * into the profile source would move a runtime-derived string into a security policy
53
+ * document, which is strictly worse than depending on the parameter API built for it.
54
+ *
55
+ * Do NOT mark this `weak_import`. Referenced strongly, a symbol Apple removes becomes a
56
+ * link error at build time, which fails closed. Weak-linked, it would become a NULL call
57
+ * or a silently unsandboxed verifier child at runtime.
58
+ */
59
+ extern int sandbox_init_with_parameters(
60
+ const char *profile,
61
+ uint64_t flags,
62
+ const char *const parameters[],
63
+ char **errorbuf
64
+ );
65
+
32
66
  int recordings_descriptor_has_no_extended_acl(int descriptor) {
33
67
  if (descriptor < 0) return 0;
34
68
  errno = 0;
@@ -244,6 +278,57 @@ static bool apply_limit(int resource, rlim_t soft, rlim_t hard) {
244
278
  return setrlimit(resource, &limit) == 0;
245
279
  }
246
280
 
281
+ /*
282
+ * Highest descriptor number that could exist in this process, used to bound the pre-exec
283
+ * sweep below. MUST be evaluated in the parent, before the child lowers RLIMIT_NOFILE:
284
+ * verifier_child() drops RLIMIT_NOFILE to 32 before it sweeps, and lowering the limit does
285
+ * not close descriptors that are already open, so a getrlimit() call made after that point
286
+ * reports 32 and would skip every inherited descriptor at or above 32.
287
+ */
288
+ static rlim_t inherited_descriptor_ceiling(void) {
289
+ rlim_t ceiling = 0;
290
+ struct rlimit limit;
291
+ if (getrlimit(RLIMIT_NOFILE, &limit) == 0) {
292
+ if (limit.rlim_cur != RLIM_INFINITY && limit.rlim_cur > ceiling) ceiling = limit.rlim_cur;
293
+ if (limit.rlim_max != RLIM_INFINITY && limit.rlim_max > ceiling) ceiling = limit.rlim_max;
294
+ }
295
+ /*
296
+ * An unlimited hard limit tells us nothing, so fall back to the kernel's per-process
297
+ * cap, which is the real ceiling on descriptor numbers.
298
+ */
299
+ int per_process_maximum = 0;
300
+ size_t size = sizeof(per_process_maximum);
301
+ if (sysctlbyname("kern.maxfilesperproc", &per_process_maximum, &size, NULL, 0) == 0 &&
302
+ per_process_maximum > 0) {
303
+ if (ceiling == 0 || ceiling > (rlim_t)per_process_maximum) {
304
+ ceiling = (rlim_t)per_process_maximum;
305
+ }
306
+ }
307
+ if (ceiling < DESCRIPTOR_SWEEP_FLOOR) ceiling = DESCRIPTOR_SWEEP_FLOOR;
308
+ return ceiling;
309
+ }
310
+
311
+ /*
312
+ * Darwin has neither `closefrom()` nor Linux's `close_range()` — both are absent from the
313
+ * macOS SDK entirely, not merely behind a header we failed to include — so the sweep is
314
+ * open-coded.
315
+ *
316
+ * A plain bounded loop of close() is used rather than enumerating /dev/fd, which would
317
+ * close exactly the open descriptors instead of probing a range. /dev/fd enumeration needs
318
+ * opendir()/readdir(), which allocate; this code runs after fork() in a multithreaded XPC
319
+ * broker, where malloc can deadlock if another thread held its lock at fork time. close()
320
+ * and getrlimit() are plain syscalls and are safe here, so correctness of the sandbox
321
+ * boundary is not traded for an allocation in the one place we must not allocate.
322
+ *
323
+ * The cost is bounded by `ceiling` failed close() calls once per verification, which is
324
+ * negligible against the caller's 90-second budget.
325
+ */
326
+ static void close_descriptors_from(int lowest, rlim_t ceiling) {
327
+ for (rlim_t descriptor = (rlim_t)lowest; descriptor < ceiling; descriptor++) {
328
+ (void)close((int)descriptor);
329
+ }
330
+ }
331
+
247
332
  static void kill_and_reap(pid_t child) {
248
333
  kill(child, SIGKILL);
249
334
  int wait_status = 0;
@@ -257,7 +342,8 @@ static void verifier_child(
257
342
  gid_t group_id,
258
343
  const char *expected_sha256,
259
344
  const char *sandbox_profile,
260
- const char *output_path
345
+ const char *output_path,
346
+ rlim_t descriptor_ceiling
261
347
  ) {
262
348
  if (!apply_limit(RLIMIT_CPU, 60, 60) ||
263
349
  !apply_limit(RLIMIT_AS, 4ULL * 1024 * 1024 * 1024, 4ULL * 1024 * 1024 * 1024) ||
@@ -276,7 +362,7 @@ static void verifier_child(
276
362
  _exit(74);
277
363
  }
278
364
  if (null_descriptor > CHILD_OUTPUT_FD) close(null_descriptor);
279
- closefrom(5);
365
+ close_descriptors_from(CHILD_OUTPUT_FD + 1, descriptor_ceiling);
280
366
 
281
367
  gid_t groups[] = { group_id };
282
368
  if (setgroups(1, groups) != 0 || setgid(group_id) != 0 || setuid(user_id) != 0 ||
@@ -289,12 +375,23 @@ static void verifier_child(
289
375
  };
290
376
  char *sandbox_error = NULL;
291
377
  if (sandbox_init_with_parameters(sandbox_profile, 0, sandbox_parameters, &sandbox_error) != 0) {
292
- if (sandbox_error != NULL) sandbox_free_error(sandbox_error);
378
+ if (sandbox_error != NULL) {
379
+ // `sandbox_free_error` is the only documented way to release the buffer the
380
+ // sandbox API allocates, and it is deprecated with no replacement. We _exit
381
+ // immediately below, so this is belt-and-braces rather than load-bearing;
382
+ // the deprecation warning is scoped to this one call instead of the file.
383
+ #pragma clang diagnostic push
384
+ #pragma clang diagnostic ignored "-Wdeprecated-declarations"
385
+ sandbox_free_error(sandbox_error);
386
+ #pragma clang diagnostic pop
387
+ }
293
388
  _exit(72);
294
389
  }
295
390
  // Sandbox initialization is allowed to use implementation-internal descriptors.
296
391
  // Re-close everything above the two explicit artifact descriptors before exec.
297
- closefrom(5);
392
+ // RLIMIT_NOFILE is 32 by this point, so the sandbox cannot have opened anything above
393
+ // it; sweeping to the inherited ceiling again is generous and harmless.
394
+ close_descriptors_from(CHILD_OUTPUT_FD + 1, descriptor_ceiling);
298
395
 
299
396
  char archive_argument[16];
300
397
  char output_argument[16];
@@ -356,6 +453,9 @@ int recordings_run_artifact_verifier(
356
453
  return errno != 0 ? errno : EMFILE;
357
454
  }
358
455
 
456
+ /* Captured before fork(), while getrlimit() still describes the real descriptor table. */
457
+ const rlim_t descriptor_ceiling = inherited_descriptor_ceiling();
458
+
359
459
  const pid_t child = fork();
360
460
  if (child < 0) {
361
461
  status = errno;
@@ -367,7 +467,8 @@ int recordings_run_artifact_verifier(
367
467
  verifier_group_id,
368
468
  expected_archive_sha256,
369
469
  sandbox_profile,
370
- output_path
470
+ output_path,
471
+ descriptor_ceiling
371
472
  );
372
473
  } else {
373
474
  struct timespec started;
@@ -380,6 +380,8 @@ if [ "$SOURCE_PACKAGE_ROOT" != "$PACKAGE_ROOT" ]; then
380
380
  "$SOURCE_PACKAGE_ROOT/scripts/native_fs_guard.ts" \
381
381
  "$SOURCE_PACKAGE_ROOT/scripts/build_native_fs_guard.sh" \
382
382
  "$SOURCE_PACKAGE_ROOT/scripts/native/recordings_fs_guard.c" \
383
+ "$SOURCE_PACKAGE_ROOT/scripts/policy/local-only-approved-targets.txt" \
384
+ "$SOURCE_PACKAGE_ROOT/scripts/read_local_only_targets.sh" \
383
385
  "$SOURCE_PACKAGE_ROOT/packaging/macos/build_release_pkg.sh" \
384
386
  "$SOURCE_PACKAGE_ROOT/packaging/macos/release_lifecycle.ts" \
385
387
  "$SOURCE_PACKAGE_ROOT/packaging/macos/Verifier.entitlements" \
@@ -418,7 +420,7 @@ generate_and_verify_native_fs_guard() {
418
420
  TMPDIR="$BUILD_WORK_DIR" \
419
421
  "$BASH_EXECUTABLE" "$SOURCE_PACKAGE_ROOT/scripts/build_native_fs_guard.sh" \
420
422
  "$addon" "$dependency_root/node_modules/node-api-headers/include"
421
- /usr/bin/lipo -verify_arch arm64 x86_64 "$addon"
423
+ /usr/bin/lipo "$addon" -verify_arch arm64 x86_64
422
424
  if [ "$MODE" = "release" ]; then
423
425
  run_codesign --force --sign "$CODESIGN_IDENTITY" --options runtime --timestamp "$addon"
424
426
  else
@@ -991,8 +993,34 @@ if [ "$MODE" = "release" ]; then
991
993
  fi
992
994
  fi
993
995
  elif [ "$MODE" = "local" ]; then
994
- if [ "$LOCAL_APPROVED_TARGET" != "station06" ]; then
995
- echo "Local-only builds currently require RECORDINGS_LOCAL_APPROVED_TARGET=station06." >&2
996
+ # Same policy file and same sourced reader the installer uses. The artifact tool
997
+ # re-implements the same rules in TypeScript and re-checks the target later, so
998
+ # agreement between the two implementations rests on their shared contract test,
999
+ # not on shared code.
1000
+ #
1001
+ # Both resolve from $SOURCE_PACKAGE_ROOT — the `git archive` of $SOURCE_SHA — and not
1002
+ # from $PACKAGE_ROOT. They used to read $PACKAGE_ROOT, which is the mutable working
1003
+ # tree, so this gate validated a different file from the one the artifact was built
1004
+ # from: with the archived policy listing only station06, a working-tree policy listing
1005
+ # "attacker" got "attacker" approved, and station06 refused. Every other build input
1006
+ # is resolved from the snapshot for exactly this reason (see the required-snapshot-input
1007
+ # loop above, which already demands both of these paths inside the archive), and a gate
1008
+ # that checks a file the artifact does not contain is not a gate.
1009
+ LOCAL_TARGET_POLICY="${SOURCE_PACKAGE_ROOT}/scripts/policy/local-only-approved-targets.txt"
1010
+ LOCAL_TARGET_READER="${SOURCE_PACKAGE_ROOT}/scripts/read_local_only_targets.sh"
1011
+ [ -f "$LOCAL_TARGET_READER" ] && [ ! -L "$LOCAL_TARGET_READER" ] || {
1012
+ echo "Packaged local-only approved target reader is missing." >&2
1013
+ exit 1
1014
+ }
1015
+ # shellcheck source=/dev/null
1016
+ . "$LOCAL_TARGET_READER"
1017
+ LOCAL_APPROVED_TARGET_MATCHED=0
1018
+ LOCAL_APPROVED_TARGET_LIST=""
1019
+ read_local_only_targets \
1020
+ "$LOCAL_TARGET_POLICY" LOCAL_APPROVED_TARGET_LIST LOCAL_APPROVED_TARGET_MATCHED \
1021
+ "$LOCAL_APPROVED_TARGET" || exit 1
1022
+ if [ "$LOCAL_APPROVED_TARGET_MATCHED" -ne 1 ]; then
1023
+ echo "Local-only builds require an approved RECORDINGS_LOCAL_APPROVED_TARGET (${LOCAL_APPROVED_TARGET_LIST})." >&2
996
1024
  exit 1
997
1025
  fi
998
1026
  if ! [[ "$LOCAL_APPROVED_TARGET_IDENTITY_SHA256" =~ ^[a-f0-9]{64}$ ]]; then
@@ -1031,13 +1059,50 @@ elif [ "$MODE" = "local" ]; then
1031
1059
  echo "Local-only artifacts must be built on a different authenticated Tailscale node." >&2
1032
1060
  exit 1
1033
1061
  fi
1034
- CODESIGN_IDENTITY="-"
1035
- EXPECTED_TEAM_ID="ADHOC"
1062
+ # Signing identity for a local-station artifact.
1063
+ #
1064
+ # This used to be an unconditional `CODESIGN_IDENTITY="-"`, and that was the defect
1065
+ # behind "the Fn key does nothing". Hold-to-talk is a CGEventTap (RecordingsLib/
1066
+ # FnKeyMonitor.swift), macOS only creates that tap for a process with Accessibility,
1067
+ # and TCC keys every grant to bundle identifier PLUS code-signing identity. An ad-hoc
1068
+ # signature has no identity to key on beyond a CDHash that changes on every rebuild,
1069
+ # so each reinstall orphaned the grant, AXIsProcessTrusted() stayed false and the app
1070
+ # logged "fn monitor start ok=false" forever. Ad-hoc is therefore not a neutral default
1071
+ # here — it structurally prevents the permission the feature needs from ever sticking.
1072
+ #
1073
+ # So when a Developer ID Application identity is supplied, sign with it and pin its
1074
+ # team. That is the same stable identity a release uses; what still separates this from
1075
+ # a release is notarization, the single-target binding, and the builder attestation
1076
+ # above, none of which are relaxed. With no identity supplied the old ad-hoc behaviour
1077
+ # is kept verbatim, because a Mac without the signing key must still be able to build.
1078
+ if [ -n "$CODESIGN_IDENTITY" ] && [ "$CODESIGN_IDENTITY" != "-" ]; then
1079
+ if [[ "$CODESIGN_IDENTITY" != "Developer ID Application:"* ]]; then
1080
+ echo "Local-station signing requires a Developer ID Application identity in RECORDINGS_CODESIGN_IDENTITY." >&2
1081
+ exit 1
1082
+ fi
1083
+ if [ -z "$EXPECTED_TEAM_ID" ]; then
1084
+ echo "Local-station Developer ID signing requires RECORDINGS_EXPECTED_TEAM_IDENTIFIER to pin the team." >&2
1085
+ exit 1
1086
+ fi
1087
+ if [ "$EXPECTED_TEAM_ID" = "ADHOC" ]; then
1088
+ echo "RECORDINGS_EXPECTED_TEAM_IDENTIFIER=ADHOC is not a Developer ID team." >&2
1089
+ exit 1
1090
+ fi
1091
+ LOCAL_SIGNING_MODE="developer_id"
1092
+ else
1093
+ CODESIGN_IDENTITY="-"
1094
+ EXPECTED_TEAM_ID="ADHOC"
1095
+ LOCAL_SIGNING_MODE="ad_hoc"
1096
+ fi
1036
1097
  ARTIFACT_POLICY="local_only"
1037
1098
  APPROVED_TARGET="$LOCAL_APPROVED_TARGET"
1038
1099
  APPROVED_TARGET_IDENTITY_KIND="$LOCAL_APPROVED_TARGET_IDENTITY_KIND"
1039
1100
  APPROVED_TARGET_IDENTITY_SHA256="$LOCAL_APPROVED_TARGET_IDENTITY_SHA256"
1040
- echo "WARNING: local-only artifacts are ad-hoc signed, non-notarized, and restricted to ${APPROVED_TARGET}." >&2
1101
+ if [ "$LOCAL_SIGNING_MODE" = "developer_id" ]; then
1102
+ echo "WARNING: local-only artifacts are Developer ID signed, non-notarized, and restricted to ${APPROVED_TARGET}." >&2
1103
+ else
1104
+ echo "WARNING: local-only artifacts are ad-hoc signed, non-notarized, and restricted to ${APPROVED_TARGET}." >&2
1105
+ fi
1041
1106
  echo "WARNING: installing can change code identity and require Microphone or Accessibility reauthorization." >&2
1042
1107
  else
1043
1108
  BUILD_CONFIGURATION="debug"
@@ -1468,8 +1533,8 @@ run_signed_helper_contract() {
1468
1533
  HOME="$contract_home"
1469
1534
  PATH="/usr/bin:/bin:/usr/sbin:/sbin"
1470
1535
  TMPDIR="$contract_home"
1471
- HASNA_RECORDINGS_STORAGE_MODE="local"
1472
- RECORDINGS_STORAGE_MODE="local"
1536
+ HASNA_RECORDINGS_CLIENT_STORE="sqlite"
1537
+ RECORDINGS_CLIENT_STORE="sqlite"
1473
1538
  HASNA_RECORDINGS_DB_PATH="$contract_home/recordings.db"
1474
1539
  RECORDINGS_AUDIO_DIR="$contract_home/audio"
1475
1540
  )
@@ -1783,7 +1848,8 @@ if [ "$MODE" = "local" ]; then
1783
1848
  --package-root "$PACKAGE_ROOT" \
1784
1849
  --approved-target "$APPROVED_TARGET" \
1785
1850
  --approved-target-identity-kind "$APPROVED_TARGET_IDENTITY_KIND" \
1786
- --approved-target-identity-sha256 "$APPROVED_TARGET_IDENTITY_SHA256"
1851
+ --approved-target-identity-sha256 "$APPROVED_TARGET_IDENTITY_SHA256" \
1852
+ --expected-team-id "$EXPECTED_TEAM_ID"
1787
1853
  publish_app_output
1788
1854
  verify_source_unchanged
1789
1855
  echo "Built immutable local-only app artifact: $FINAL_ARCHIVE"