diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 1104c430..b4059e9b 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -213,8 +213,12 @@ jobs: unzip -o "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" test -s codebase-memory-mcp.exe test -s codebase-memory-mcp.payload.exe - ./codebase-memory-mcp.payload.exe --version - ./codebase-memory-mcp.exe --version + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + LAUNCH_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-version.XXXXXX")" + trap 'rm -rf "$LAUNCH_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$LAUNCH_DIR/" + "$LAUNCH_DIR/codebase-memory-mcp.payload.exe" --version + "$LAUNCH_DIR/codebase-memory-mcp.exe" --version - name: Start artifact server shell: msys2 {0} @@ -240,8 +244,12 @@ jobs: - name: Smoke test shell: msys2 {0} run: | - SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" \ - scripts/smoke-test.sh ./codebase-memory-mcp.exe + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")" + trap 'rm -rf "$SMOKE_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/" + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ + scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server @@ -252,7 +260,12 @@ jobs: run: | scripts/security-strings.sh ./codebase-memory-mcp.exe scripts/security-strings.sh ./codebase-memory-mcp.payload.exe - scripts/security-install.sh ./codebase-memory-mcp.exe + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SECURITY_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-security.XXXXXX")" + trap 'rm -rf "$SECURITY_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SECURITY_DIR/" + TMPDIR="$SECURITY_DIR" \ + scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe" - name: Windows Defender scan shell: pwsh diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96917038..a98a1d62 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -115,16 +115,16 @@ jobs: run: | scripts/build.sh CC=clang CXX=clang++ # MSYS2 /tmp is intentionally shared. The launcher correctly rejects - # a bundle below that writable ancestor, so stage in the runner's - # account-private native temp directory instead. - SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")" - SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")" + # a bundle below that writable ancestor. RUNNER_TEMP is also below + # the shared D:\a tree, so stage in the runner account's profile. + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")" trap 'rm -rf "$SMOKE_DIR"' EXIT cp build/c/codebase-memory-mcp-launcher.exe \ "$SMOKE_DIR/codebase-memory-mcp.exe" cp build/c/codebase-memory-mcp.exe \ "$SMOKE_DIR/codebase-memory-mcp.payload.exe" - SMOKE_TEMP_ROOT="$SMOKE_ROOT" \ + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index abc7ae19..3fcfece9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -3083,7 +3083,9 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t # Pass the known-correct arch: powershell runs under x64 emulation on ARM64, so # install.ps1's own detection can't tell it's arm64. DL_ARCH is authoritative here. HOME="$PS1_TEST_HOME" CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" \ - powershell.exe -ExecutionPolicy ByPass -File "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true + powershell.exe -NoProfile -ExecutionPolicy ByPass -Command \ + '$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]' \ + "$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true # 13g: binary placed PS1_BIN="$PS1_TEST_DIR/codebase-memory-mcp.exe" diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index ee1939cc..a0d7451f 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -133,7 +133,11 @@ Write-Host "Payload: $bin" -ForegroundColor Green Write-Host "Launcher: $launcherBin" -ForegroundColor Green Write-Host "ABI mismatch fixture: $abiMismatchLauncher" -ForegroundColor Green -$guardBundle = Join-Path $tmp ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) +$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +if (-not $localAppData) { throw "could not resolve the current user's local application-data directory" } +$guardRoot = Join-Path $localAppData "Temp" +New-Item -ItemType Directory -Path $guardRoot -Force | Out-Null +$guardBundle = Join-Path $guardRoot ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $guardBundle | Out-Null $guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" $guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" @@ -141,7 +145,15 @@ Copy-Item -LiteralPath $launcherBin -Destination $guardBin Copy-Item -LiteralPath $bin -Destination $guardPayload Write-Host "Guard bundle: $guardBin" -ForegroundColor Green +$previousTemp = $env:TEMP +$previousTmp = $env:TMP +$previousTmpDir = $env:TMPDIR try { + # The launcher deliberately rejects GitHub's shared D:\a ancestry. Keep + # launcher fixtures and Python-created descendants inside this account. + $env:TEMP = $guardRoot + $env:TMP = $guardRoot + $env:TMPDIR = $guardRoot $env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 $env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) @@ -204,6 +216,9 @@ if (-not $GuardsOnly) { } } finally { Remove-Item -LiteralPath $guardBundle -Recurse -Force -ErrorAction SilentlyContinue + $env:TEMP = $previousTemp + $env:TMP = $previousTmp + $env:TMPDIR = $previousTmpDir } Write-Host "" diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index a0cbe6e9..f0653136 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -1984,14 +1984,13 @@ static int posix_socket_record_publication_recover( static int posix_linkat_no_follow(int source_dir_fd, const char *source_name, int destination_dir_fd, const char *destination_name) { - int flags = 0; -#if defined(__APPLE__) && defined(AT_SYMLINK_NOFOLLOW_ANY) - /* Darwin documents that linkat(..., 0) may be rejected by some - * filesystems. This explicit flag preserves the intended no-follow - * semantics while making the operation portable to those filesystems. */ - flags = AT_SYMLINK_NOFOLLOW_ANY; -#endif - return linkat(source_dir_fd, source_name, destination_dir_fd, destination_name, flags); + /* linkat without AT_SYMLINK_FOLLOW links the source entry itself, which is + * the required no-follow behavior. Do not pass Darwin's broader + * AT_SYMLINK_NOFOLLOW_ANY constant: macOS 14 headers define it, but that + * kernel rejects it for linkat with EINVAL. The retained and validated + * source state plus post-link inode/link-count checks retain the + * fail-closed publication contract. */ + return linkat(source_dir_fd, source_name, destination_dir_fd, destination_name, 0); } static void posix_bound_socket_unlink_if_matches(int dir_fd, const char *socket_name, dev_t device, diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 6eeb2501..7f08448d 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -1354,8 +1354,8 @@ static bool runtime_activation_peer_matches_claim(cbm_daemon_runtime_service_t * } bool active_image = runtime_process_image_reference_matches_process(&service->active_image, process_id); - if (active_image && strcmp(claimed_build, service->identity.build_fingerprint) == 0) { - return true; + if (active_image) { + return strcmp(claimed_build, service->identity.build_fingerprint) == 0; } char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; return cbm_daemon_runtime_process_build_fingerprint(process_id, peer_fingerprint) && @@ -1569,7 +1569,6 @@ static void *runtime_connection_worker(void *opaque) { received = cbm_daemon_ipc_receive_frame(worker->connection, CBM_DAEMON_IPC_WAIT_FOREVER, &frame, &payload); if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { - free(payload); break; } switch (frame.flags) { diff --git a/src/launcher/windows_launcher.c b/src/launcher/windows_launcher.c index d2d9ee0c..ba9ea41a 100644 --- a/src/launcher/windows_launcher.c +++ b/src/launcher/windows_launcher.c @@ -176,7 +176,7 @@ static bool launcher_bounded_ace_sid_is_trusted(const ACE_HEADER *header, PSID c IsWellKnownSid((PSID)sid, WinCreatorOwnerSid))); } -static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { +static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, DWORD mutation) { void *token_user = NULL; PSID user_sid = NULL; if (!launcher_current_user(&token_user, &user_sid)) { @@ -196,10 +196,6 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { : launcher_sid_is_trusted(owner, user_sid)) && dacl && IsValidAcl(dacl) && GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; - const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | - WRITE_OWNER | ACCESS_SYSTEM_SECURITY; for (DWORD index = 0; secure && index < information.AceCount; index++) { void *opaque = NULL; if (!GetAce(dacl, index, &opaque) || !opaque) { @@ -239,8 +235,14 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { return secure; } +static DWORD launcher_private_mutation_rights(void) { + return GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_ADD_FILE | + FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | + DELETE | WRITE_DAC | WRITE_OWNER | ACCESS_SYSTEM_SECURITY; +} + static bool launcher_security_is_private(HANDLE file) { - return launcher_security_is_safe(file, true); + return launcher_security_is_safe(file, true, launcher_private_mutation_rights()); } static HANDLE launcher_open_regular(const wchar_t *path, DWORD access, bool require_private) { @@ -319,11 +321,20 @@ static bool launcher_path_tree_plain(const wchar_t *file_path) { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); BY_HANDLE_FILE_INFORMATION information; + DWORD mutation = launcher_private_mutation_rights(); + if (index < directory_length) { + /* Default C:\\Users ACLs grant cross-account add-subdirectory on + * this intermediate component. That cannot replace the existing + * next component. No other write right is relaxed, and the final + * executable directory remains fully private so a peer cannot + * plant DLL or .exe.local redirection artifacts beside CBM. */ + mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY); + } valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && GetFileInformationByHandle(component, &information) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && - launcher_security_is_safe(component, false); + launcher_security_is_safe(component, false, mutation); if (component != INVALID_HANDLE_VALUE) (void)CloseHandle(component); path[index] = saved; diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 04520586..8ad5d933 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1323,6 +1323,7 @@ static void app_fake_worker_destroy(void *opaque, cbm_daemon_application_worker_ cbm_usleep(1000); } (void)app_project_lock_release(&worker->project_lock_lease); + cbm_index_worker_result_free(&worker->result); free(worker->project_key); free(handle); } diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index d1dda0ec..0ceae97a 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -7,6 +7,7 @@ */ #include "test_framework.h" #include "test_helpers.h" +#include "test_daemon_runtime_contract.h" #include "daemon/application.h" #include "daemon/host.h" @@ -53,6 +54,8 @@ enum { RUNTIME_TEST_PATH_CAP = 1024, RUNTIME_TEST_LOG_CAP = 4096, RUNTIME_TEST_TIMEOUT_MS = 2000, + RUNTIME_TEST_CLEANUP_TIMEOUT_MS = 60000, + RUNTIME_TEST_CLEANUP_FREE_ATTEMPTS = 3, /* Generation-zero rendezvous layout, deliberately repeated rather than * derived from production macros so an accidental resize fails loudly. */ RUNTIME_TEST_RENDEZVOUS_ABI = 1, @@ -245,6 +248,22 @@ static bool runtime_test_windows_copy_self(const char *destination) { return copied; } +static bool runtime_test_windows_wait_image_probe(HANDLE process) { + if (!process) { + return false; + } + DWORD wait_status = WaitForSingleObject(process, TF_RUNTIME_IMAGE_WATCHDOG_MS); + if (wait_status == WAIT_OBJECT_0) { + return true; + } + (void)TerminateProcess(process, 30); + if (WaitForSingleObject(process, RUNTIME_TEST_TIMEOUT_MS) != WAIT_OBJECT_0) { + fprintf(stderr, "daemon_runtime copied-image child could not be reaped\n"); + abort(); + } + return false; +} + static bool runtime_test_windows_posix_replace(const char *source, const char *destination) { wchar_t *source_wide = cbm_utf8_to_wide(source); wchar_t *destination_wide = cbm_utf8_to_wide(destination); @@ -657,7 +676,7 @@ static bool runtime_test_run_hello_image(const char *image_path, NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; + bool waited = started && runtime_test_windows_wait_image_probe(process.hProcess); DWORD exit_code = 0; bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { @@ -671,6 +690,7 @@ static bool runtime_test_run_hello_image(const char *image_path, #elif defined(__APPLE__) || defined(__linux__) pid_t child = fork(); if (child == 0) { + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); execl(image_path, image_path, "__cbm_runtime_hello_client", fixture->parent, fixture->key, identity->semantic_version, identity->build_fingerprint, (char *)NULL); _exit(127); @@ -722,11 +742,7 @@ static bool runtime_test_run_activation_image(const char *image_path, NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; - if (started && !waited) { - (void)TerminateProcess(process.hProcess, 30); - (void)WaitForSingleObject(process.hProcess, 5000); - } + bool waited = started && runtime_test_windows_wait_image_probe(process.hProcess); DWORD exit_code = 0; bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { @@ -742,7 +758,7 @@ static bool runtime_test_run_activation_image(const char *image_path, int action_written = snprintf(action_text, sizeof(action_text), "%u", (unsigned int)action); pid_t child = action_written > 0 && action_written < (int)sizeof(action_text) ? fork() : -1; if (child == 0) { - (void)alarm(10); + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); execl(image_path, image_path, "__cbm_runtime_activation_client", fixture->parent, fixture->key, identity->semantic_version, identity->build_fingerprint, action_text, (char *)NULL); @@ -1342,10 +1358,22 @@ static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture) { if (fixture->service) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(fixture->service); - if (state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { - (void)cbm_daemon_runtime_service_stop(fixture->service, RUNTIME_TEST_TIMEOUT_MS); + bool stopped = + state == CBM_DAEMON_RUNTIME_SERVICE_EXITED || + cbm_daemon_runtime_service_stop(fixture->service, RUNTIME_TEST_CLEANUP_TIMEOUT_MS); + bool freed = false; + for (size_t attempt = 0; stopped && !freed && attempt < RUNTIME_TEST_CLEANUP_FREE_ATTEMPTS; + attempt++) { + freed = cbm_daemon_runtime_service_free(fixture->service); + if (!freed) { + cbm_usleep(1000); + } } - (void)cbm_daemon_runtime_service_free(fixture->service); + if (!freed) { + fprintf(stderr, "daemon_runtime fixture teardown failed\n"); + abort(); + } + fixture->service = NULL; } cbm_daemon_ipc_endpoint_free(fixture->endpoint); (void)cbm_unlink(fixture->rotated_log_path); @@ -1691,6 +1719,74 @@ TEST(daemon_runtime_exact_hello_issues_connection_bound_identity) { PASS(); } +TEST(daemon_runtime_unexpected_frame_payload_is_freed_once) { + static const uint8_t unexpected_payload[] = {0xde, 0xad, 0xbe, 0xef}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "unexpected-frame", &identity); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_ipc_connection_t *raw = NULL; + bool raw_connected = false; + bool unexpected_sent = false; + bool bad_peer_released = false; + bool bad_peer_closed = false; + bool owner_survived = false; + bool owner_closed = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); + } + if (owner) { + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + raw_connected = raw != NULL; + } + if (raw) { + unexpected_sent = cbm_daemon_ipc_send_frame( + raw, CBM_DAEMON_FRAME_RESPONSE, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, unexpected_payload, + (uint32_t)sizeof(unexpected_payload)); + } + if (unexpected_sent) { + bad_peer_released = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); + } + if (bad_peer_released) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + bad_peer_closed = received != 1; + free(payload); + owner_survived = cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + if (owner) { + owner_closed = cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(raw_connected); + ASSERT_TRUE(unexpected_sent); + ASSERT_TRUE(bad_peer_released); + ASSERT_TRUE(bad_peer_closed); + ASSERT_TRUE(owner_survived); + ASSERT_TRUE(owner_closed); + ASSERT_TRUE(exited); + PASS(); +} + TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); @@ -4087,6 +4183,7 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); + RUN_TEST(daemon_runtime_unexpected_frame_payload_is_freed_once); RUN_TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop); RUN_TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients); #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) diff --git a/tests/test_daemon_runtime_contract.h b/tests/test_daemon_runtime_contract.h new file mode 100644 index 00000000..e2b192dd --- /dev/null +++ b/tests/test_daemon_runtime_contract.h @@ -0,0 +1,10 @@ +#ifndef TEST_DAEMON_RUNTIME_CONTRACT_H +#define TEST_DAEMON_RUNTIME_CONTRACT_H + +/* A copied sanitizer executable can take tens of seconds to fingerprint. The + * parent watchdog covers both the HELLO exchange and its bounded close. */ +#define TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS 60000U +#define TF_RUNTIME_IMAGE_WATCHDOG_MS (2U * TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS + 5000U) +#define TF_RUNTIME_IMAGE_WATCHDOG_SECONDS ((TF_RUNTIME_IMAGE_WATCHDOG_MS + 999U) / 1000U) + +#endif /* TEST_DAEMON_RUNTIME_CONTRACT_H */ diff --git a/tests/test_main.c b/tests/test_main.c index bb1cb291..88debb40 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -10,6 +10,7 @@ int tf_skip_count = 0; #include "test_framework.h" #include "test_helpers.h" +#include "test_daemon_runtime_contract.h" #include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ #include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ #include "foundation/mem.h" /* cbm_mem_init — worker budget */ @@ -391,10 +392,13 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { cbm_daemon_runtime_connect_result_t result; memset(&result, 0, sizeof(result)); cbm_daemon_runtime_client_t *client = - endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000, &result) : NULL; + endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, + TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS, &result) + : NULL; bool accepted = client && result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE; - bool closed = !client || cbm_daemon_runtime_client_close(client, 5000); + bool closed = + !client || cbm_daemon_runtime_client_close(client, TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS); cbm_daemon_ipc_endpoint_free(endpoint); if (!accepted) { return 26; @@ -425,7 +429,7 @@ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { bool exchanged = endpoint && cbm_daemon_runtime_request_activation_shutdown( endpoint, &identity, (cbm_daemon_runtime_activation_action_t)action_value, - 5000, &result); + TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS, &result); cbm_daemon_ipc_endpoint_free(endpoint); return exchanged && result.accepted ? 0 : 29; } diff --git a/tests/test_security_fuzz_harness.sh b/tests/test_security_fuzz_harness.sh index f4ea1583..f3904a1e 100644 --- a/tests/test_security_fuzz_harness.sh +++ b/tests/test_security_fuzz_harness.sh @@ -82,12 +82,25 @@ if [[ ! -s "$ENV_LOG" ]]; then exit 1 fi -while IFS=$'\t' read -r child_home child_cache; do - if [[ -z "$child_home" || "$child_home" == "$CALLER_HOME" ]]; then +normalize_path() { + local path=${1%$'\r'} + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$path" 2>/dev/null && return 0 + fi + printf '%s\n' "${path//\\//}" +} + +CALLER_HOME_NORMALIZED=$(normalize_path "$CALLER_HOME") +CALLER_CACHE_NORMALIZED=$(normalize_path "$CALLER_CACHE") + +while IFS=$'\t' read -r child_home_raw child_cache_raw; do + child_home=$(normalize_path "$child_home_raw") + child_cache=$(normalize_path "$child_cache_raw") + if [[ -z "$child_home" || "$child_home" == "$CALLER_HOME_NORMALIZED" ]]; then echo "FAIL: security-fuzz exposed the caller HOME to a fuzz target" exit 1 fi - if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE" ]]; then + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE_NORMALIZED" ]]; then echo "FAIL: security-fuzz exposed the caller CBM_CACHE_DIR to a fuzz target" exit 1 fi diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 4839e4ed..49e5167f 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -349,6 +349,20 @@ require( in windows_test_driver, "the permanent-launcher guard must fail instead of skip on driver/precondition errors", ) +require( + all( + needle in windows_test_driver + for needle in ( + "[Environment+SpecialFolder]::LocalApplicationData", + '$guardRoot = Join-Path $localAppData "Temp"', + '$env:TEMP = $guardRoot', + '$env:TMP = $guardRoot', + '$env:TMPDIR = $guardRoot', + ) + ), + "Windows launcher guards must keep staged and Python-created fixtures " + "beneath the current account profile", +) # Launcher supervision has two distinct failure directions: killing the # launcher must kill its payload job, and killing only the launcher's immediate @@ -423,7 +437,13 @@ ancestor_security_contracts = { "FILE_FLAG_OPEN_REPARSE_POINT", ), "src/launcher/windows_launcher.c": ( - "launcher_security_is_safe(component, false)", + "launcher_security_is_safe(component, false, mutation)", + "launcher_private_mutation_rights()", + "if (index < directory_length)", + "mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY)", + "FILE_ADD_FILE", + "FILE_DELETE_CHILD", + "DLL or .exe.local", "ACCESS_SYSTEM_SECURITY", "956008885U", "FILE_FLAG_OPEN_REPARSE_POINT", @@ -448,10 +468,7 @@ windows_match = re.search( windows_smoke = windows_match.group(1) if windows_match else "" require(bool(windows_smoke), "_smoke.yml must contain the smoke-windows job") require( - re.search( - r"scripts/smoke-test\.sh\s+(?:\"|')?\./codebase-memory-mcp\.exe(?:\"|')?", - windows_smoke, - ) is not None, + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"' in windows_smoke, f"Windows release smoke must execute the canonical {launcher}", ) require( @@ -466,13 +483,62 @@ require( windows_release_smoke_blocks = [ re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() for block in smoke_blocks - if "scripts/smoke-test.sh ./codebase-memory-mcp.exe" in block + if 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"' in block ] require( len(windows_release_smoke_blocks) == 1 - and 'SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" ' - 'scripts/smoke-test.sh ./codebase-memory-mcp.exe' in windows_release_smoke_blocks[0], - "Windows release smoke must keep every launcher fixture under runner-private temp", + and all( + needle in windows_release_smoke_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/"', + 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', + ) + ), + "Windows release smoke must keep every launcher fixture beneath the current account profile", +) +windows_release_version_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if 'LAUNCH_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-version.XXXXXX")"' in block +] +require( + len(windows_release_version_blocks) == 1 + and all( + needle in windows_release_version_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$LAUNCH_DIR/"', + '"$LAUNCH_DIR/codebase-memory-mcp.payload.exe" --version', + '"$LAUNCH_DIR/codebase-memory-mcp.exe" --version', + ) + ), + "Windows release version checks must execute the pair beneath the current account profile", +) +require( + "$RUNNER_TEMP" not in windows_smoke, + "Windows release smoke must not treat GitHub's shared RUNNER_TEMP ancestry as private", +) +windows_release_security_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if 'scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe"' in block +] +require( + len(windows_release_security_blocks) == 1 + and all( + needle in windows_release_security_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SECURITY_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-security.XXXXXX")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SECURITY_DIR/"', + 'TMPDIR="$SECURITY_DIR" ' + 'scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe"', + ) + ), + "Windows release install audit must execute the pair beneath the current account profile", ) # Native update transport remains HTTPS-only in production. Release smoke may @@ -492,6 +558,11 @@ require( and 'CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL"' in smoke_script, "Phase 14 native update must use an explicit file:// fixture override", ) +require( + "'$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]'" in smoke_script + and '"$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR"' in smoke_script, + "Windows install.ps1 smoke must set native TEMP/TMP inside PowerShell", +) require( 'CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL"' in smoke_script and '"$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE"' in smoke_script, @@ -562,21 +633,21 @@ if pr_windows_blocks: pr_windows_block = re.sub(r"\\\s*\n\s*", " ", pr_windows_blocks[0]) pr_windows_block = re.sub(r"\s+", " ", pr_windows_block).strip() staging_steps = ( - 'SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")"', - 'SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")"', + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")"', 'trap \'rm -rf "$SMOKE_DIR"\' EXIT', 'cp build/c/codebase-memory-mcp-launcher.exe ' '"$SMOKE_DIR/codebase-memory-mcp.exe"', 'cp build/c/codebase-memory-mcp.exe ' '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', - 'SMOKE_TEMP_ROOT="$SMOKE_ROOT" ' + 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', ) positions = [pr_windows_block.find(step) for step in staging_steps] require( all(position >= 0 for position in positions), "Windows PR smoke must stage launcher and payload under release names " - "in the runner-private native temp directory and invoke the canonical launcher", + "beneath the current account profile and invoke the canonical launcher", ) require( all(left < right for left, right in zip(positions, positions[1:])), @@ -586,6 +657,10 @@ if pr_windows_blocks: pr_windows_block.count("scripts/smoke-test.sh") == 1, "Windows PR smoke must invoke smoke-test exactly once through the launcher", ) + require( + "$RUNNER_TEMP" not in pr_windows_block, + "Windows PR smoke must not treat GitHub's shared RUNNER_TEMP ancestry as private", + ) if failures: print("Windows launcher bundle contract FAILED:", file=sys.stderr) diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py index d9834e86..edf0086d 100644 --- a/tests/windows/test_windows_launcher.py +++ b/tests/windows/test_windows_launcher.py @@ -397,6 +397,88 @@ def assert_untrusted_ancestor_acl_rejected( print("PASS: launcher rejected an untrusted mutation ACE on an ancestor") +def assert_add_only_ancestor_acl_allowed(source_launcher, source_payload, env, work): + ancestor = work / "cross-account add-only ancestor" + launcher, _ = copy_portable_pair(source_launcher, source_payload, ancestor / "bundle") + grant = run(["icacls", ancestor, "/grant", "*S-1-1-0:(AD)"], env) + require( + grant.returncode == 0, + "could not install native Everyone-add-subdirectory ancestor fixture: %s" + % output_text(grant)[-600:], + ) + try: + for candidate, spelling in ( + (launcher, "normal"), + ("\\\\?\\" + str(launcher.resolve()), "extended DOS"), + ): + result = run([candidate, "--version"], env) + require( + result.returncode == 0, + "%s launcher path treated sibling creation as replacement access: %s" + % (spelling, output_text(result)[-600:]), + ) + finally: + remove = run(["icacls", ancestor, "/remove:g", "*S-1-1-0"], env) + require( + remove.returncode == 0, + "could not remove native Everyone-add-subdirectory ancestor fixture", + ) + print("PASS: launcher allowed add-only sibling creation without weakening path integrity") + + +def assert_targeted_ancestor_acl_rejected(path, launcher, right, description, env): + grant = run(["icacls", path, "/grant", "*S-1-1-0:(%s)" % right], env) + require( + grant.returncode == 0, + "could not install native Everyone-%s fixture: %s" + % (description, output_text(grant)[-600:]), + ) + try: + for candidate, spelling in ( + (launcher, "normal"), + ("\\\\?\\" + str(launcher.resolve()), "extended DOS"), + ): + result = run([candidate, "--version"], env) + require( + result.returncode != 0, + "%s launcher accepted cross-account %s" + % (spelling, description), + ) + finally: + remove = run(["icacls", path, "/remove:g", "*S-1-1-0"], env) + require( + remove.returncode == 0, + "could not remove native Everyone-%s fixture" % description, + ) + require( + run([launcher, "--version"], env).returncode == 0, + "launcher did not recover after the %s ACE was removed" % description, + ) + + +def assert_file_add_and_executable_parent_acl_rejected( + source_launcher, source_payload, env, work +): + file_add_ancestor = work / "cross-account file-add ancestor" + launcher, _ = copy_portable_pair( + source_launcher, source_payload, file_add_ancestor / "bundle" + ) + assert_targeted_ancestor_acl_rejected( + file_add_ancestor, launcher, "WD", "file creation on an intermediate ancestor", env + ) + + executable_parent = work / "cross-account executable parent" + launcher, _ = copy_portable_pair(source_launcher, source_payload, executable_parent) + assert_targeted_ancestor_acl_rejected( + executable_parent, + launcher, + "AD", + "subdirectory creation beside the executable", + env, + ) + print("PASS: launcher kept file-add and executable-parent ACL boundaries strict") + + def process_entries(): kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] @@ -979,6 +1061,12 @@ def main(): env, cache = isolated_environment(work) assert_release_descriptor(source_launcher, source_payload, env, cache) assert_portable_mutations_refuse(source_payload, env, cache, work) + assert_add_only_ancestor_acl_allowed( + source_launcher, source_payload, env, work + ) + assert_file_add_and_executable_parent_acl_rejected( + source_launcher, source_payload, env, work + ) assert_untrusted_ancestor_acl_rejected( source_launcher, source_payload, env, work )