@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.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2078 -277
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +10 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -49
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +140 -56
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +187 -79
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +108 -49
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/build_native_fs_guard.sh +15 -2
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +138 -24
- package/scripts/macos_artifact.ts +205 -33
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +75 -9
- 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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1472
|
-
|
|
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"
|
|
Binary file
|