Commit Graph
5 Commits
Author SHA1 Message Date
Martin Vogel 787f87d81c fix: block sensitive Windows program roots
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-27 04:40:26 +02:00
Martin Vogel 80d52797f6 feat(foundation): per-project request manifest with content-keyed approval
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>
2026-08-06 18:00:08 +02:00
Martin Vogel c6478790d8 fix(foundation): match Windows system trees by first component only
"Users" was matched the way credential directory names are — against every
path component. On Windows every user's files live under C:\Users\<name>, so
that refused every ordinary project path, including a CI runner's own
workspace. The Windows smoke job on the pull request caught it; the unit
tests could not, because they only ever run compiled on macOS here.

System trees (Windows, ProgramData, Program Files) now match only as the
first component below the drive, which still refuses anything inside them at
any depth. "Users" is handled separately as the tree root only, mirroring
POSIX where "/Users" is refused but "/Users/dev/projects" is not.

Tests cover the regression directly: C:/Users/dev/projects/app and
C:/Users/runneradmin/work/repo are allowed, C:/Users is not, C:/Windows/System32
is refused at depth, and C:/dev/Windows-app — merely named after a system
tree — is allowed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 01:58:22 +02:00
Martin Vogel 7fa5b07718 feat: enforce one workspace boundary on every indexing entry point
Routes the MCP index_repository handler and the graph UI's POST /api/index
through a single decision function. The UI route previously checked only that
root_path was a directory, so an operator's configured boundary held on one
entry point and not the other; it now canonicalizes first and applies the same
policy, answering 403 with the reason.

The decision is two-tier, because a bare default-deny would refuse every
first run and a bare opt-in leaves the default open:

- Breadth is always enforced, with nothing configured. Filesystem, drive and
  share roots, top-level system trees, the home directory itself and
  credential directories are refused as indexing roots out of the box.
- Containment in a declared root applies once CBM_ALLOWED_ROOT is set or a
  grant exists, and is evaluated first so a path outside a configured root is
  reported as exactly that.

Three things the tests caught, each a real defect rather than a test fix:

- On macOS /etc, /tmp and /var are firmlinked under /private, so
  canonicalizing "/etc" yields "/private/etc" and counted two deep — sailing
  past a minimum of two, missing the very path being guarded. Depth now
  discounts a leading "private" component.
- An earlier draft refused any root containing the cache directory. That was
  over-claimed: the indexer only parses recognised source files and a graph
  database is binary SQLite it would never extract. Refusing a whole root is
  also the wrong remedy where the concern does hold — not walking the cache
  is. Removed, with the reasoning recorded at the site.
- Rewording the refusal to "outside every allowed root" broke an assertion
  matching "outside the allowed root", and that test's early return skipped
  its CBM_ALLOWED_ROOT cleanup, leaking the variable into every later test in
  the suite. The original wording is kept and guidance appended instead.
  Worth remembering: these contracts match strings, not properties.

Docs updated in the same change, since both env-var tables said "unset
imposes no restriction" and that is no longer true: CONFIGURATION.md and
README.md describe the two tiers, and CONFIGURATION.md lists the always-
refused roots along with the two limits that matter — this constrains scope
rather than sensitivity, and the credential list is a denylist that raises the
cost of a mistake rather than closing the class. SECURITY.md's supported-
versions table was still on 0.8.x and now reads 0.9.x.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 01:02:02 +02:00
Martin Vogel 03e935576b feat(foundation): classify indexing roots by breadth and sensitivity
Adds cbm_workspace_classify_root, the breadth half of the workspace
boundary. It answers "is this path obviously too broad or too sensitive to
index as one root", not "is this caller allowed to index it" — that second
question needs the user-level grant store, since it is the only input a
caller cannot write. The header says so, because a reader who mistakes one
for the other would draw the wrong conclusion about what is enforced.

Three rules, each doing what the others cannot:

- Depth below the volume. Two components on POSIX refuses every top-level
  tree in one rule with no list to maintain. Windows and UNC count
  drive/share-relative and require one, because there the first component is
  already user space ("D:/repos") — the system trees that sit at the same
  depth ("C:/Windows") are covered by name instead, which is tractable
  because that set is small and stable.
- The home directory itself, which is two deep on macOS and Linux and so is
  invisible to depth.
- Credential directory names, matched against every component so both
  "…/.ssh" and "…/.ssh/keys" are refused. This list is deliberately additive
  so extending it needs no design authority.

A path that is or contains the cache directory is refused outright: indexing
such a tree would pull every other project's graph database into this
project's index.

Classification order is load-bearing and commented as such. The home
directory normally contains the cache directory, so testing the cache first
reported every home as "holds the cache" and made it non-overridable, which
contradicts the intent that a person may override it. Depth precedes the
cache test for the same reason: "/Users" satisfies both and "too broad" is
the reason that helps the reader.

Takes home and cache as parameters rather than reading the environment, so
the policy is a pure function and the tests need no filesystem. Twelve table
tests; three of them failed on the first implementation and drove the
ordering fix above.

Not yet wired to any caller — deliberately, so the policy can be reviewed on
its own.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 00:11:37 +02:00