80d52797f6
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>
282 lines
13 KiB
C
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);
|
|
}
|