Files
codebase-memory-mcp/src/foundation/workspace.h
T
Martin VogelandMark Murawski 3e2de74e98 feat(workspace): record broad approvals with allow-root --approve-broad
Replace the PATH_ALLOW_BROAD environment variable with a recorded grant,
the shape agreed in review: `codebase-memory-mcp allow-root
--approve-broad <path>` records an explicit human approval for one root
refused as too broad (CBM_WS_DENY_TOO_SHALLOW), mirroring
--approve-sensitive end to end.

- cbm_workspace_grant_add() takes approve_broad next to
  approve_sensitive. Each flag lifts only its own verdict, absolute
  denials stay unliftable, and a refusal names the flag that would
  lift it.
- The approval is stored with a '^' marker, listed by allow-root --list
  as "(approved broad)", and honoured by cbm_workspace_root_allowed()
  only for CBM_WS_DENY_TOO_SHALLOW and only on an exact path match,
  keeping the exact-match rule of the original change.
- cbm_workspace_root_allowed() returns to its previous signature. Every
  caller (MCP index_repository and index-job start, auto-index, daemon
  sessions, the HTTP UI) reads the same grant store, so stdio and
  daemon sessions decide identically and nothing depends on the
  environment a daemon was started with.
- The too-broad refusal names `allow-root --approve-broad <path>`, a
  command a person runs.
- cbm_workspace_classify_root() and cbm_workspace_verdict_is_overridable()
  are unchanged.

The regression test keeps its name and its exact-match, sibling,
absolute and sensitive checks, now against the grant store, and adds
that a broad marker never lifts a sensitive verdict.

Co-authored-by: Mark Murawski <github@kobaz.net>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-20 19:26:31 +02:00

170 lines
8.9 KiB
C

