diff --git a/macos/Modore/Sources/Modore/Services/LoginItemService.swift b/macos/Modore/Sources/Modore/Services/LoginItemService.swift index 22e9ad1..8f8f655 100644 --- a/macos/Modore/Sources/Modore/Services/LoginItemService.swift +++ b/macos/Modore/Sources/Modore/Services/LoginItemService.swift @@ -102,6 +102,14 @@ enum LoginItemService { .merging(supportModule.files) { current, _ in current } .merging(tokenModule.files) { current, _ in current } for (key, value) in pinnedFiles { + // The module dictionaries merge keep-first, but a raw value used + // to land with an unconditional overwrite -- the exact asymmetry + // that made the "approval_token" vs "approval_token_module" key + // collision possible to reintroduce silently. A colliding key now + // refuses instead of clobbering whichever payload merged first. + guard files[key] == nil else { + return .failure("내부 오류: 고정 파일 키가 충돌해 실행하지 않았습니다 (\(key)).") + } files[key] = value } let result = await LocalProcessRunner.capture( diff --git a/macos/Modore/Sources/Modore/Services/ObservationService.swift b/macos/Modore/Sources/Modore/Services/ObservationService.swift index 911ad59..0a78d44 100644 --- a/macos/Modore/Sources/Modore/Services/ObservationService.swift +++ b/macos/Modore/Sources/Modore/Services/ObservationService.swift @@ -60,7 +60,11 @@ enum ObservationService { return .failure("봉인한 관찰 스크립트를 확인하지 못해 실행하지 않았습니다.") } - let timeout = TimeInterval(windowSeconds + 20) + // The slack after the window is exactly when 4 lsof + 2 ps snapshots + // run -- on precisely the overloaded machines this feature targets. + // 20 fixed seconds was tight there; a minute is still a hard cap, + // and the run is owner-attended either way. + let timeout = TimeInterval(windowSeconds + 60) async let cpuOutcome = run( argument: cpuInvocation.argument, files: cpuInvocation.files, @@ -118,6 +122,11 @@ enum ObservationService { timeout: timeout ) guard result.status == 0, result.endState == .exited else { + // "status 124" alone hid the one failure the owner can actually + // act on: the machine was too loaded to finish inside the cap. + if result.endState == .timedOut { + return .failure("관찰이 제한 시간을 넘겨 중단되었습니다. 시스템 부하가 높을 수 있으니 잠시 뒤 다시 시도하세요.") + } return .failure("관찰 스크립트 실행이 실패했습니다 (status \(result.status)).") } return .success(result.output) diff --git a/macos/Modore/Sources/Modore/Services/RuntimeWorkspace.swift b/macos/Modore/Sources/Modore/Services/RuntimeWorkspace.swift index 5d3bee8..c803169 100644 --- a/macos/Modore/Sources/Modore/Services/RuntimeWorkspace.swift +++ b/macos/Modore/Sources/Modore/Services/RuntimeWorkspace.swift @@ -273,6 +273,17 @@ enum RuntimeWorkspace { mainResourceURL: mainApplicationResourceURL, mainBundleURL: mainApplicationBundleURL ) + // A bundled runtime that is NOT the running app's own signed + // bundle skips runtimePayloadMatchesCodeSignature below. That + // combination is only constructible through this function's + // injected parameters -- production call sites use the + // defaults, where the two URLs coincide -- so it must carry + // the same explicit opt-in as the development branch. Without + // this, a future refactor that decouples the parameters would + // silently run unsigned code with no test to notice. + if !isSignedMainBundle, environment[developmentModeKey] != "1" { + return nil + } do { try installBundledRuntime(from: bundledRuntime, to: installedRuntime) try installUserConfigIfNeeded( diff --git a/macos/Modore/Sources/Modore/Services/ScanModelActions.swift b/macos/Modore/Sources/Modore/Services/ScanModelActions.swift index 57ddc65..a5f34e6 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModelActions.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModelActions.swift @@ -219,6 +219,12 @@ extension ScanModel { var pinnedFiles = invocation.files .merging(supportModule.files) { current, _ in current } .merging(tokenModule.files) { current, _ in current } + // Same checked insert as LoginItemService: a raw-value key that + // collides with a module key must refuse, not clobber. + guard pinnedFiles["approval_token"] == nil else { + errorMessage = "내부 오류: 고정 파일 키가 충돌해 정리를 실행하지 않았습니다." + return + } pinnedFiles["approval_token"] = Data(preview.approvalToken.utf8) let result = await LocalProcessRunner.capture( executable: "/bin/bash", diff --git a/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift b/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift index 20ef24b..59ef469 100644 --- a/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift +++ b/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift @@ -122,6 +122,31 @@ final class CleanupSafetyTests: XCTestCase { XCTAssertEqual(permissions?.intValue ?? 0, 0o600) } + // The bundled-runtime branch skips the code-signature payload comparison + // whenever the injected resourceURL is not the running app's own bundle. + // That combination is reachable only through parameter injection, so it + // must demand the same explicit opt-in as the development branch -- + // otherwise a future refactor decoupling the parameters would silently + // execute unsigned code. + func testForeignBundledRuntimeIsRefusedWithoutDevelopmentOptIn() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pch-runtime-foreign-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let resources = root.appendingPathComponent("resources") + let bundled = resources.appendingPathComponent("runtime") + let support = root.appendingPathComponent("support") + try writeRuntime(at: bundled, marker: "foreign") + let projectRoot = support.appendingPathComponent("Modore/results") + + XCTAssertNil(RuntimeWorkspace.prepareExecution( + projectRoot: projectRoot, + environment: [:], + resourceURL: resources, + currentDirectory: root, + applicationSupportRoot: support + )) + } + func testStandaloneExecutionUsesSignedBundleAfterStagedRuntimeChanges() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("pch-runtime-execution-source-\(UUID().uuidString)") @@ -509,7 +534,9 @@ final class CleanupSafetyTests: XCTestCase { XCTAssertTrue(RuntimeWorkspace.prepareForExecution( projectRoot: installed, - environment: [:], + // The foreign-bundle opt-in; without it the call is refused + // before reaching the revalidation semantics under test. + environment: ["PCH_DEVELOPMENT_MODE": "1"], resourceURL: resources, applicationSupportRoot: support )) @@ -626,7 +653,7 @@ final class CleanupSafetyTests: XCTestCase { XCTAssertFalse(RuntimeWorkspace.prepareForExecution( projectRoot: installed, - environment: [:], + environment: ["PCH_DEVELOPMENT_MODE": "1"], resourceURL: resources, applicationSupportRoot: support )) @@ -654,7 +681,7 @@ final class CleanupSafetyTests: XCTestCase { XCTAssertFalse(RuntimeWorkspace.prepareForExecution( projectRoot: installed, - environment: [:], + environment: ["PCH_DEVELOPMENT_MODE": "1"], resourceURL: resources, currentDirectory: root, applicationSupportRoot: support diff --git a/scripts/login_items.sh b/scripts/login_items.sh index 23b2c93..4c00112 100755 --- a/scripts/login_items.sh +++ b/scripts/login_items.sh @@ -201,7 +201,22 @@ cmd_execute() { return 1 } - local created_epoch stored_name now age + # cleanup.sh cross-checks its whole manifest; the minimum equivalent here + # is refusing a manifest written under a different protocol version -- + # fields this version reads could mean something else in another one. + local stored_version created_epoch stored_name now age + stored_version="$(/usr/bin/awk -F '\t' '$1 == "version" {print $2; count++} END {if (count != 1) exit 1}' "$executing")" || { + /bin/rm -f "$executing" + emit "status" "blocked" + emit "name" "$target" + return 1 + } + [[ "$stored_version" == "$PROTOCOL_VERSION" ]] || { + /bin/rm -f "$executing" + emit "status" "blocked" + emit "name" "$target" + return 1 + } created_epoch="$(/usr/bin/awk -F '\t' '$1 == "createdEpoch" {print $2; count++} END {if (count != 1) exit 1}' "$executing")" || { /bin/rm -f "$executing" emit "status" "blocked" diff --git a/scripts/modules/macos/privacy.sh b/scripts/modules/macos/privacy.sh index abb1dc2..84cbd63 100644 --- a/scripts/modules/macos/privacy.sh +++ b/scripts/modules/macos/privacy.sh @@ -14,7 +14,14 @@ if ! declare -F record_collection_status >/dev/null 2>&1; then fi collect_privacy_permissions() { - local tcc_db="${PCH_TCC_DB_PATH:-$HOME/Library/Application Support/com.apple.TCC/TCC.db}" + # PCH_TCC_DB_PATH는 테스트 주입용이며, devtool_updates.sh의 + # PCH_TEST_BREW_BIN처럼 PCH_TEST_MODE=1일 때만 열린다. 게이트 없이 두면 + # 이 모듈만 확립된 패턴의 예외가 되고, 어떤 미래 호출자가 이 변수를 + # 통과시키는 순간 임의 sqlite 파일 읽기 리다이렉트가 된다. + local tcc_db="$HOME/Library/Application Support/com.apple.TCC/TCC.db" + if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + tcc_db="${PCH_TCC_DB_PATH:-$tcc_db}" + fi local out_file="$TMP_DIR/privacy.tsv" local error_file="$TMP_DIR/privacy.err" local status row_count diff --git a/tests/test_macos_login_items.py b/tests/test_macos_login_items.py index f5da34d..235e5a2 100644 --- a/tests/test_macos_login_items.py +++ b/tests/test_macos_login_items.py @@ -214,6 +214,27 @@ def test_execute_rejects_reusing_an_already_consumed_token(project_root, tmp_pat assert second_payload["status"] == "blocked" +def test_execute_rejects_a_manifest_from_a_different_protocol_version(project_root, tmp_path): + """Fields this version reads could mean something else under another + protocol version, so a version mismatch must block instead of executing + on a guess. cleanup.sh cross-checks its whole manifest; this is the + minimum equivalent for the login-item manifest.""" + home = tmp_path / "home" + stub, state_file, _ = _fake_osascript(tmp_path, initial_items=["Foo", "Bar"]) + + _, preview_payload = _preview(project_root, tmp_path, "Bar", osascript_stub=stub, home=home) + token = preview_payload["approvalToken"] + manifest = home / "Library" / "Application Support" / "Modore" / "login-item-approvals" / f"{token}.tsv" + tampered = manifest.read_text(encoding="utf-8").replace("version\t1", "version\t2") + manifest.write_text(tampered, encoding="utf-8") + + result, payload = _execute(project_root, tmp_path, "Bar", token, osascript_stub=stub, home=home) + + assert payload["status"] == "blocked", payload + assert result.returncode == 1 + assert "Bar" in state_file.read_text(encoding="utf-8") + + def test_execute_rejects_a_token_approved_for_a_different_name(project_root, tmp_path): home = tmp_path / "home" stub, state_file, _ = _fake_osascript(tmp_path, initial_items=["Foo", "Bar"]) diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 3fd67a5..b50209c 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -620,6 +620,48 @@ def test_bundled_app_runtime_includes_every_macos_script(project_root): missing = expected - runtime_files assert not missing, f"scripts missing from the shipped app runtime: {sorted(missing)}" + # The same gap exists for every non-.sh runtime dependency: a new rule + # file, i18n bundle, JXA program, or runtime .py works in every checkout- + # based test while silently absent from the signed bundle. Directories + # that are wholly runtime data are globbed outright; .py needs an explicit + # build/reference exclusion list, mirroring build_only_scripts above. + data_locals = {"data/config.json"} # generated per-machine, gitignored + python_not_shipped = { + # Release/build tooling, never invoked by the running app. + "scripts/release_smoke.py", + "scripts/artifact_audit.py", + # The Python reference implementation kept for cross-engine parity + # tests; the app runs the JXA equivalents. A guard elsewhere asserts + # rule_engine.py/scanner_helper.py must NOT ship. + "scripts/report.py", + "scripts/report_render.py", + "scripts/report_i18n.py", + "scripts/_jsonutil.py", + "scripts/rule_engine.py", + "scripts/scanner_helper.py", + } + non_shell_expected = { + f"data/{path.name}" for path in (project_root / "data").glob("*.json") + } - data_locals + non_shell_expected |= { + f"data/report_i18n/{path.name}" + for path in (project_root / "data" / "report_i18n").glob("*.json") + } + non_shell_expected |= { + f"rules/{path.name}" for path in (project_root / "rules").glob("*.json") + } + non_shell_expected |= { + f"scripts/{path.name}" for path in (project_root / "scripts").glob("*.jxa.js") + } + non_shell_expected |= { + f"scripts/{path.name}" for path in (project_root / "scripts").glob("*.py") + } - python_not_shipped + + non_shell_missing = non_shell_expected - runtime_files + assert not non_shell_missing, ( + f"runtime data/programs missing from the shipped app runtime: {sorted(non_shell_missing)}" + ) + @pytest.mark.parametrize( "script,args", @@ -1021,6 +1063,7 @@ def run_scenario(tcc_db_path, label): f"""#!/bin/bash set -u TMP_DIR="{tmp_dir}" +PCH_TEST_MODE=1 PCH_TCC_DB_PATH="{tcc_db_path}" record_collection_status() {{ printf 'status\\t%s\\n' "$3" > "{scenario_dir}/captured-status.txt"