Skip to content

chore: sync fork with upstream main - #1

Merged
thientrile merged 722 commits into
mainfrom
chore/sync-upstream-main
Sep 27, 2026
Merged

thientrile merged 722 commits into
mainfrom
chore/sync-upstream-main

Conversation

@thientrile

Copy link
Copy Markdown
Owner

Fast-forward fork main onto upstream/main (722 commits, no fork-only divergence).

DeusData and others added 30 commits September 7, 2026 03:55
…eusData#963)

scripts/setup-windows.ps1 has 326 lines and its parse-coverage report read
"113-113,113-113,245-327" — the same line named twice, and an end line that
does not exist. Two separate faults, both in cbm_error_regions_push.

Past-EOF end line. A tree-sitter node that ends at column 0 stopped right
after the previous line's newline, so it holds no text on the row it points
at. Adding 1 to that row named a line past the end of the file whenever the
region ran to EOF. The node here is start=(244,2) end=(326,0). Clamp the end
to the row above when the end column is 0 and the node spans more than one
row.

Duplicate range. Line 113 carries two separate ERROR nodes, at columns 25-29
and 31-32, and each pushed its own range. A line range says nothing new the
second time. Drop a range that exactly repeats the one already open. The
drop runs BEFORE the cap check, so a repeat is never miscounted as a range
the cap threw away.

Only an EXACT repeat is dropped, never a range that merely overlaps. Each
range is judged separately afterwards by cbm_region_is_recovered, which asks
whether definitions starting inside that range cover it. Two ranges holding
the same numbers always get the same verdict, so dropping the repeat changes
nothing. Two different ranges do not. Merging 3-3 into 2-3 hands the wider
range's covering definition to an error that definition does not explain, and
a real parse failure then vanishes from the report.

That is not hypothetical. An earlier version of this commit merged on overlap
and broke perl_malformed_source_remains_partial_issue1838, the test added
with the Perl grammar refresh in 17b5a43. The malformed fixture produces two
ERROR nodes, at lines 2-3 and 3-3. Merged, the 2-3 range looks fully covered
by before_error and is removed, so parse_incomplete comes back false on a file
that plainly does not parse. That test now pins this boundary.

The real file reports "113-113,245-326".

Two tests, both proved RED first with the exact expected text:
  coverage_repeated_error_line_reports_one_range_issue963  "2-2,2-2" != "2-2"
  coverage_range_never_ends_past_the_last_line_issue963    "1-5"    != "1-4"

Suites run on this change: parse_coverage 34, extraction 325, pipeline 264,
mcp 246, index_resilience 7 — all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
…ge-duplicate-and-eof