#ifndef CBM_FOUNDATION_WORKSPACE_H
#define CBM_FOUNDATION_WORKSPACE_H
#include <stdbool.h>
#include <stddef.h>
/*
* Workspace boundary policy — classify a directory as an indexing root.
*
* This is the breadth half of the boundary: it rejects roots that are too broad
* or too sensitive to be indexed as a unit. It is NOT the authorization half.
* Whether a caller may index a given tree at all is answered by the user-level
* grant store, because that is the only input a caller cannot write. A verdict
* of CBM_WS_ALLOW here means "this path is not obviously too broad", never
* "this path is authorized".
*
* Every function takes an ALREADY-CANONICALIZED absolute path. Classifying
* before symlink resolution lets `/a/b/c/d/e/link -> /` present as a deep path
* and launder straight through, so callers must resolve first.
*
* home_dir and cache_dir are passed in rather than read from the environment so
* the policy is a pure function and can be tested without touching the host.
* Either may be NULL, which disables the checks that depend on it.
*/
typedef enum {
CBM_WS_ALLOW = 0,
/* Fewer path components than the platform minimum — e.g. "/etc", "/home".
* Lifted only for one exact path by a recorded broad approval
* (allow-root --approve-broad), never by a manifest or a tool call. */
CBM_WS_DENY_TOO_SHALLOW,
/* A filesystem root, a drive root, a UNC share root, or a path that is or
* contains the cbm cache directory. Never overridable. */
CBM_WS_DENY_ABSOLUTE,
/* The home directory itself, or a credential/system directory. Overridable
* by an explicit human action, never by a manifest or a tool call. */
CBM_WS_DENY_SENSITIVE,
} cbm_ws_verdict_t;
/* Classify canonical_path as a candidate indexing root. */
cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const char *home_dir,
const char *cache_dir);
/* Stable, human-facing reason for a verdict. Never NULL. */
const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict);
/* True when a sensitive approval (allow-root --approve-sensitive) may override
* this verdict. Absolute denials never are. A shallow path is not overridable
* in this sense either: only an exact broad approval lifts it, see
* cbm_workspace_grant_add. */
bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict);
/* Number of path components below the volume root, ignoring repeated and
* trailing separators. "/" is 0, "/etc" is 1, "C:/repos" is 1, "//srv/share" is
* 0 (the share root itself). Exposed for tests. */
int cbm_workspace_path_depth(const char *canonical_path);
/* Canonical containment check: is abs_path at or below root_path, after symlink
* and junction resolution? The definition currently lives in src/mcp/mcp.c; it is
* declared here so the MCP handler, the HTTP UI route and the CLI all reach the
* same implementation. Two entry points evaluating the same policy separately is
* what produced the UI/MCP divergence in the first place. */
bool cbm_path_within_root(const char *root_path, const char *abs_path);
/*
* ── Grant store ──────────────────────────────────────────────────────────────
*
* The authorization half. Grants live in a user-level file under the cache
* directory and are only ever written by an explicit human action (the CLI), so
* neither an indexed repository nor a tool caller can widen its own boundary.
*
* Enforcement is deliberately two-tier, because default-deny with an empty store
* would refuse every first run:
*
* - The breadth policy above is ALWAYS enforced. Out of the box that already
* refuses "/", "/etc", "$HOME", "~/.ssh" and friends — the paths the reports
* actually demonstrate — with no configuration at all.
* - Containment in a granted root is enforced ONLY once the store is non-empty
* or a root is configured. Declaring your first root is therefore what turns
* on true confinement, and it never silently loosens: adding a root can only
* narrow what was previously unrestricted.
*/
/* Absolute path of the grant file for a cache directory. */
bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz);
/* Record canonical_path as an allowed root. approve_sensitive records an explicit
* human override for a CBM_WS_DENY_SENSITIVE path; approve_broad records one for a
* CBM_WS_DENY_TOO_SHALLOW path. Each flag lifts only its own verdict, and only
* for exactly this path. Absolute denials are never grantable. Returns false and
* fills err on refusal. */
bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir,
const char *canonical_path, bool approve_sensitive, bool approve_broad,
char *err, size_t err_sz);
/* Newline-separated granted roots into out. True when at least one exists. */
bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz);
/* The whole decision, used by every entry point that accepts a repo path.
* canonical_path must already be canonicalized. configured_root is the legacy
* CBM_ALLOWED_ROOT / session policy, treated as an additional grant, or NULL.
* On refusal, err receives a message that names the command which would fix it. */
bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir,
const char *cache_dir, const char *configured_root, char *err,
size_t err_sz);
/* The home and cache directories the policy should be evaluated against. Shared
* so two callers cannot derive them differently and reach different verdicts for
* the same path. Either may return NULL. */
const char *cbm_workspace_home_dir(void);
const char *cbm_workspace_cache_dir(void);
#endif /* CBM_FOUNDATION_WORKSPACE_H */
/*
* ── Per-project request manifest (.cbmpathwhitelist) ─────────────────────────
*
* A repository may ship a manifest 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 project write access — 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 (a `git pull` that widens the requests, say) lapses the
* approval and asks again. Without the hash, a repo approved once could widen
* itself forever. This is direnv's model, and it is the only shape that keeps the
* ergonomics of a checked-in file without making the file a permission slip.
*/
#define CBM_WS_MANIFEST_NAME ".cbmpathwhitelist"
enum { CBM_WS_MANIFEST_MAX_ENTRIES = 64 };
typedef struct {
/* Requested roots, verbatim from the file (already control-char screened). */
char entries[CBM_WS_MANIFEST_MAX_ENTRIES][1024];
int count;
/* SHA-256 hex of the raw file bytes; empty when there is no manifest. */
char digest[65];
bool present;
} cbm_ws_manifest_t;
/* Read <project_root>/.cbmpathwhitelist. Returns false only on a malformed file
* (an entry containing control characters, or more entries than the cap); a
* missing file is success with present=false. */
bool cbm_workspace_manifest_read(const char *project_root, cbm_ws_manifest_t *out);
/* True when this exact manifest content has been approved for this project. A
* changed manifest is a different digest and so is not approved. */
bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *project_root,
const cbm_ws_manifest_t *manifest);
/* Record approval for the manifest currently on disk. Each entry is judged in
* its resolved form (links followed, `..` collapsed), since that is the
* directory that would be indexed; an entry that is relative, does not exist
* or is not a directory is refused, as is one that would not be allowable as a
* root on its own — approving a manifest must not become a way around the
* breadth policy. err names the entry as written and, when it differs, the
* directory it resolves to. */
bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir,
const char *project_root, char *err, size_t err_sz);
/* True when candidate is at or below an APPROVED manifest entry of project_root.
* Entries are resolved and classified again here: approval binds to the
* manifest text, and where an entry leads is read at the time it is used. An
* entry that no longer resolves matches nothing. */
bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir,
const char *project_root, const char *candidate);