Commit Graph
32 Commits
Author SHA1 Message Date
Martin Vogel 3f8b6b4138 Merge branch 'main' into fix/cohort-lock-tmp-cleaner 2026-10-20 19:45:49 +02:00
Martin Vogel ad1f029645 Merge origin/main into fix/cohort-lock-tmp-cleaner
Bring the branch up to current main (a2227157) so the PR checks run
against today's tree. Textually clean; no conflict resolution.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-20 18:31:17 +02:00
Pcristin fd9b01ad7a fix(daemon): wait out Windows local teardown contention
Signed-off-by: Pcristin <xxxokzxxx@protonmail.com>
2026-10-11 22:33:05 +00:00
Martin Vogel 6eb33534f6 fix(daemon): explain foreign-owned cache ancestors and warn on network filesystems (#1717)
A cache root whose resolved path runs through a directory owned by another
non-root account (typical on shared/HPC filesystems where ~/.cache is a
symlink into a shared tree) is refused by the private-directory walk. The
refusal itself is correct: the owner of an ancestor can rename or replace
everything below it after the walk, so it could redirect the cache. But
posix_directory_transition_secure() set no validation detail, so the caller
printed "ancestry component validation failed (errno 17)" -- a stale EEXIST
from mkdirat() that named neither the directory nor the reason.

- The refusal now names the directory, its owner uid and account name, the
  caller's uid, and the remedy (a location with a self/root-owned ancestry,
  e.g. via CBM_CACHE_DIR). The path is shortened from the left so the
  remedy always fits the 384-byte detail buffer.
- On Linux the account name is read from /etc/passwd, not getpwuid(). The
  release binary is statically linked against glibc, and getpwuid() there
  loads the host's NSS modules at runtime, which can crash on a glibc
  version mismatch (a real risk on sssd/LDAP cluster hosts). Accounts that
  exist only in a directory service are shown by uid.
- New advice-only check: on Linux, a cache root on NFS, CIFS, SMB2, Lustre
  or GPFS (statfs f_type) logs one daemon.cache_root_network_fs warning per
  process. SQLite WAL is unreliable on network filesystems. Nothing is
  refused.
- docs/CONFIGURATION.md gains a "Shared / HPC filesystems" section.