fix(coverage): stop reporting a duplicate range and a line past EOF (DeusData#963)
…n-variable-count

fix(cypher): refuse a query naming more variables than a binding holds
…rt-null-guard

fix(pipeline): skip the function sort when the graph has no functions
…chdog

fix(main): exit Windows stdio servers when the spawning client dies (DeusData#914)
…ous-recovery

fix: recover markerless stale rendezvous sockets during activation
…shallowest

try_load_nested_gitignore() returned NULL as soon as a walk frame already
carried a local matcher, and that matcher was inherited by every child
frame. So only the SHALLOWEST nested .gitignore on a path was ever loaded;
any deeper one was silently dropped — an EMPTY storage/.gitignore was
enough to disable storage/dump/.gitignore's "*". On the standard Laravel
layout (storage/.gitignore above storage/<dir>/.gitignore) that meant
thousands of git-ignored cache/debugbar dumps were discovered and indexed,
which on the reporter's app grew to 25,638 discovered files against 6,682
git-visible ones and ended in an OOM kill (DeusData#1973, discovery half; also the
§2 root cause of DeusData#530).

Discovery now keeps a root->leaf chain of .gitignore links per directory
frame: every directory below the root loads its own .gitignore, a frame
without one shares its parent's link, and a path is judged by walking the
chain leaf->root with git's precedence — the deepest file with an opinion
wins (so a deeper negation re-includes what an ancestor ignores), and inside
one file the last matching pattern wins. The repository root's .gitignore
(merged with info/exclude) is the chain head, so the same precedence holds
between root and nested files. Cost is one match per .gitignore on the
path — O(depth) per entry, no rescans; the walk frame shrinks by the 4 KB
local-prefix buffer it no longer needs.

Tests pin the reporter's layout (empty ancestor + deeper "*"), a deeper
negation overriding an ancestor pattern, and three nesting levels with an
empty middle file. Measured on a Laravel app (koel) seeded with typical
compiled-view/cache artifacts: 2,885 discovered files with 450 git-ignored
-> 2,435 with 0; clean clones of linux, dotnet/runtime, koel and redis
discover byte-identical file sets before and after.

Fixes the discovery half of DeusData#1973.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Hermes `install` failed with `op=pre_llm_hook_install` whenever config.yaml
held a PLAIN scalar with a `>` or `|` anywhere after the value start —
`probe: kaomoji face >w< here` — while the identical string double-quoted,
or a mid-value `*`, installed fine. An agent-config failure aborts
activation, so the whole install exited 1 and user-PATH registration was
skipped although eleven other agents had configured cleanly. Bisected by
@ukind, key by key and persona by persona, down to two persona strings
(DeusData#1924, continuing DeusData#1631/DeusData#1656).

Root cause: yaml_sequence_line_has_unsupported — the document validator on
the mapping-sequence path that the hook upsert/remove uses — ran a second
scan after the shared yaml_range_has_unsupported_ex and returned
"unsupported" for `[`, `]`, `|` and `>` at ANY position of a plain scalar.
The mapping-body path (mcp_servers) never had that check, which is exactly
why the mcp op succeeded and only the hook op failed on the same file.

Fix: the positional rule the `*`/`&` fix (v0.10.4) and the quote fix
(DeusData#1631, v0.10.6) already use — an indicator counts only where a NODE begins
(range start, after a mapping colon, after a block-sequence dash).
Interior ones are text. `probe: >`, `probe: |`, `probe: >-` and
`probe: [a, b]` still begin a value and stay unsupported, byte-identically.

`[`/`]` get the same treatment on purpose: in block context they are
ordinary characters inside a plain scalar (`a: b[0]` parses as the string
`b[0]`), a value-start `[` still opens a flow sequence and stays refused
(the DeusData#1631 line: exact `[]` accepted, non-empty flow sequences unsupported),
and the mapping-body path already accepted them mid-value — leaving the
sequence scan stricter would keep the mcp-op-passes / hook-op-fails
asymmetry this issue is about. Known conservative residue, shared with the
`*` rule: a plain-scalar CONTINUATION line that itself starts with one of
these characters is still refused (nothing precedes it on its line).

Tests (tests/test_config_yaml_edit.c): three accept cases run both real
Hermes ops (mcp_servers + pre_llm_call) against a persona corpus with a
mid-value `>`, `|` and `[]` and require the original document to survive as
an untouched prefix; two pins keep value-start block scalars and a non-empty
flow sequence refused with the file byte-identical. RED on the unfixed
editor: 61 passed, 3 failed (`yaml_hermes_hook_upsert(&fixture) == -1`);
GREEN: 64 passed; RED again with the production change reverted.

config_yaml_edit 64 · yaml 73 · cli 306 — 443 passed, 0 failed (macOS,
ASan/UBSan test build).

Not in this change: the refusal still reaches the user as
`op=pre_llm_hook_install path=... (target: regular file, N bytes)` with no
reason. The editor collapses every one of its ~236 YAML_ERROR sites into
CBM_YAML_IDENTITY_EDIT_ERROR, so naming the refusing rule needs a detailed
variant with a typed failure out-parameter, the shape the Codex TOML hook
path already has (cbm_toml_codex_hook_failure_t +
record_agent_config_error_with_reason). Follow-up.

Fixes DeusData#1924.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…ignature

After every publish the pipeline re-exports <root>/.codebase-memory/ (graph.db.zst + artifact.json) whenever an artifact already lives there: once a session indexed with persistence, or when the team committed it and every linked worktree checks it out. The watcher's dirty signature folded those files' (size, mtime) in, so each successful reindex rewrote them, the next poll saw a new dirty state, and the daemon re-triggered itself forever: index.supervisor.reap outcome=clean immediately followed by watcher.changed strategy=git, 100+ times in 15 minutes with index workers pinned (DeusData#1953). Nothing under .git moved and no source changed, which is why the reporters blamed their long hyphenated worktree branch names (the DeusData#1254 theory its own reporter retracted); the regression test builds that exact worktree and shows the branch is irrelevant.

Scope git status with the same :(exclude).codebase-memory pathspec the exporter's own clean-tree probe already uses for the same reason. Nothing under that directory is ever an index input (discovery skips it), so a change there can never require a reindex; the pathspec is CWD-relative, so a monorepo sub-package watch excludes its own artifact directory. Untracked and committed artifacts both looped before; a real edit and a real HEAD move are still detected.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…terministic-order

fix(pipeline): make the complexity pass independent of worker id order
…agents-cleanup

fix(codex): replace global guidance with an activation pointer
…i-attribute-decorates

fix(extract): scan every attribute_list sibling, not just the first (DeusData#1692)
The outer run rewrites the pipeline's db_path to its own stage, so the
inner publication (gbuf dump and the delta clone) minted ITS stage from
that stage: <db>.stage.A.stage.B, with -wal/-shm beside it under WAL.
Every such copy is a full-size generation, so one project could occupy
three times its database on disk (DeusData#1839).

Mint from the stage root instead. The root is recognised only by the
exact minted shape "<name>.stage.<6 alphanumerics>" in the basename, so
a database whose own name merely contains ".stage." keeps its full name,
and the marker is never matched across a path separator. The inner
finalize still renames B over A; the unpredictable-name contract of the
mkstemp helper is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…t-overshooting

The extraction memory gate was advisory: when a full back-pressure cycle
ended still over budget it latched "futile" and kept admitting files, so a
host that could not hold the graph ran on until the kernel killed the
worker (DeusData#1997, DeusData#832) and the supervisor then quarantined innocent files.

Now the worker that latches futility runs ONE confirmation cycle (collect
+ bounded naps). If resident memory drained, the gate re-arms as before.
If it is still over budget, the attempt fails whole: every worker stops
pulling, extract returns the new CBM_PIPELINE_ABORT_OVER_BUDGET, the
orchestrator discards the staging DB on that non-zero code like any other,
and the previously serving generation is left byte-identical. The verdict
is a plain return value so it travels as a clean-exit worker response,
never a kill. The abort flag is its own atomic, not the shared cancel
token, which belongs to client cancellation.

The abort/persist codes move to pipeline.h alongside the new one (callers
legitimately distinguish them); FORCE_FULL_REINDEX stays internal.

Tests: the futility test now asserts the new code (the confirmation adds
one cycle to its bound), and a new test proves fail-whole preserves the
live DB byte-for-byte, keeps its node count, and leaves no stage residue.

Refs DeusData#1997 DeusData#832 DeusData#1973 DeusData#1864

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
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 (DeusData#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>
A single agent configuration the editors refuse to touch (the reporter's
~/.cursor/mcp.json was a symlink into a dotfiles checkout) aborted the
uninstall activation BEFORE index and binary removal. `uninstall -y`
exited 1 and left a 300 MB executable plus the whole cache behind — the
data-loss half of DeusData#1954 — while the transcript still read "removed".

Agent-config failures are now collected instead of gating: every cleanup
runs, the indexes and the executable go, and the command ends with one
list of the files that could not be cleaned (agent, operation, path,
observed target) and a non-zero exit. The existing per-error stderr line
is unchanged.

Two tests encoded the old hostage contract ("binary must survive failed
cleanup") and move to the new one. The new test drives `uninstall -y`
against a sandbox HOME with a dangling ~/.cursor/mcp.json link and
asserts binary + index gone, exit non-zero, and the file named on
stderr; on main it fails at ASSERT(binary_gone). (DeusData#1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A stage was recognisable only by its name: the mkstemp descriptor was
closed at once and nothing marked who was writing it. A worker killed
mid-run -- the daemon cancels with SIGTERM and SIGKILLs after one second
of grace, which a gigabyte backup or delta clone never finishes inside --
left its full-size stage behind for good, and no later run could tell a
dead stage from a live one, so none tried (DeusData#1839).

Ownership is now an exclusive kernel lock on the sidecar "<stage>.lock",
taken when the stage is minted and dropped by the same helpers that
discard the stage or rename it into place. The kernel releases it on any
death, so "can I take this lock?" is exactly "is this stage dead?" -- no
pid, no mtime, no grace period. The lock lives on a sidecar rather than
the stage file itself because on macOS an flock conflicts with SQLite's
fcntl byte locks on the same file (verified: flock(LOCK_EX) then
fcntl(F_WRLCK) on another descriptor fails with EAGAIN, both directions).

The primitive is cbm_lockfile_open: POSIX open(O_CLOEXEC|O_NOFOLLOW) plus
flock(LOCK_EX|LOCK_NB); Windows _wsopen with _SH_DENYRW and _O_NOINHERIT.
Neither is inherited by spawned children. A backup that fails keeps the
outer stage NAME owned: the rebuilt generation is renamed over it by the
inner finalize and published from it, so its lock is held to the end.
The sweep that uses this ownership follows separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
index_repository rendered every non-zero pipeline code as "Pipeline
failed. Check repo_path exists and contains source files", which sent
people debugging a path that was fine (DeusData#2020). Three outcomes now say
what actually happened:

- CBM_PIPELINE_ABORT_OVER_BUDGET (DeusData#1997, DeusData#832): status "error" with
  reason "over_memory_budget", previous_index "preserved", the budget and
  peak RSS in MB, and a hint naming CBM_MEM_BUDGET_MB / CBM_WORKERS /
  excludes. The handler logs index.abort reason=over_memory_budget. The
  CLI prints the same JSON and exits 1; a supervised worker still exits 0
  with the response written, so the daemon passes it through verbatim
  instead of entering skip-and-continue recovery.
- CBM_PIPELINE_ABORT_PRESERVE_DB: status "aborted_previous_preserved"
  (retry; the previous index still serves).
- CBM_PIPELINE_PERSIST_FAILED: status "persist_failed" (disk/permissions).

The incremental test helper goes fail-closed on error responses so a
transient pre-publication abort surfaces once at the source instead of
cascading into dozens of downstream count failures.

Test: an in-process index over 64 files at a 1 MiB budget seam returns
the named error, leaves the project DB the same size on disk, and
index_status still reports the previous node count.

Refs DeusData#2020 DeusData#1997 DeusData#832

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
… run

Nothing on the worker-death path ever cleaned a stage up: the supervisor
reaps only an exit status, and a cancel is SIGTERM then SIGKILL after one
second, which a gigabyte backup or clone never finishes inside. Every
daemon-side cancel or OOM kill therefore left a full-size <db>.stage.X
(plus -wal/-shm) behind for good, and with auto_index a large project
filled the disk with copies of itself (DeusData#1839).

Each run now sweeps before minting its own stage. Only names of the exact
minted shape for THIS database are considered -- "<basename>.stage.<6
alphanumerics>" and that stage's -wal/-shm/-journal/.lock sidecars --
never the live database, a quarantined .corrupt, or another project's
files. Liveness is the ownership lock and nothing else: a stage whose
sidecar lock can be taken has no writer and is removed (logged as
pipeline.stage action=orphan_removed bytes=N); one whose lock a live
writer holds is kept (orphan_kept reason=live_writer). No pid, no mtime,
no grace period, so the sweep is deterministic and never races a writer.

A stage minted by an older binary carries no lock and is swept too; that
writer's final rename then fails and it discards its stage. The live
database is never named on either path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…e job limit

The daemon handed every index worker aggregate/physical_job_limit (a
fixed quarter by default) regardless of how many jobs were running, so a
lone worker on a host like DeusData#1864 was capped at a quarter of the memory
the daemon itself was allowed — and with the budget now failing whole
(decision A) that fixed slice would have turned an idle host into a
guaranteed over_memory_budget. Decision 2 (DeusData#1997, DeusData#832): the slice is
decided at spawn time as aggregate / jobs active right then (the job
being spawned included), so a lone worker may use the whole aggregate
and concurrent jobs split it. The explicit CBM_MEM_BUDGET_MB override
keeps its precedence inside the worker (bf46b91); the sub-byte-per-slot
guard is unchanged. The spawn line logs project, active_jobs,
aggregate_mb and slice_mb so a fail-whole verdict can be read against
the budget the worker really had.

Tests: sequenced admission proves first=aggregate, second=aggregate/2;
the racing 4-way admission test accepts any aggregate/1..4 slice (the
divisor is scheduling-dependent there); a scripted CLEAN worker
returning the over_memory_budget error response is passed through
verbatim on the first attempt — no recovery loop, no quarantine.

Refs DeusData#1997 DeusData#832 DeusData#1864

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A daemon that died at listener publication (a full /tmp) took the
lifetime reservation, failed, and released it; the waiting client saw the
generation vanish, respawned it, and repeated that for its whole 30 s
deadline before reporting "CBM daemon is active or starting but could not
accept this client" -- the opposite of what happened, with no cause and
no path. MCP hosts then dropped the server silently (DeusData#1828).

A detached daemon has no stderr and no channel back to its launcher, so
the host now appends one start-failure record per failed start to
<cache>/logs/cbm-daemon.start-failures.log -- next to cbm-daemon.log,
through the same owner-only private-log open. Deliberately NOT the
runtime directory: that is exactly what a full /tmp cannot write, and a
new artifact there would entangle the stale-generation cleanup that
DeusData#1894 just hardened. The record carries the listener detail from the
previous commit (component, stage, errno, path) plus the runtime
directory and a wall-clock stamp, and is written while the lifetime
reservation is still held, so a client that watches the generation
vanish already finds the cause.

The client bootstrap loop reads that record (every 50 ms after its own
spawn, and unconditionally before any relaunch), accepts only records for
its endpoint not older than its spawn instant (2 s skew), and stops at
once:

    error: CBM daemon failed to start: pending_publication failed with
    ENOSPC (No space left on device) at /tmp/cbm-daemon-1000/cbm-<key>.sock.pending.tmp;
    see ~/.cache/codebase-memory-mcp/logs/cbm-daemon.log

The read is an optional bootstrap op (unit contracts with fake ops are
untouched); the production op resolves the cache log directory. Every
other wait state -- a live or stopping generation, a mute holder, a
concurrent starter -- keeps its existing semantics and diagnostics.

Tests (daemon_bootstrap):
- daemon_bootstrap_fails_fast_when_daemon_dies_at_publication: the
  production bootstrap (real cohort, probe, startup lock, handoff) with a
  seam-only spawn override that forks a REAL daemon host whose record
  writes fail with ENOSPC. RED before: 34.7 s and "active or starting";
  GREEN after: FAILED in well under 5 s naming ENOSPC and the temp path,
  one child spawned, daemon log carries errno=ENOSPC path=... .
- daemon_bootstrap_start_failure_record_round_trip: record/read/format
  contract, a stale record and another endpoint's record are ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…sting_db

The route probe opened whatever resolve_db_path returned, which inside
the staged run is the run's own stage. On a first index nothing was
copied into it, so the probe opened a 0-byte mkstemp placeholder, failed
its integrity check, and warned "pipeline.route path=full
reason=invalid_existing_db" -- on every first index of every project.
Read back after an OOM kill it looked like a corrupted database and a
daily "corrupt -> full rebuild -> OOM" loop (DeusData#1864); the loop is "no
database -> full -> OOM", and the stale 0-byte stage never influenced
the next run at all (each run mints a fresh name).

cbm_pipeline_run now records whether the destination existed and was
copied into the stage, and the route decision returns full early with
reason=no_existing_db (or existing_db_backup_failed, after the
backup_failed_full_rebuild warning) without probing a placeholder.
"invalid_existing_db" stays reserved for a real copy that fails its
integrity check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Every config editor (JSON-like, TOML, YAML, text) opens with O_NOFOLLOW
and refuses symlinks, so a link planted at ~/.cursor/mcp.json cannot
point a privileged writer at another file. Users keep exactly those
files as symlinks into a dotfiles checkout; the blanket refusal made
install and uninstall fail on them and, before the previous commit,
turned uninstall into data loss (DeusData#1954).

Decision C: a symlink the INVOKING user owns, resolving to a regular
file the same user owns, is followed — as an explicit opt-in of the
agent-configuration writers only. cbm_install_agent_configs and the
uninstall activation register the user's configuration roots (HOME,
XDG_CONFIG_HOME) for the duration of their work; a link whose own
directory is outside every registered root, or any link seen by another
editor caller, stays refused, so repository paths never follow.

One shared helper (src/cli/config_edit_path.c) runs the race-safe
sequence: lstat(link) → owner == invoking uid (a root process follows
root-owned links only) → realpath → the target's parent directory
opened as a dirfd (O_DIRECTORY|O_NOFOLLOW; owned by the invoking user
or root, not writable by others unless sticky) → openat(dirfd, base,
O_NOFOLLOW) → fstat: regular file, same owner, link count 1, and the
same (dev, ino) as stat(realpath). Each editor reads through that
validated descriptor, stages its temp file with openat(dirfd, …,
O_CREAT|O_EXCL|O_NOFOLLOW), runs its pre-publish snapshot comparison
against the resolved path, and commits with renameat on the pinned
dirfd, so the link itself is never replaced. Every other link is refused
with an observable reason (surfaced in the uninstall diagnostics):
another owner, root over a non-root link, dangling, a directory or
special target, a hard-linked or swapped target, a foreign or
world-writable parent. Whether the target must also stay under a root
is a pending user decision behind CBM_CONFIG_EDIT_TARGET_UNDER_ROOT
(default: anywhere the user owns). Windows never follows reparse points.

Tests: each editor suite gains a same-owner link edited in place under
the opt-in (link preserved and still pointing at the same target, temp
file staged beside the target, bytes identical to the same edit on a
plain file, no stray temp files) and keeps its refusal cases; the
JSON-like suite adds no-opt-in, outside-root, dangling, directory,
identity-skew and world-writable-parent refusals. The foreign-owner and
root-run refusals move the observer through a test seam (a test cannot
create a foreign-owned link without root; a real root run demotes the
link first). test_cli.c drives `uninstall -y` end to end through a
symlinked ~/.cursor/mcp.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Review of the previous head found the global rule unsound for one caller:
cbm_mkdir_p also creates <repo_path>/.codebase-memory inside the checkout
being indexed, and git creates symlinks owned by whoever cloned, so a
repository shipping `.codebase-memory -> ~/.ssh` would have been followed
by the artifact writer. "User-owned" says nothing about "user-intended"
once the path root is a checkout.

cbm_mkdir_p keeps the strict walk (root-owned links only). The new
cbm_mkdir_p_ex(path, mode, policy) with CBM_MKDIR_FOLLOW_OWNED also
follows a symlink owned by the invoking user, and only callers whose
paths are rooted in the user's own configuration opt in: the installer's
agent roots, skills, instruction files and hook directories, the
JSON-like config editor's parent creation, the cache root, <cache>/logs,
the UI config directory and the DB parent. The artifact directory inside
the checkout and the ~/.local/bin creations stay strict; the activation
transaction re-walks the latter with its own no-follow policy anyway.

What a follow lands on is now checked too: the opened target must be a
directory owned by root or the invoking user and not world-writable
unless sticky, so a link swapped between the fstatat and the openat
cannot steer the walk into a directory an attacker controls or one where
anyone can pre-plant entries. This applies to root-owned links as well;
macOS /private/tmp is root-owned and sticky and still passes. Refusal
restores the first openat's errno.

Tests: the plain walk refuses an own link that the opted-in walk
follows; targets that are world-writable without the sticky bit are
refused; as root, a foreign-owned target and a user-owned link are both
refused, each followed by restoring ownership and walking again; the
identical `.codebase-memory -> <user-owned dir>` link is refused by the
plain walk and followed once opted in; cbm_artifact_export refuses a
checked-in artifact-dir link and leaves its target empty; the installer
test gains the euid-0 leg with home, link and target owned by a non-root
uid. Under euid 0 the tests demote their links to a non-root owner, since
root-owned links are trusted by both walks.

Refs DeusData#1722

Signed-off-by: TheStranjer <791672+TheStranjer@users.noreply.github.com>
…ollowed

Review of the previous head reproduced a check-then-use window in the
parent-chain walk: the link was judged by name (fstatat) and then opened
by name (openat), with nothing tying the two to the same entry. An
LD_PRELOAD shim that swaps the link for one pointing elsewhere the moment
the judgement returns made both earlier heads follow the swapped link,
including one owned by a foreign uid under root; the post-follow target
check only bounds where such a swap can land.

The link is now never opened by name after it has been judged. On Linux
an O_PATH|O_NOFOLLOW descriptor pins the inode, fstat on that descriptor
makes the ownership decision and readlinkat(fd, "") reads the target text
from the same inode; on other systems the link is stat'ed by name before
and after the readlinkat and both must be the same inode with the same
owner. The walk then opens the text from the parent directory, so a
relative text resolves against the link's own directory exactly as the
kernel resolves it, and the target check runs on what that opens. The
readlinkat form is used rather than /proc/self/fd so the walk does not
depend on /proc being mounted. What is still done by path is resolving
the text itself, which the target check bounds; the comment says so.

Test: relative, ../, absolute and chained link texts resolve from the
link's directory with the process CWD moved elsewhere. The race itself
is exercised out of tree with the harness from the review thread.

Refs DeusData#1722

Signed-off-by: TheStranjer <791672+TheStranjer@users.noreply.github.com>
…alar-indicators

fix(yaml): scope > | [ ] indicators to the value start so Hermes configs install (DeusData#1924)
…mlinked-config

# Conflicts:
#	tests/test_cli.c
…elf-trigger

fix(watcher): exclude the indexer's own artifact dir from the git dirty signature (DeusData#1953)
…ore-depth

fix(discover): stack every nested .gitignore on a path, not just the shallowest (DeusData#1973)
DeusData and others added 29 commits September 24, 2026 01:21
…me-sanitizer-timeout-only

fix(tests): scale daemon runtime sanitizer timeout
…-return-types

fix(extract): a C-family function's return type keeps its pointer and qualifiers
…reate-race

fix(daemon): losing the runtime-directory creation race is not a failure on Windows
…attern-boundaries

fix(service-patterns): match library ids on identifier boundaries
Sync pkg/scoop/codebase-memory-mcp.json with the Scoop Main bucket
manifest (v0.11.0): add arm64, notes, and matching hashes/URLs.

Signed-off-by: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com>
…rap-sync-spawn

fix(test): the ENOSPC bootstrap test no longer lets a deadline decide its verdict
…ree-tests

test(hook-augment): pin worktree payload-cwd resolution
…e-isolation

fix(test): isolate smoke harness daemon runtime
feat(pipeline): link markdown file references into the graph
…covery-fix

fix(windows): preserve worker recovery errors
Resolve the RUN_TEST list conflict in tests/test_daemon_application.c by keeping both sides' new tests.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com>
Signed-off-by: Mike Parsons <212198286+TheDarkniteFalls@users.noreply.github.com>
`codebase-memory-mcp cli <tool> --flag value` builds the tool's JSON
arguments from typed flags. Array-typed parameters such as
`semantic_query` used to accept only repeated flags; the JSON spelling
shown in the MCP schema (`--semantic-query '["a","b"]'`) was stored as a
single opaque string element (DeusData#2150). `cli_add_typed` now expands such a
literal; this pins the edge cases of that contract so it cannot regress:

- a well-formed array of strings contributes its elements
- an explicit empty array `[]` contributes nothing (not the keyword "[]")
- a malformed literal stays one literal string, without an error
- non-string items are copied as typed values, not stringified
- literals and repeated flags mix, accumulating in argument order
- repeated literals accumulate

Signed-off-by: Hubert Tarnacki <hubert.tarnacki@gmail.com>
fix(routes): accept relative JAX-RS @path templates and compose Scala class-level @path (DeusData#2147)
…zed-build-config

test: verify sanitizer build configuration
…anch-head-regression

test(pipeline): cover changed-file and empty-commit Branch HEAD refresh
test(cli): pin JSON array literal handling for array-typed arguments (DeusData#2150)
…dows-unicode-git-root

fix(windows): support Unicode repository paths
…0.11.0

chore(scoop): bump packaged manifest to 0.11.0
@thientrile
thientrile merged commit 0bca5a4 into main Sep 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.