Skip to content

feat: declare indexing roots from the CLI; size-safe compression; prune the cache from discovery - #1472

Open
DeusData wants to merge 4 commits into
mainfrom
feat/workspace-grants-and-manifest
Open

feat: declare indexing roots from the CLI; size-safe compression; prune the cache from discovery#1472
DeusData wants to merge 4 commits into
mainfrom
feat/workspace-grants-and-manifest

Conversation

@DeusData

@DeusData DeusData commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Follow-ups to #1464.

allow-root

The grant store landed readable but unwritable, so only CBM_ALLOWED_ROOT could
declare a root. allow-root <path> records one, --list shows what is recorded,
and --approve-sensitive is required for a home or credential directory. Roots
refused outright — volume and drive roots, paths too broad to index as a unit —
cannot be granted at all.

Enrollment is a command a person types and deliberately nothing else, which is
the property the store exists for: neither an indexed repository nor a tool
caller can widen its own boundary. A confirmation returned through the MCP
surface would be answered by the same agent that may have been influenced, so it
would not be a human decision.

Paths are canonicalized before recording, since the policy is defined over
resolved paths and a grant stored as a symlink would not match what the indexer
later presents. Classified stateless in the daemon bootstrap — it writes one line
of user-level config and reads nothing from a project, so enrolling a root must
not depend on daemon state.

Compression sizes

Decompression already took size_t so a >2 GiB capacity could not wrap through
int, but compression still took int for both source length and destination
capacity, and the artifact export cast a size_t database size down to reach it.
Both are size_t now and the function returns int64_t, matching its
decompressing counterpart.

Cache pruning in discovery

A custom CBM_CACHE_DIR may sit inside a repository — tests do it routinely — and
walking into it pulls every other project's graph database into this project's
file list. The walk now prunes it by absolute path. This is the narrow form of a
concern briefly implemented as refusing any root that contained the cache;
refusing a whole root was too blunt, and not walking the cache is what the
concern actually asks for. The test fails without the prune, and cache is not
in the built-in skip list, so it is not vacuous.

…walk

Two loose ends from the bounds work.

The decompression side already took size_t so a >2 GiB capacity could not wrap
through int, but compression still took int for both the source length and the
destination capacity, and the artifact export cast a size_t database size down
to reach it. A database past 2 GiB would have handed the encoder a negative
length. Both lengths and the bound helper are size_t now, and the function
returns int64_t like its decompressing counterpart.

The discovery walk now prunes the cache directory by absolute path. A custom
CBM_CACHE_DIR may sit inside a repository — tests do it routinely — and walking
into it pulls every other project's graph database into this project's file
list. This is the narrow form of a concern that was briefly implemented as
refusing any root that contained the cache; refusing a whole root was too blunt,
and not walking the cache is what the concern actually asks for.

The test fails without the prune: a .go file planted under the cache is
otherwise discovered, and "cache" is not in the built-in skip list, so the
assertion is not vacuous.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The grant store was readable but nothing could write to it, so only
CBM_ALLOWED_ROOT could declare a root. `allow-root <path>` records one,
`--list` shows what is recorded, and `--approve-sensitive` is required for a
home or credential directory. Roots that are refused outright — volume and
drive roots, paths too broad to index as a unit — cannot be granted at all.

Enrollment is a command a person types and deliberately nothing else. That is
the property the whole store exists for: neither an indexed repository nor a
tool caller can widen its own boundary. A confirmation delivered through the
MCP surface would be answered by the same agent that may have been influenced,
so it would not be a human decision.

The path is canonicalized before recording, because the policy is defined over
resolved paths and a grant stored as a symlink would not match the resolved
path the indexer later presents.

Classified stateless in the daemon bootstrap: it writes one line of user-level
config and reads nothing from a project, so enrolling a root must not depend on
daemon state. Without that classification the argument fell through to MCP
server mode and exited silently on EOF.

Verified end to end: /etc refused as too broad, $HOME refused pending the flag,
a project directory recorded, a granted root indexes, and a non-granted root is
refused naming the exact command that would allow it.

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

A repository may ship .cbmpathwhitelist listing outside roots it would like
indexed alongside it. The file REQUESTS; it never GRANTS. Both attackers in this
project's threat model can write a file inside a repository — a malicious indexed
repo, and an agent with write access to the project — so a repo-local file cannot
be authoritative without handing them the boundary.

Approval is recorded user-level and keyed to a SHA-256 of the manifest bytes, so
editing the file lapses approval and it must be granted again. Without the hash a
repo approved once could widen itself forever on a later pull. This is direnv's
model, and it is the only shape that keeps a checked-in file's ergonomics without
making the file a permission slip. The hash covers the raw bytes rather than the
parsed entries: a reordering or comment change is still a change the approver has
not seen.

`allow-root --approve-manifest <project>` is the human action that grants.
Approval refuses outright if any requested entry would not stand on its own as an
indexing root, so approving cannot become a route around the breadth policy, and
entries are re-classified at use time as well so a stored approval cannot outrank
a credential list that has grown since.

Entries containing control characters are rejected — the same shape as the
newline splitting that let a crafted indexed path inject an extra entry into the
scoped file list.

Deliberately NOT wired into the root boundary. A project's manifest authorizes
outside roots FOR THAT PROJECT, so the question is "may project P pull in tree T",
not "may T be indexed standalone", and the boundary check only ever sees one path
with no project context to ask that with. A first draft did wire it there by
passing the candidate as its own project root, which read a manifest that by
definition was not the one requesting it and so authorized nothing — caught by
exercising it end to end. The consuming half belongs where project context exists,
in discovery walking a project's approved extra roots;
cbm_workspace_manifest_allows is the query it will use.

Five tests, including the one the design rests on: approval lapses when the
manifest content changes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Three language fixtures assign cbm_project_name_from_path into lp->project each
time they open an indexed store. Teardown frees the last one, so a fixture that
indexes more than once dropped every earlier heap name — 68 allocations across the
run, which the macOS leak lane reports at process exit.

Pre-existing rather than introduced here: none of these files or fqn.c are in this
branch's diff, and the leak lane passed on the previous PR. Adding a suite shifted
what the leak job sees, which is how it surfaced. Fixing it here rather than
recording it, since a gate that only stays green by composition accident is not a
gate.

Freeing before reassigning also makes the ownership obvious at the point it
matters, instead of relying on the reader noticing the teardown three functions
away.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
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.

1 participant