Files
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

282 lines
13 KiB
C

/*
* test_workspace.c — workspace boundary policy.
*
* The policy is a pure function over an already-canonicalized path, so these are
* table tests with no filesystem involved. home_dir and cache_dir are injected.
*/
#include "../src/foundation/compat.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "foundation/workspace.h"
#include "foundation/compat_fs.h"
#include <stdio.h>
#include <string.h>
static const char *HOME = "/Users/dev";
static const char *CACHE = "/Users/dev/.cache/codebase-memory-mcp";
TEST(ws_depth_counts_components_below_the_volume) {
ASSERT_EQ(cbm_workspace_path_depth("/"), 0);
ASSERT_EQ(cbm_workspace_path_depth("/etc"), 1);
ASSERT_EQ(cbm_workspace_path_depth("/etc/"), 1);
ASSERT_EQ(cbm_workspace_path_depth("/Users/dev"), 2);
ASSERT_EQ(cbm_workspace_path_depth("/Users//dev///x"), 3);
/* macOS firmlinks: the /private prefix must not inflate depth, or "/etc"
* resolves to "/private/etc" and passes a minimum of two. */
ASSERT_EQ(cbm_workspace_path_depth("/private/etc"), 1);
ASSERT_EQ(cbm_workspace_path_depth("/private/tmp/proj"), 2);
ASSERT_EQ(cbm_workspace_path_depth("/private"), 0);
/* Drive-relative, so an ordinary Windows workspace is one deep. */
ASSERT_EQ(cbm_workspace_path_depth("C:/"), 0);
ASSERT_EQ(cbm_workspace_path_depth("D:/repos"), 1);
ASSERT_EQ(cbm_workspace_path_depth("D:\\repos\\app"), 2);
/* A UNC share root is the share itself. */
ASSERT_EQ(cbm_workspace_path_depth("//srv/share"), 0);
ASSERT_EQ(cbm_workspace_path_depth("//srv/share/proj"), 1);
PASS();
}
TEST(ws_volume_roots_are_absolutely_denied) {
ASSERT_EQ(cbm_workspace_classify_root("/", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("C:/", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("C:\\", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("//srv/share", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
/* "/private" carries no components of its own once the macOS firmlink prefix
* is discounted, so it is a volume root rather than merely shallow. */
ASSERT_EQ(cbm_workspace_classify_root("/private", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_ABSOLUTE));
PASS();
}
/* A relative or empty path is not a usable root and must not fall through as
* allowed just because no rule matched it. */
TEST(ws_non_absolute_paths_are_denied) {
ASSERT_EQ(cbm_workspace_classify_root("", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("relative/path", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root(NULL, HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
PASS();
}
/* One depth rule refuses every POSIX top-level tree without a list to maintain.
* This is the whole reason depth carries its weight. */
TEST(ws_posix_top_level_trees_are_too_shallow) {
static const char *const shallow[] = {"/etc", "/home", "/Users", "/var",
"/opt", "/srv", "/usr", "/private/etc",
"/private/var", "/private/tmp"};
for (size_t i = 0; i < sizeof(shallow) / sizeof(shallow[0]); i++) {
ASSERT_EQ(cbm_workspace_classify_root(shallow[i], HOME, CACHE), CBM_WS_DENY_TOO_SHALLOW);
}
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_TOO_SHALLOW));
PASS();
}
/* Legitimately shallow project roots must survive: these are the false positives
* a blanket depth rule would cause, which is why Windows counts drive-relative. */
TEST(ws_legitimate_shallow_roots_are_allowed) {
ASSERT_EQ(cbm_workspace_classify_root("/opt/sdk", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/srv/protos", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("D:/repos", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("//srv/share/proj", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/dev/app", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
/* $HOME is depth 2 on both macOS and Linux, so depth cannot see it. */
TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/projects", HOME, CACHE), CBM_WS_ALLOW);
/* A sibling that merely shares a prefix is not the home directory. */
ASSERT_EQ(cbm_workspace_classify_root("/Users/developer", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_TRUE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_SENSITIVE));
PASS();
}
/* The enumeration bypass: a credential directory passes every breadth rule, so
* it has to be named. Matched on any component, so subdirectories go too. */
TEST(ws_credential_directories_are_sensitive_at_any_depth) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh/keys", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.aws", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.gnupg", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.kube", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/Library/Keychains", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* A name that merely contains a listed one is a different directory. */
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.sshconfig", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
TEST(ws_windows_system_trees_are_sensitive) {
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/Users", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
/* THE regression that matters on Windows: every user's work lives under
* C:\Users\<name>, so matching "Users" against every component would refuse
* every ordinary project path — including a CI runner's own workspace. Only
* the tree root is refused. */
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/dev/projects/app", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/runneradmin/work/repo", HOME, CACHE),
CBM_WS_ALLOW);
/* System trees are refused at any depth inside them, not just at the root. */
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows/System32", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* A project merely named after one is not one. */
ASSERT_EQ(cbm_workspace_classify_root("C:/dev/Windows-app", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("C:/ProgramData", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/Program Files/app", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* Case-insensitive: NTFS is, so a case-flipped name must not slip past. */
ASSERT_EQ(cbm_workspace_classify_root("C:/WINDOWS", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/users/dev/.SSH", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
PASS();
}
/* POSIX is case-sensitive, so a differently-cased directory is a different one
* and must not be refused. */
TEST(ws_posix_matching_is_case_sensitive) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.SSH", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
/* Absent injected context, the checks that depend on it simply do not fire —
* they must not crash or deny everything. */
TEST(ws_null_context_disables_dependent_checks) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", NULL, NULL), CBM_WS_ALLOW);
/* Depth and volume rules are context-free and still apply. */
ASSERT_EQ(cbm_workspace_classify_root("/etc", NULL, NULL), CBM_WS_DENY_TOO_SHALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/", NULL, NULL), CBM_WS_DENY_ABSOLUTE);
PASS();
}
TEST(ws_every_verdict_has_a_reason) {
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_ALLOW));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE));
/* Reasons are user-facing; a bare enum name would not help anyone. */
ASSERT_TRUE(strlen(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW)) > 20);
PASS();
}
/* ── Per-project request manifest ───────────────────────────────────────── */
static void ws_write(const char *path, const char *body) {
FILE *f = cbm_fopen(path, "wb");
if (f) {
(void)fputs(body, f);
(void)fclose(f);
}
}
TEST(ws_manifest_absent_is_not_an_error) {
char *base = th_mktempdir("cbm_ws_m0");
ASSERT(base != NULL);
cbm_ws_manifest_t m;
ASSERT_TRUE(cbm_workspace_manifest_read(base, &m));
ASSERT_FALSE(m.present);
ASSERT_EQ(m.count, 0);
th_cleanup(base);
PASS();
}
TEST(ws_manifest_parses_entries_and_skips_comments) {
char *base = th_mktempdir("cbm_ws_m1");
ASSERT(base != NULL);
char path[1024];
snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME);
ws_write(path, "# a comment\n/opt/sdk\n\n/srv/protos\n");
cbm_ws_manifest_t m;
ASSERT_TRUE(cbm_workspace_manifest_read(base, &m));
ASSERT_TRUE(m.present);
ASSERT_EQ(m.count, 2);
ASSERT_STR_EQ(m.entries[0], "/opt/sdk");
ASSERT_STR_EQ(m.entries[1], "/srv/protos");
ASSERT_EQ((int)strlen(m.digest), 64);
th_cleanup(base);
PASS();
}
/* A control character is how a crafted entry would smuggle a second value past a
* line reader — the same shape as the newline splitting in the scoped file list. */
TEST(ws_manifest_rejects_control_characters) {
char *base = th_mktempdir("cbm_ws_m2");
ASSERT(base != NULL);
char path[1024];
snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME);
ws_write(path, "/opt/sdk\t\x01evil\n");
cbm_ws_manifest_t m;
ASSERT_FALSE(cbm_workspace_manifest_read(base, &m));
ASSERT_EQ(m.count, 0);
th_cleanup(base);
PASS();
}
/* THE property the design rests on: a manifest grants nothing until a person
* approves it, and editing it lapses that approval rather than inheriting it. */
TEST(ws_manifest_approval_is_keyed_to_content) {
char *base = th_mktempdir("cbm_ws_m3");
char *cache = th_mktempdir("cbm_ws_m3c");
ASSERT(base != NULL);
ASSERT(cache != NULL);
char path[1024];
snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME);
ws_write(path, "/opt/sdk\n");
cbm_ws_manifest_t before;
ASSERT_TRUE(cbm_workspace_manifest_read(base, &before));
/* Unapproved grants nothing. */
ASSERT_FALSE(cbm_workspace_manifest_is_approved(cache, base, &before));
char err[1024];
ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err)));
ASSERT_TRUE(cbm_workspace_manifest_is_approved(cache, base, &before));
/* Widen the requests, as a `git pull` would. Approval must lapse. */
ws_write(path, "/opt/sdk\n/srv/protos\n");
cbm_ws_manifest_t after;
ASSERT_TRUE(cbm_workspace_manifest_read(base, &after));
ASSERT_TRUE(strcmp(before.digest, after.digest) != 0);
ASSERT_FALSE(cbm_workspace_manifest_is_approved(cache, base, &after));
th_cleanup(base);
th_cleanup(cache);
PASS();
}
/* Approving a manifest must not become a route around the breadth policy. */
TEST(ws_manifest_approval_refuses_overbroad_requests) {
char *base = th_mktempdir("cbm_ws_m4");
char *cache = th_mktempdir("cbm_ws_m4c");
ASSERT(base != NULL);
ASSERT(cache != NULL);
char path[1024];
snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME);
ws_write(path, "/etc\n");
char err[1024];
ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err)));
ASSERT_TRUE(strstr(err, "too broad") != NULL);
th_cleanup(base);
th_cleanup(cache);
PASS();
}
SUITE(workspace) {
RUN_TEST(ws_manifest_absent_is_not_an_error);
RUN_TEST(ws_manifest_parses_entries_and_skips_comments);
RUN_TEST(ws_manifest_rejects_control_characters);
RUN_TEST(ws_manifest_approval_is_keyed_to_content);
RUN_TEST(ws_manifest_approval_refuses_overbroad_requests);
RUN_TEST(ws_depth_counts_components_below_the_volume);
RUN_TEST(ws_volume_roots_are_absolutely_denied);
RUN_TEST(ws_non_absolute_paths_are_denied);
RUN_TEST(ws_posix_top_level_trees_are_too_shallow);
RUN_TEST(ws_legitimate_shallow_roots_are_allowed);
RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine);
RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth);
RUN_TEST(ws_windows_system_trees_are_sensitive);
RUN_TEST(ws_posix_matching_is_case_sensitive);
RUN_TEST(ws_null_context_disables_dependent_checks);
RUN_TEST(ws_every_verdict_has_a_reason);
}