The ownership policy is unchanged, and there is still no override (same
stance as #1830).

Test seams: one seam reports a directory (matched by dev/ino) as
foreign-owned, and another supplies the statfs magic. The refusal, the
message and the once-per-process warning are therefore testable without
root and on every platform.

Refs #1717

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-30 23:33:54 +02:00
Martin Vogel 8b8cafb616 Merge pull request #2409 from DeusData/test/daemon-ipc-startup-gate-no-timeout
test(daemon-ipc): wait for the startup gate event, not an attempt budget
2026-09-30 22:21:43 +02:00
Martin Vogel 4c6ecffdcd test(daemon-ipc): wait for the startup gate event, not an attempt budget
daemon_ipc_windows_startup_retries_transient_rendezvous_reader failed on
a windows-latest CLANG64 runner (ASSERT(startup_observed); the whole
suite took 7 s) on a PR that touched only CLI files.

ipc_test_startup_gate_wait_acquired polled 50000 x cbm_usleep(200) and
returned false once the budget ran out. On POSIX that is ~10 s, but on
Windows cbm_usleep(us) is Sleep(us / 1000), so cbm_usleep(200) is
Sleep(0): the budget collapsed to 50000 bare yields, which a loaded
runner spends in milliseconds before the startup thread reaches the
gate. A timeout decided the verdict.

The helper now waits until the startup thread has either parked in the
gate or returned without reaching it (a new `finished` flag, set by
ipc_test_win_startup_call after its call returns). Both flags only go
false -> true and one of them is always set: before the gate the startup
path only try-acquires, and a thread parked in the gate cannot return
until the test releases it. No bound is left to decide the verdict. A
thread that returns without reaching the gate fails at once, and the
test reports its try-acquire status as the reason.

The same shape is removed from two cross-platform tests:

- daemon_ipc_wait_forever_is_interruptible waited for the server's
  forever-wait under a 2 s wall-clock deadline. Past the deadline the
  test skipped the interrupt, so the join then blocked on a server
  parked in WAIT_FOREVER: a hang, not a failure. It now waits for
  `waiting` or for a new `finished` flag set when the server thread
  returns.
- daemon_ipc_connect_waits_for_delayed_listener capped its wait for
  `delay_started` at 2000 polls. That store is the thread's first
  action, so the wait needs no attempt bound.

No production change and no new sleep: the existing per-poll yields
stay.

Proof. A standalone harness mirrors the gate, both helpers and the
thread wrapper, with cbm_usleep modelled as on Windows
(Sleep(us / 1000), so cbm_usleep(200) is a bare yield):
- startup thread arriving only after 50000 polls: old helper 20/20 FAIL
  (startup_observed=false with call.result=1, the CI signature), new
  helper 20/20 PASS;
- startup thread descheduled 500 ms: the old helper gives up after 9 ms,
  10/10 FAIL; new helper 10/10 PASS; control, old helper with POSIX
  usleep: 10/10 PASS;
- thread returning without reaching the gate: the new helper fails
  within 11 ms with call.result=-1 (the old one took 16 s under POSIX).

Real daemon_ipc suite on macOS: with the server and listener threads
held 4 s and 6 s (beyond both old bounds) the two cross-platform tests
pass. The old 2000-poll loop under the 6 s hold fails
ASSERT(delay_observed). The old 2 s deadline under the 4 s hold hangs in
the join until killed. A server that skips accept makes the new loop
fail ASSERT(reached_wait) with no added wait.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-28 19:43:10 +02:00
Shane McCarronandClaude Opus 5.5 b15e7f91b9 style(tests): drop unrelated reflow hunks
Restore main's formatting on pre-existing lines that a whole-file
clang-format pass had reflowed, so the diff carries only the #2178 change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbYKGdU7pqG6AEuMQuJxiF
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
2026-09-25 14:24:26 -05:00
Martin Vogel 19f50de3d2 fix(daemon): name the WSL DrvFs remedy when a /mnt/<drive> cache is refused (#1687)
On WSL2 the Windows drives under /mnt/<drive> are DrvFs (9p) mounts. With
the default automount options they report 0777 for every directory and
ignore chmod, so a CBM_CACHE_DIR there is refused by the private-directory
walk: the ancestor rule (not world-writable) fails first, and the leaf
0700 rule would fail right after. The refusal is correct -- the 0777 is
real -- but it gave WSL users no way forward.

The security gate is unchanged. When the refusing directory is on a 9p
filesystem under a WSL kernel (fstatfs magic 0x01021997 plus
/proc/sys/kernel/osrelease), the ancestor refusal and the leaf
"mode survived chmod" refusal now name the remedy: set
[automount] options = "metadata,umask=22,fmask=11" in /etc/wsl.conf and
run `wsl --shutdown`, or keep the cache on the Linux filesystem. The
ancestor message uses shorter rule text on DrvFs so the remedy fits
the 384-byte detail buffer. Detection compiles only on Linux; other
platforms and non-WSL 9p mounts keep the generic message.

A CBM_ENABLE_TEST_SEAMS switch forces the DrvFs verdict so the daemon_ipc
suite checks the remedy text (and its absence off DrvFs) deterministically.
docs/CONFIGURATION.md documents the WSL requirement.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-25 18:37:16 +02:00
Shane McCarronandClaude Opus 5 1133ba3246 fix(daemon): address QA round 2
- Classify touch failures explicitly. A file is lost only on proof that
  the path no longer names it: ENOENT, st_nlink 0, another inode, a
  symlink or other non-regular entry, or a replaced lock directory. Any
  other syscall failure (EIO, ESTALE, ENOMEM, EACCES on reopen) is
  transient, so a flaky runtime volume no longer stops the daemon. The
  POSIX private-lock touch and the ipc lock/marker touches share this
  rule; the Windows touch reports a failed handle query as transient.
- The identity marker is classified by path (fstatat, no follow) before
  it is reopened, so replacement by a symlink is now loss, not a
  transient reopen failure.
- Tests: symlink replacement of the identity marker (lost); an
  unreadable marker is transient (-1) and recovers (1) once readable;
  the cohort touch test ages both held files and asserts they were
  refreshed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
2026-09-22 11:27:26 -05:00
Martin Vogel a041794f8e fix(daemon): losing the runtime-directory creation race is not a failure on Windows
Several processes first-starting together against a runtime directory
that does not exist yet -- test-windows-guards' section_cold_storm, or a
host that launches more than one MCP server on its very first run -- all
observe the final path component absent. One CreateDirectoryW wins; the
others get ERROR_ALREADY_EXISTS. win_private_directory_tree_secure()
treated that as a failure and recorded no validation detail, so every
loser exited with a bare

    secure CLI coordination could not be created (endpoint)

The function called right after the walk, win_runtime_directory_secure(),
has always tolerated ERROR_ALREADY_EXISTS for the same directory, and the
POSIX walk tolerates EEXIST at the same point; only the Windows walk in
front of them did not.

Seen four times on 2026-09-21 on unrelated PRs (#1768 three attempts in
a row, #2140, #1999, #808). The racy lines date from July; the guard
began exercising them on 2026-09-03, when each guard section was given
its own empty CBM_RUNTIME_DIR, so the final component is now absent at
storm time on every run.

The walk now treats ERROR_ALREADY_EXISTS from its own CreateDirectoryW as
"the directory I wanted exists". Nothing is trusted because of that: an
ancestor still goes through win_directory_component_secure(), and the
final component through win_runtime_directory_secure(), which refuses a
non-directory or reparse point and enforces owner and DACL.

Every refusal on this path now names its component and its rule. The
walk reports the Windows error when it can neither create nor inspect a
component, and win_runtime_directory_secure() says whether the path
could not be created, cannot be inspected, exists but is not a directory,
or is a reparse point. One helper owns the wide-to-UTF-8 conversion for
these messages and the existing ancestor message now uses it too, so
src/daemon/ipc.c stays at its memory-core baseline.

Deterministic reproduction, no threads and no timing: a test seam fires
in the walk between "component observed absent" and CreateDirectoryW,
and the test plays the process that wins the creation.

  daemon_ipc_windows_private_directory_survives_lost_creation_race
  daemon_ipc_windows_private_directory_refuses_and_names_a_planted_file

Verification. macOS arm64 (ASan+UBSan): build clean, daemon_ipc 53 passed
(2 Windows-only skips), daemon_bootstrap 28 passed, lint-memory-core
unchanged (ipc.c stays at 146 raw sites). The two new tests are Windows-only
and have NOT been run locally: the Windows VM was down when this was written,
so the RED run on the seam-only tree and the GREEN run with this fix are
delegated to CI's windows-latest legs (test-windows shards, test-windows-guards)
by an explicit maintainer decision. If the guard suite's cold storm still
fails with this in place, the attribution above is wrong and this reverts.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-22 01:12:51 +02:00
Shane McCarronandClaude Opus 5 3cf2a6663f fix(daemon): address QA round 1
- Separate loss from transient failure. Touch helpers now report OK /
  lost / transient (version_cohort: OK/UNSAFE/IO; ipc: 1/0/-1). Only a
  proven unlink or replacement stops the daemon. A refresh failure on a
  still-valid file (EROFS, EPERM, EMFILE on the marker reopen) logs
  daemon.coordination_touch_failed once, retries every tick, and logs
  daemon.coordination_touch_recovered when it clears.
- Cohort and claim locks now prove the path still names the held inode
  (private_file_revalidate against the stored directory + name), not
  just st_nlink, so rename-away, hard-link+unlink and a replaced
  directory are caught like unlink.
- The runtime service's self-owned participant guard is refreshed by
  the listener heartbeat too.
- Tests: rename-away detection; cohort lease/claim touch (lease with
  only lifetime held is OK, each unlinked file reported independently);
  IPC listener touch now checks timestamps are actually refreshed and
  covers the participant guard and identity-marker replacement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
2026-09-21 13:02:07 -05:00
Shane McCarronandClaude Opus 5 9e793df87b fix(daemon): keep held runtime files fresh and exit when one is lost
macOS com.apple.tmp_cleaner deletes /tmp files whose atime, mtime and
ctime are all older than three days. The daemon's long-held lock files
are empty and never written, so under a daemon that lives past three
days the cleaner unlinks them while they are held. The next participant
then creates and locks a fresh inode: forked index workers see the
cohort daemon marker as free, read the live daemon as uncoordinated and
exit 1 on every reindex, and a lost IPC lifetime lock or cohort lifetime
file stops protecting the generation at all. A lost socket identity
marker additionally makes listener close leave the socket pair behind.

The host lifetime loop now runs a heartbeat every 60 s over every file
the generation holds (cohort daemon claim and lease, participant guard,
IPC lifetime reservation, identity marker). Each file is refreshed with
futimens through its held descriptor, only after proving the path still
names that descriptor, so the cleaner never selects it. If any file is
already lost, the daemon logs daemon.lifetime_end
reason=coordination_file_lost and stops cleanly; the next client starts
a coordinated generation. Re-claiming in place would race that client.

Windows needs no refresh (no age-based cleaner; held lock handles deny
deletion), so its entry points only validate the handles.

Closes #2178

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
2026-09-21 11:48:06 -05:00
Martin Vogel d21e6cc762 fix(daemon,ci): stop caching the overflow uid, and run the userns guard on the core matrix
Three maintainer decisions from the challenger read on the #1830 unit.

1. DELETE THE CACHE (not just fix its comment).

   posix_ancestor_overflow_uid() memoised via pthread_once behind a comment
   claiming "immutable /proc state". Both halves were false: unshare(CLONE_NEWUSER)
   rewrites /proc/self/uid_map, and pthread_once state survives fork() already
   marked done. A process that forked and then entered a namespace kept the
   parent verdict and refused a directory it should have accepted.

   It was harmless only because every caller today runs in a freshly exec'd
   process -- the daemon double-fork execs, workers go through
   cbm_subprocess_spawn's fork+exec, worker_pool.c never forks, and src/ contains
   no setns or unshare at all. That made a security decision depend on an
   invariant nothing enforced. The next fork-without-exec caller would have
   inherited a stale verdict silently.

   Now derived fresh per call: two small /proc reads against an openat + fstat +
   fchmod + ACL check per path component in the same walk.

   With a test that would have caught the original bug and needs no namespace.
   daemon_ipc_posix_overflow_uid_is_never_cached_issue1830 counts real
   derivations through the production entry point and asserts the count rises on
   every ancestor check. Re-introducing a cache fails it by name on every
   platform. Proven both ways on linux/arm64 as uid 1001: cache re-introduced
   49 passed 1 failed; removed 50 passed 0 skipped.

   Worth knowing why that test is not redundant: with the re-exec probe in
   place, the userns smoke test PASSES even with the cache restored, because the
   probe is a fresh process and immune to it by construction. The two tests are
   complementary -- the smoke test binds the namespace wiring, this one binds
   the absence of the cache.

2. RUN THE GUARD ON THE CORE MATRIX.

   Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, so the
   #1830 smoke test skipped on every 24.04 leg and its only real-namespace
   coverage sat on the two ubuntu-22.04 legs. Those are broad-matrix only, so
   the test ran in NO pull request, and GitHub retires those images on
   2027-04-17 -- after which it would have become a permanent silent skip, and a
   skip is not a red. A deliberate relaxation of a security check was guarded by
   a test that ran nowhere a change would be reviewed, on a clock.

   _test.yml now lifts the restriction with one sysctl on every ubuntu leg
   (hosted runners have passwordless sudo), best-effort so a runner that refuses
   simply returns to skipping. The O10 skip-whitelist comment no longer presents
   the AppArmor restriction as immovable.

3. KEEP THE TOLERANCE, CORRECT THE SAFETY ARGUMENT.

   The comment claimed that in a single-uid map "nobody reachable can have
   created it or can mutate it". The overflow uid is what EVERY unmapped host
   uid maps to, not only host root (user_namespaces(7)), so overflow-owned does
   not prove root-created: on a shared host another local user's sticky
   directory is indistinguishable from root-owned /tmp from inside the namespace.

   The tolerance stays, because there is no in-namespace discriminator that
   could tell them apart -- and narrowing it would refuse the legitimate
   root-owned 01777 /tmp that #1830 exists to accept, i.e. it would revert the
   fix in practice. What changes is the claim: the real guarantee is that no
   principal reachable from inside the namespace can create or mutate such an
   ancestor, and the leaf is never tolerated as overflow (0700, euid-rechecked),
   so a hostile host-side ancestor owner is bounded to denial of service and
   socket-path control.

macOS daemon_ipc 53 passed 2 skipped; linux/arm64 50 passed 0 skipped;
lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-13 10:21:16 +02:00
Martin Vogel f9038b84eb fix(test,daemon): make the userns probe revert-sensitive and stop a false invariant shipping
Follow-up to the re-exec fix, from the challenger read the frozen scope
requires for security-boundary units. Three findings, all addressed here; two
direction questions go to the maintainer separately.

1. src/daemon/ipc.c stated an invariant that is FALSE. The comment above
   posix_ancestor_overflow_uid() read "Derived once from immutable /proc
   state". /proc/self/uid_map is not immutable -- unshare(CLONE_NEWUSER) is
   exactly what rewrites it -- and pthread_once state survives fork() already
   marked done. The hazard was written up in the tests, which is the one place
   an ipc.c maintainer will never look, while production shipped the opposite
   claim. The comment now states the real guarantee: the cache is safe only
   because every caller runs in a freshly exec'd process, and it names what
   would break it.

   An audit of every process-creation site in src/ backs that: the daemon
   double-fork execs (daemon/bootstrap.c), cbm_fork_with_retry execs
   (foundation/subprocess.c), index workers go through cbm_subprocess_spawn,
   worker_pool.c never forks, and src/ contains no setns or unshare at all.
   No production path forks without exec and then reaches this check.

2. The probe now _exit()s instead of returning. The runner is built with
   -fsanitize=address, so returning runs the atexit chain, and a future leak
   anywhere in the prologue would exit 23 -- which the parent would have read
   as a verdict on a security test that has none.

3. The parent now treats 0/1/2/3 as the closed protocol and names anything
   else with the code it actually saw. A signal or a sanitizer exit is a broken
   harness, not a refusal, and must not be able to look like one.

The probe guard also now matches its caller (linux AND test seams) instead of
compiling dead code in a non-seams build.

VERIFICATION. The challenger was right that the earlier proof was incomplete:
it showed the new test goes green, never that the test still binds the #1830
relaxation. Stubbing posix_compute_ancestor_overflow_uid() to
POSIX_NO_OVERFLOW_UID -- the relaxation removed -- and running as uid 1001:

  tolerance off, new probe   48 passed, 1 failed (refused, exit 1)
  tolerance on,  new probe   49 passed, 0 skipped

so the smoke test is revert-sensitive to the production decision, not merely to
the test change. Worth recording: both deterministic #1830 tests PASSED with
the tolerance stubbed off, because they drive the test-seam override rather
than the real derivation. They bind the decision table; only this smoke test
binds the wiring.

macOS daemon_ipc 53 passed 1 skipped; linux/arm64 49 passed 0 skipped;
lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-13 09:56:54 +02:00
Martin Vogel 469c3dd996 fix(test): exec the #1830 userns probe instead of forking a primed process
The single-uid userns smoke test forked a child, unshared CLONE_NEWUSER,
wrote a 1:1 uid_map, then called cbm_daemon_ipc_private_directory_secure()
directly in that child. The call cannot give the right answer there.

posix_ancestor_overflow_uid() in src/daemon/ipc.c derives the overflow uid
once per process under pthread_once, and the comment above it calls the
/proc state "immutable". unshare(CLONE_NEWUSER) is precisely what changes
/proc/self/uid_map, and pthread_once state survives fork(). Seven earlier
call sites in this suite run the derivation first, so by the time the test
forks, the child has already inherited the HOST answer -- no overflow uid --
and refuses the directory no matter what its namespace says.

The test therefore asserted a product behaviour it never executed, and failed
as a product refusal (exit 1) while the product was correct: production
daemons start inside their namespace and derive the right answer on first use.

Re-exec /proc/self/exe into a new --userns-secure-probe mode so the decision
is made by a process that STARTED in the namespace, which is also the only
shape production ever takes. A failed exec gets its own exit code 3 and a
named FAIL, so a broken harness can never be misread as a quiet refusal.

Verified both directions on linux/arm64 in the container leg as uid 1001:
reverted, 48 passed / 1 failed on "expected WEXITSTATUS(status) == 1"; with
the fix, 49 passed, 0 skipped.

The non-root half of that recipe is now written down at the test, because it
is the part that fooled me first: as root the mapped range covers uid 0, so
root-owned /tmp stays 1:1 inside the namespace and never turns overflow. The
first run of this reproduction passed with the fix REVERTED for exactly that
reason. Only a non-root uid reaches the branch the test exists to cover.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-13 09:20:11 +02:00
Martin Vogel 91bee4fb28 Merge pull request #2113 from DeusData/fix/1828-enospc-daemon-start-diagnostics
fix(daemon): name errno/path on listen failure and fail the client fast instead of a 30s timeout (#1828)
2026-09-12 03:59:26 +02:00
Martin Vogel ca86fdb057 Merge branch 'main' into fix/1828-enospc-daemon-start-diagnostics 2026-09-11 23:26:30 +02:00
Martin Vogel 62bafb76b3 Merge remote-tracking branch 'origin/main' into fix/decision-b-win-sid-trust
# Conflicts:
#	src/daemon/ipc.h
2026-09-10 01:11:18 +02:00
Martin VogelandClaude Fable 5.1 df579830f3 fix(install): trust the local machine's built-in Administrator; name untrusted owners (#1705, #2023, #1686)
Windows install/daemon runtime directories created by an elevated process are
owned by the built-in Administrator ACCOUNT (RID 500 under the local machine
account-domain SID), even when that account is renamed or disabled. cbm's
directory-owner/ACL trust refused it, breaking install/activation on such
machines.

#1705: resolve THIS machine's built-in Administrator SID via LSA
(LsaOpenPolicy(POLICY_VIEW_LOCAL_INFORMATION) +
LsaQueryInformationPolicy(PolicyAccountDomainInformation) ->
CreateWellKnownSid(WinAccountAdministratorSid, accountDomainSid)), cached once
per process, and accept it as a trusted owner/grantee in the daemon
(win_sid_trusted) and the CLI activation transaction. Resolution is
deliberately NOT IsWellKnownSid(WinAccountAdministratorSid) and NOT a
trailing-RID-500 match: both accept a FOREIGN S-1-5-21-*-500 (a domain
administrator, or another machine's built-in Administrator), which would be a
cross-machine trust escalation. Any LSA or synthesis failure caches "none" and
grants no tolerance (fail closed). owner-not-current-user (the exact-owner
target rule) is unchanged.

#2023/#1686: an owner-trust refusal now names the offending owner SID (the path
was already carried), so a terse "status -3, os 0" becomes actionable.

Scope notes: on a domain controller -- which has no separate local SAM, so its
account domain IS the AD domain -- this trusts the domain Administrator, by
design (on a DC that account is the machine-equivalent top admin). An
un-sysprepped cloned Windows image shares the machine account-domain SID, so a
sibling clone's built-in Administrator would be non-foreign; this is inherent
to machine-SID identity.

Verified on a native Windows (arm64) VM: compiles under the sanitized build;
the daemon_ipc SID-trust test passes (local RID-500 trusted, a fabricated
foreign S-1-5-21-*-500 rejected, BUILTIN\Administrators trusted) at 35 passed /
0 failed with the fix, and 34 passed / 1 failed (ASSERT(local_ok)) with the
trust clause reverted -- the test binds precisely to the fix. #1856 (stock
ancestor ACEs): the Windows ancestor-ACL guards already pass; no defect
reproduced, no code change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-10 00:40:49 +02:00
Martin VogelandClaude Fable 5.1 8116672a7e fix(daemon): accept single-uid userns overflow-owned ancestors (#1830)
Inside a 1:1 user namespace (podman --userns=keep-id, unshare -U
--map-current-user), the root-owned ancestors /, /tmp appear owned by the
kernel overflow uid; the daemon's ancestor-ownership walk refused every
overflow-owned ancestor, making the daemon unusable in that legitimate setup.

Tolerate an overflow-owned ancestor ONLY when /proc/self/uid_map is a
single-uid map whose sole inside id is our euid AND /proc/sys/kernel/overflowuid
is readable (never hardcoded; unreadable => no tolerance). No environment
escape hatch. Group-writable overflow ancestor refused (host group unknown);
world-writable overflow tolerated only with the sticky bit (the /tmp shape).
The created/final/snapshot gates stay strict == geteuid(), so a host-squatted
overflow-owned private directory is still refused; the tolerance only widens
which ancestors the walk traverses. macOS byte-identical (overflow=-1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-09 23:45:38 +02:00
Martin VogelandClaude Fable 5.1 33c4fd80ef fix(daemon): name errno and path in daemon.ipc.listen_failed
A full /tmp made every daemon start die at pending publication, and the
only durable trace was `daemon.ipc.listen_failed stage=pending_publication`
-- no syscall, no errno, no path. The reporter needed hours (and a `df` on
the wrong mount) to find the cause (#1828).

Every listener failure now records the stage, the errno the failing step
reported, and the artifact path it was operating on, and the log line
carries all three:

    daemon.ipc.listen_failed stage=pending_publication errno=ENOSPC
        path=/tmp/cbm-daemon-1000/cbm-<key>.pending.tmp

The record publication helper notes which step refused (create, write,
link, unlink, sync, close, read-back) and on which artifact, so the
listener can name it. The bind failure keeps its errno but joins the same
shape (stage=socket_bind errno=... path=...) instead of a bare numeric
bind_errno. cbm_errno_name() maps the portable errno set to its symbol.
cbm_daemon_ipc_listen_failure_detail() exposes the same triple to the
daemon host for the fail-fast record that follows.

Test: daemon_ipc_listen_failure_names_errno_and_path forces the record
write to fail with ENOSPC through a narrow inert seam (the socket binds,
the file is created, only its data write is refused -- the reporter's
exact shape) and asserts the log line names the errno and the temp path,
and that the namespace is left clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-08 01:35:48 +02:00
Matt Van Horn 8e70590d16 fix: recover markerless stale rendezvous sockets during activation
Fixes #1760

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-31 05:49:20 -07:00
Martin Vogel b91abca173 fix(daemon): admit a group-writable ancestor, and stop inventing an errno (#1537)
The reporter came back with the output that named this:

    exact executable identity could not be verified (cache-private) -
    /Users/.../.cache/codebase-memory-mcp: ancestry component validation
    failed (errno 2)

Two separate defects, and the second is why nobody could see the first.

**The refusal.** The daemon's private-directory ancestry walk rejected any
group-write bit on any ancestor — `(mode & 0022) == 0`. That is the identical
rule #1535 removed on the activation side, in the sibling gate the consolidated
strictness decision covers but that never got changed. A group-writable ~ or
~/.cache is ordinary: WSL2 ships 0775, so do several distro skeletons and any
site with a shared primary group. So the daemon refused to run, permanently,
with nothing the reader could close or restart.

World-writable is still refused (any local user could swap a path component),
and the private directory itself is still chmod'd to 0700 and verified after
the walk — which is what makes admitting the ancestor safe: the thing that
actually holds data stays owner-private either way. Group-writable ancestors
are logged, so the weaker check is never silent. Both directions are tested;
a fix that admitted everything would "fix" this by deleting the protection.

**The invented errno.** When that check failed, the branch set no detail, so
the caller fell back to printing `errno` — which nothing on the path sets. The
reporter was handed ENOENT for a permission refusal and went looking for a
missing file that was there all along; it cost a round trip to find out the
number meant nothing. The branch now names the component and the rule.

Reported by @listepo, who ran the diagnostics that turned this from a guess
into a one-line root cause.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 12:47:59 +02:00
PETRO YAKOVYSHYN 3e540aac0b fix(daemon): make private directory DACL inheritable
win_security_init built its owner-only ACL with AddAccessAllowedAce, which
has no AceFlags parameter and can only emit an ACE with flags set to zero.
Applied to a directory together with PROTECTED_DACL_SECURITY_INFORMATION
that yields D:PAI(A;;FA;;;<user>): the protection severs the inherited ACEs
while the new ACE propagates nothing, so every child created afterwards is
born with an empty DACL and is unreadable even by its owner.

win_runtime_directory_secure is also a repair path. It continues on
ERROR_ALREADY_EXISTS and re-stamps the existing directory, so a tree
restored by hand with icacls is destroyed again on the next daemon or
client start.

Add a second ACL for containers, built with AddAccessAllowedAceEx and
CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, and use it at the three
directory sites. Files and kernel objects are leaves and keep the flagless
ACL.

The container ACE carries FILE_ALL_ACCESS rather than GENERIC_ALL. Windows
splits an inheritable generic-rights ACE into an effective mapped ACE plus
an INHERIT_ONLY one holding the generic bits, and the owner-only DACL
validators require exactly one ACE, so the generic form fails every lock
directory with "secure CLI coordination could not be created
(project-locks)".

Fixes #1351

Signed-off-by: PETRO YAKOVYSHYN <petroyakovyshyn@gmail.com>
2026-08-11 01:34:13 -07:00
Martin Vogel 3fc7e9ac22 Merge pull request #1226 from Oxygen56/fix/windows-werror-build
fix(win): compile cleanly with MinGW GCC
2026-07-31 01:53:49 +02:00
Martin Vogel 5f2c146866 test(daemon_ipc): pin the Windows startup interleaving instead of polling for it
Two Windows tests needed the startup thread to be observed HOLDING the startup
lock, and looked for it by polling the lock for BUSY up to 200 times. That state
has no lower bound: whenever the rendezvous handoff does not block, acquire ->
handoff -> release completes in microseconds, so the window can close between
two polls and startup_observed asserts on a coin flip. This blocked release run
30374135923 on test-windows (windows-2025, CLANG64, x86_64).

Budget-tuning the poll was tried and rejected -- a wider budget only lowers the
failure rate, and a transient window is never a fixed test. Instead the
production path exposes a gate that fires once the startup lock is held and
before the handoff runs, matching the hook pattern already used for POSIX
publication. The test parks the startup thread there and releases it when it is
ready, so the interleaving is pinned by construction. The acquired flag is
monotonic, so the observation cannot be missed; the remaining bound only catches
a thread that never started at all. The gate is inert unless a test installs it.

Also fixes an unrelated use-after-free this exposed. ipc_forever_wait_server
closed the connection and NULLed it from the server thread, while the test was
about to call cbm_daemon_ipc_connection_interrupt on the pointer it had already
loaded -- and receive_frame can return on its own before that call lands. It
crashed roughly one Windows run in five, independent of the change above.
Ownership now stays with the test, which closes after joining, so interrupt can
only ever see a live connection.

Verified on real Windows: 5 consecutive daemon_ipc runs, no failure and no
crash. macOS 46 passed x5 under ASan/UBSan; Linux 6606 passed, 0 failed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-28 19:42:56 +02:00
Oxygen56 52e46f3fc4 fix(win): compile cleanly with MinGW GCC
Signed-off-by: Oxygen56 <jiangth99@163.com>
2026-07-23 19:21:42 +08:00
Martin Vogel 0baddf7887 daemon: permanent lifecycle, daemon-backed CLI/hooks, real-Windows hardening, long-path launcher transactions
Daemon lifecycle and Windows correctness, verified on a real Windows 11
ARM64 VM through the maintained test-infrastructure/vm drivers, plus the
macOS and Linux arm64 suites and the container lint gate.

Daemon lifecycle:

- daemon start/stop/status subcommands. `daemon start` launches a
  PERMANENT daemon (spawn shape is byte-exact argv; survives idle
  periods and session ends) and reports an already-active daemon
  instead of failing. Permanence is honored at every stop latch:
  last-committed-client disconnect, host initial-client window,
  coordinator release, and application final-session close — a
  permanent daemon also keeps admitting new sessions after its last
  one closes.
- daemon stop refuses while sessions are active and lists the blocking
  peers (pid/role) that must finish first; an idle daemon drains
  through the activation-shutdown machinery with the ACK ordered after
  connection interrupts. A second stop is idempotent. The wire ops are
  no-cohort first-frame requests with peer fingerprint authentication,
  so stop/status never conflict with an exact-build admission gate.
- One-shot CLI commands now execute through the daemon (index workers
  keep their local supervised path). A cold CLI run that had to spawn a
  temporary daemon prints a hint that `daemon start` removes the
  per-command startup tax; a warm daemon is recycled silently.
- Hooks are connect-only fail-open: with no daemon present the hook
  emits a visible, rate-limited notice (Claude-dialect systemMessage
  plus stderr for other dialects) and always exits 0 — augmentation is
  never allowed to block the caller's tool use.
- Version skew: a newer-build client automatically drains an
  older-build permanent daemon (strict semantic-version triples only;
  dev builds never auto-drain) and the build-conflict message names
  `cbm daemon stop` as the manual escape hatch.

Windows IPC/runtime (real-VM verified):

- ipc(win): persistent pending overlapped ConnectNamedPipe. The accept
  path used to destroy its listening pipe instance on every 20 ms poll
  timeout; a client attaching in the teardown window was severed or left
  on an orphaned pipe object whose HELLO no server handle could ever
  read, absorbing the connect until the client's own timeout expired.
  The pending connect now survives poll timeouts and nothing is
  destroyed while a client could be attaching.
- ipc(win): drain-before-close for final responses. Closing a named-pipe
  server handle can discard a just-sent response before the peer reads
  it (POSIX stream sockets never lose buffered data on close). A bounded
  cbm_daemon_ipc_connection_drain (read-until-peer-EOF; no-op on POSIX,
  immediate on interrupted connections) now precedes close in
  runtime_worker_finish and runtime_reject_inline, so hello-conflict,
  capacity and disconnect acknowledgements reliably reach the peer.
- runtime: CLOSE_INTENT wire frame. A Windows named-pipe client has no
  transport half-close, so close_begin now announces departure with an
  explicit frame (ordered after APPLICATION_CANCEL, before the local
  interrupt); the server releases the client's admission on receipt
  instead of waiting for the handle to close. Admission-drop timing is
  now identical to POSIX shutdown() semantics on every platform.
- runtime(win): client close cancellation. close_begin serializes with
  request publication under the send lock, best-effort sends the active
  token's APPLICATION_CANCEL frame, then interrupts local I/O; the
  server cancels MCP/subprocess work promptly. Contract tests accept
  both correct outcomes (interrupted transport or decoded CANCELLED).
- runtime: activation acknowledgement ordering. The activation ACK is
  the requester's license to act on "snapshotted and draining", so every
  connection interrupt is now initiated before the ACK is sent; a
  session could previously get one more request serviced after the
  requester observed the ACK.
- service(win): deadline-bounded private-file prepare. The conflict-log
  prepare retry loop (100 x Sleep(2), which rounds up to the ~16 ms
  timer granularity) burned ~1.6 s against permanently obstructed paths,
  stalling hello rejections past the client's timeout. The retry budget
  is now a 250 ms deadline; transient share collisions still retry.
- subprocess(win): cmd.exe /C payload encoder quotes metacharacters
  correctly (root cause of the git-on-Windows failure cluster).
- watcher: SHA-256 buffer sizing (CBM_SZ_64 -> CBM_SZ_128) and a native
  Windows stop/unwatch cancellation test with exact-image verification.
- httpd: send_all writes in bounded 64 KiB slices. A single giant
  nonblocking send() on Windows is absorbed wholesale into AFD kernel
  buffering regardless of SO_SNDBUF, so send deadlines and interrupts
  could never engage against a slow peer (and the full payload was
  pinned in nonpaged pool). Slicing restores a deterministic
  backpressure point; a test hook pins SO_SNDBUF for the deadline and
  interrupt tests.
- ui/http: shutdown lifecycle — interrupt checks, response-wide send
  deadline, explicit connection states, refusal to free a server while
  a listener-owned connection is active.

Windows long-path support:

- Central path-aware wide conversion (canonicalize via GetFullPathNameW
  and prepend the extended-length prefix for absolute paths >=240) at
  the compat chokepoints (cbm_fopen/compat_fs/mkstemp/mkdtemp), sqlite
  store opens, and the daemon build-fingerprint/log paths. Deep managed
  installs (a 64-hex generation directory routinely exceeds MAX_PATH)
  now index, stage and activate correctly.
- activation transaction: its own file APIs and the component-walking
  ancestry validators now operate in the extended-length namespace;
  the launcher path is canonicalized (and prefixed when deep) once at
  entry so every downstream exact-string comparison stays
  form-consistent.
- Executable self-resolution uses the wide APIs (GetModuleFileNameW,
  GetFileAttributesW) so non-ASCII install paths survive argv[0]
  resolution.

Windows launcher install/uninstall transaction:

- FileRenameInfoEx names are NUL-terminated in an over-allocated
  buffer. FileNameLength governs per the contract, but filter drivers
  read FileName as NUL-terminated and appended adjacent heap bytes to
  created names — a flaky, garbage-suffixed rename target. Both the CLI
  and the launcher rename helpers are fixed.
- Uninstall retires state via rename-aside (.cbm ->
  .cbm-retired-v1-<tag>-<pid>) with the retired tag shortened to 16 hex
  chars so the bare rename target stays under the FileRenameInfoEx
  NT-conversion ceiling at guard depths; 64 bits still uniquely
  identify the generation.
- When the running launcher's mapped generation backings pin .cbm
  against rename, the backings are relocated to activation-<pid>-N
  .retired tombstones beside the install (a mapped image may be renamed,
  never deleted; the launcher's liveness-guarded sweep reclaims stale
  tombstones). Every relocation is recorded, and a FAILED uninstall
  reverses the moves after restoring .cbm — via MoveFileExW with
  extended-length paths on both arguments, since the deep generation
  target is beyond the handle-based rename's bare-path reach — so a
  restored install keeps its generation backings and stays runnable.
- After a committed uninstall the retired tree's backings are relocated
  out so the tree is shallow enough for the detached cleanup's rd, and
  the cleanup's working directory strips the extended-length prefix
  (CreateProcessW lpCurrentDirectory silently ignores prefixed paths).
- Files created under Administrators-default-owner directories
  (CopyFileW destinations, CREATE_NEW tombstones, probe directories)
  are explicitly owner-stamped so the exact-owner validators hold on
  runner images; guard fixtures stamp hand-built trees the same way.

Diagnostics, tests and infra:

- diagnostics: discovery is now an always-delivered JSON control record
  (new cbm_log_control) that survives CBM_LOG_LEVEL suppression and
  paths containing spaces; placement honors $TMPDIR with /tmp fallback
  via a diagnostics-local helper; the soak parser reads the JSON record;
  documented in docs/CONFIGURATION.md. Red-first coverage for suppressed
  log levels, TMPDIR-with-spaces, and native Windows output-contract
  assertions.
- tests(win): daemon_ipc/daemon_frontend fixtures now build endpoint
  parents with production-shaped ancestry (LocalAppData on Windows, via
  th_secure_runtime_parent_new) — the runtime ancestry validation
  correctly refuses temp roots whose ancestors grant mutation rights to
  Authenticated Users (C:/msys64/tmp, GitHub-runner work dirs) — and
  drive the documented startup-owner publication flow before reading
  generation-bound endpoint addresses. This turns the 26 Windows
  failures previously visible in CI's full-test job green without
  weakening any validation.
- tests(win): the launcher guard covers the full permanent-launcher
  contract including failed-uninstall restore and immediate reinstall
  after uninstall; new daemon lifecycle and reworked hook-augment
  guards run the start/recycle/stop flow end to end.
- tests: CBM_SKIP_PERF is now actually consumed by the test runner
  (it was set by CI but never read, so perf suites ran everywhere);
  four throughput/bench suites are classified as perf, the heavy
  store_arch suite moved to the slow-timeout tier, and two
  wall-clock-sensitive assertions were rewritten as invariant checks
  with coarse hang-detector backstops.
- build/test infra: build-dir safety contract, UI dev-proxy security
  contract, soak daemon-recovery contract, path-safety helper, the
  Windows VM worktree-sync contract wired into scripts/test.sh, and
  vm/win.sh guards building its clean embedded-UI product in an
  isolated BUILD_DIR so it cannot clobber the incremental test build.
  provision-windows.sh now installs Node.js for the guards UI build.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-21 15:32:15 +02:00
Martin Vogel ad2874bcff fix: close remaining daemon coordination races
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 15:26:38 +02:00
Martin Vogel 89c7c98f00 fix: support standard Windows profile ACLs
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 12:26:17 +02:00
Martin Vogel 83c137d2a5 feat: complete shared daemon lifecycle
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 01:26:08 +02:00
Martin Vogel 0e00ef5702 feat: coordinate concurrent CBM sessions
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-16 19:20:46 +02:00