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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions macos/Modore/Sources/Modore/Services/LoginItemService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion macos/Modore/Sources/Modore/Services/ObservationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions macos/Modore/Sources/Modore/Services/RuntimeWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions macos/Modore/Sources/Modore/Services/ScanModelActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 30 additions & 3 deletions macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
))
Expand Down Expand Up @@ -626,7 +653,7 @@ final class CleanupSafetyTests: XCTestCase {

XCTAssertFalse(RuntimeWorkspace.prepareForExecution(
projectRoot: installed,
environment: [:],
environment: ["PCH_DEVELOPMENT_MODE": "1"],
resourceURL: resources,
applicationSupportRoot: support
))
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion scripts/login_items.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion scripts/modules/macos/privacy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_macos_login_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
43 changes: 43 additions & 0 deletions tests/test_service_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down