diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index b0e4c7bee..c80c71f63 100644 --- a/.github/workflows/sandbox-isolation.yml +++ b/.github/workflows/sandbox-isolation.yml @@ -234,8 +234,8 @@ jobs: executed=$(grep -oE 'executed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+') passed=$(grep -oE 'passed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+') echo "executed=${executed:-0} passed=${passed:-0}" - if [ "${executed:-0}" -lt 102 ]; then - echo "::error::Expected >=102 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a repository whose memory links outside the workspace left out of a real Claude run, against the CLI following the link once that repository is added + nested memory loaded in place by a real Claude run, single- and multi-repo, and none of it past the in-place budget + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control + a repository clean filter the capture runs with its egress severed + a real Codex agent that tampers its clone's .git while the platform publishes the branch from a clean repo, its Claude counterpart running in the non-root lane), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR." + if [ "${executed:-0}" -lt 107 ]; then + echo "::error::Expected >=107 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a repository whose memory links outside the workspace left out of a real Claude run, against the CLI following the link once that repository is added + nested memory loaded in place by a real Claude run, single- and multi-repo, and none of it past the in-place budget, where a read below a directory attaches only that directory's pointer + a path-scoped rule's pointer reaching a real Claude run only after a read its glob covers, single- and multi-repo, carrying none of the rule's text, against the rule copied into the config home as its control + pointers attaching on exactly the reads the unpinned CLI attached the repository's own rules on, single- and multi-repo + an Explore subagent pointed at nested memory loaded in place, which the CLI keeps out of its context + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control + a repository clean filter the capture runs with its egress severed + a real Codex agent that tampers its clone's .git while the platform publishes the branch from a clean repo, its Claude counterpart running in the non-root lane), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR." exit 1 fi @@ -268,12 +268,14 @@ jobs: print(f'All {len(arms)} reviewer E2E arms ran and passed.') # The repository-config E2E is armed by the same CLI pins and returns early the same way; require each arm's marker. - for arm in ('claude-code single-repo Confined', 'claude-code multi-repo Confined', 'memory-link-outside claude-code multi-repo Confined', 'nested-in-place claude-code single-repo Confined', 'nested-in-place claude-code multi-repo Confined', 'nested-over-budget claude-code single-repo Confined', 'codex-cli single-repo', 'codex-cli multi-repo', 'codex-cli scratch'): + repo_config_arms = ('claude-code single-repo Confined', 'claude-code multi-repo Confined', 'memory-link-outside claude-code multi-repo Confined', 'nested-in-place claude-code single-repo Confined', 'nested-in-place claude-code multi-repo Confined', 'nested-over-budget claude-code single-repo Confined', 'scoped-rule-pointer claude-code single-repo Confined', 'scoped-rule-pointer claude-code multi-repo Confined', 'pointer-differential claude-code single-repo Confined', 'pointer-differential claude-code multi-repo Confined', 'nested-subagent-pointer claude-code single-repo Confined', 'codex-cli single-repo', 'codex-cli multi-repo', 'codex-cli scratch') + for arm in repo_config_arms: assert f'[repo-config-e2e] ran {arm}' in text, f'repository-config E2E arm "{arm}" did not run — check CODESPACE_REQUIRE_REVIEW_CLIS and the CLI install step' - for method, rows in (('A_claude_run_ignores_the_settings_its_repository_commits_and_still_reads_its_memory', 1), ('A_multi_repo_claude_run_reads_every_repositorys_memory_and_none_of_its_settings', 1), ('A_claude_run_leaves_out_repository_memory_that_links_outside_the_workspace', 1), ('A_claude_run_reads_nested_memory_in_place_and_nothing_it_must_not', 2), ('A_claude_run_over_the_in_place_budget_loads_no_nested_memory_up_front', 1), ('A_codex_run_ignores_the_config_and_hooks_its_repository_commits_and_still_reads_its_agents_md', 1), ('A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it', 1), ('A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository', 1)): + repo_config_methods = (('A_claude_run_ignores_the_settings_its_repository_commits_and_still_reads_its_memory', 1), ('A_multi_repo_claude_run_reads_every_repositorys_memory_and_none_of_its_settings', 1), ('A_claude_run_leaves_out_repository_memory_that_links_outside_the_workspace', 1), ('A_claude_run_reads_nested_memory_in_place_and_nothing_it_must_not', 2), ('A_claude_run_over_the_in_place_budget_loads_no_nested_memory_up_front', 1), ('An_explore_subagent_is_pointed_at_nested_memory_loaded_in_place', 1), ('A_scoped_rule_reaches_the_model_only_after_a_read_it_matches', 2), ('Pointer_rules_attach_where_the_unpinned_cli_attached_project_rules', 2), ('A_codex_run_ignores_the_config_and_hooks_its_repository_commits_and_still_reads_its_agents_md', 1), ('A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it', 1), ('A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository', 1)) + for method, rows in repo_config_methods: cases = [r for r in results if 'RepositoryConfigE2ETests.' + method in r.get('testName', '')] assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass' - print('All 9 repository-config E2E arms ran and passed.') + print(f'All {sum(rows for _, rows in repo_config_methods)} repository-config E2E cases and {len(repo_config_arms)} markers ran and passed.') # The goal-channel E2E is armed by the same CLI pins and returns early the same way; require each arm's marker, # confined, so the positive control it checks against is this lane's posture and not an unconfined one. @@ -414,9 +416,9 @@ jobs: root = ET.parse(path).getroot() counters = root.find('.//{*}Counters') executed, passed = int(counters.get('executed')), int(counters.get('passed')) - assert executed >= 18 and passed == executed, f'expected all 18 non-root arms to run and pass, got executed={executed} passed={passed}' + assert executed >= 19 and passed == executed, f'expected all 19 non-root arms to run and pass, got executed={executed} passed={passed}' text = open(path, encoding='utf-8').read() - for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654', '[repo-config-e2e] ran non-root claude-code own-stop-hook sealed-egress Standard uid=1654 confined=True', '[repo-config-e2e] ran non-root nested-in-place claude-code single-repo Standard uid=1654', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True', '[publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True'): + for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654', '[repo-config-e2e] ran non-root claude-code own-stop-hook sealed-egress Standard uid=1654 confined=True', '[repo-config-e2e] ran non-root nested-in-place claude-code single-repo Standard uid=1654', '[repo-config-e2e] ran non-root scoped-rule-pointer claude-code single-repo Standard uid=1654', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True', '[publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True'): assert marker in text, f'non-root arm marker "{marker}" is missing — the arm returned early or did not run as the worker uid' print(f'All {executed} non-root arms ran as uid 1654 and passed.') PY diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs index fdb7b871b..464bbb7f2 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs @@ -91,10 +91,10 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IAgentHarnessBinary, IAge /// Claude Code's switch that loads every --add-dir directory's memory: its CLAUDE.md, its /// .claude/CLAUDE.md, each .claude/rules file WITHOUT a paths: frontmatter, and the in-repository /// files any of those or of its scoped rules @-import. A rule scoped by paths: never loads from an added - /// directory itself, not even once the run opens a file it covers. This is the one project-memory route the pinned - /// CLI's loader does not gate on the project setting source, which is how a run pinned to - /// --setting-sources user keeps the repository's memory (see ). Pinned by a - /// test (Rule 8). + /// directory itself, not even once the run opens a file it covers; a pointer rule in the run's config home names it + /// instead (). This is the one project-memory route the pinned CLI's loader does + /// not gate on the project setting source, which is how a run pinned to --setting-sources user keeps + /// the repository's memory (see ). Pinned by a test (Rule 8). /// public const string AdditionalDirectoriesMemoryEnvVar = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"; @@ -269,11 +269,13 @@ public SandboxSpec BuildInvocation(AgentTask task) McpDeclarationArgs = McpConfigArgs, // Project the persona's skills as SKILL.md files the runner writes under CLAUDE_CONFIG_DIR/skills//; // Claude Code's native loader discovers them there (personal scope) and does the progressive disclosure. - // On a CONTINUE the prior session's transcript is restored alongside them (see BuildConfigHomeFiles). - ConfigHomeFiles = BuildConfigHomeFiles(task), + // On a CONTINUE the prior session's transcript is restored alongside them, and the repository's scoped rules and + // nested memory are pointed at from there (see BuildConfigHomeFiles). + ConfigHomeFiles = BuildConfigHomeFiles(task, memory.Pointers), // The agent reaches the network only when its permissions allow it (the sandbox severs egress otherwise). AllowNetwork = task.Permissions.Network == AgentNetworkAccess.On, - // Memory left out — linked outside the workspace, or nested past the in-place budget — the run's timeline says so. + // Memory left out — linked outside the workspace, nested past the in-place budget, a rule no pointer can carry — + // the run's timeline says so. LaunchNotices = memory.Notices, }; } @@ -336,13 +338,14 @@ private static void EnsureGoal(string goal) /// The config-home files the runner materializes: the persona's projected skills, PLUS — on a CONTINUE — the prior /// session's restored transcript at projects/<sanitized-cwd>/<sessionId>.jsonl where /// claude --resume reads it, PLUS — when the task carries a real acceptance contract — the P3.3 in-loop - /// Stop hook (the generated script + a settings.json wiring it to hooks.Stop). Each addition is - /// purely additive and independently gated, so a run using none of them returns the bare skills list unchanged - /// (byte-identical). The transcript-restore cwd encoding is the SHARPEST hazard (see - /// ): it must be the resolved cwd the process runs in, which the producer - /// slice supplies. + /// Stop hook (the generated script + a settings.json wiring it to hooks.Stop), PLUS the + /// to the repository's scoped rules and nested memory under rules/ + /// (), which hold runner text only. Each addition is purely additive and + /// independently gated, so a run using none of them returns the bare skills list unchanged (byte-identical). The + /// transcript-restore cwd encoding is the SHARPEST hazard (see ): it must be the + /// resolved cwd the process runs in, which the producer slice supplies. /// - private static IReadOnlyList BuildConfigHomeFiles(AgentTask task) + private static IReadOnlyList BuildConfigHomeFiles(AgentTask task, IReadOnlyList pointers) { var files = SkillProjection.ToConfigHomeFiles(task.Skills, SkillsRoot).ToList(); @@ -368,6 +371,8 @@ private static IReadOnlyList BuildConfigHomeFiles(AgentTask task files.Add(new ConfigHomeFile { RelativePath = "settings.json", Content = StopHookSettingsJson }); } + files.AddRange(pointers); + return files; } @@ -649,13 +654,16 @@ private static void AddGatewayModelTiers(Dictionary env, AgentTa /// CLAUDE.local.md and the output style its settings select stay out for good: a skill's or command's body /// runs shell once invoked and a skill's frontmatter runs hooks, an agent's frontmatter can set its own permission /// mode, hooks and MCP servers, CLAUDE.local.md is a developer's untracked file by convention, and an output - /// style replaces the CLI's own instructions in the system prompt. A rule scoped by paths:, which the unpinned CLI - /// attached once the run opened a file it covers, is lost as well. A subdirectory's own memory, which it attached the - /// same way, comes back in place instead: an --add-dir naming the subdirectory loads its CLAUDE.md, its - /// .claude/CLAUDE.md, its rules without paths: and what its rules import before the first request, and - /// reads no settings from it either. Every nested directory that holds such memory is added after the workspace and - /// its repositories, shallowest first, when all of it together, imports included, fits the in-place budget; past it - /// none is (). + /// style replaces the CLI's own instructions in the system prompt. A subdirectory's own memory, which the unpinned CLI + /// attached once the run opened a file below it, comes back in place instead: an --add-dir naming the + /// subdirectory loads its CLAUDE.md, its .claude/CLAUDE.md, its rules without paths: and what its + /// rules import before the first request, and reads no settings from it either. Every nested directory that holds such + /// memory is added after the workspace and its repositories, shallowest first, when all of it together, imports + /// included, fits the in-place budget; past it none is. A rule scoped by paths:, which the unpinned CLI attached + /// once the run opened a file it covers, and every nested directory come back one Read away too: a pointer rule in the + /// run's config home, attached by the CLI on the same reads, names the files to read and carries no repository text + /// (). Past the budget that is how a directory's memory reaches the run at all; in + /// place it is how it reaches an Explore or Plan subagent, which the CLI starts without project memory. /// RepositoryConfigE2ETests pins what loads and what does not against the real binary. --add-dir is variadic; /// every flag that follows it terminates the list. /// diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.Rebase.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.Rebase.cs new file mode 100644 index 000000000..d331f56d3 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.Rebase.cs @@ -0,0 +1,70 @@ +using System.Text.RegularExpressions; + +namespace CodeSpace.Core.Services.Agents.Harnesses.Claude; + +/// +/// A scoped rule's globs as a pointer rule in the run's config home carries them (). +/// The CLI matches a project rule's globs against the path of the file read, relative to the directory that holds the +/// rule's .claude, and a user rule's against the path relative to the run's cwd — both with the ignore +/// library's gitignore semantics. So a glob is rebased onto the cwd the way gitignore anchors it: as written when the +/// rule's directory is the cwd; below that directory when a slash anywhere but at its end anchors it there; at any depth +/// below it when nothing does. A leading ! keeps negating. Observed against 2.1.263, where the unpinned CLI +/// attaching the rule and the pinned one attaching its pointer agree read for read (RepositoryConfigE2ETests). +/// +/// Only what the runner can re-emit exactly is rebased: letters, digits and . _ + - / * ?, with one +/// leading !, no empty, . or .. segment. Nothing else is needed to write a glob the CLI reads +/// back as it is — no quote, backslash, space, brace, comma, colon, #, [ — and no @, which a +/// pointer never holds anywhere. A rule with any other glob gets no pointer at all: dropping only that glob could drop a +/// negation and widen what the rule covers. +/// +internal static partial class ClaudeRuleScope +{ + /// What a glob a pointer carries may hold: one leading !, then letters, digits and . _ + - / * ?. + [GeneratedRegex(@"^!?[A-Za-z0-9._+\-/*?]+\z")] + private static partial Regex SafeGlob(); + + /// What the directory a glob is rebased below may be, relative to the cwd: segments of letters, digits and . _ + -. + [GeneratedRegex(@"^[A-Za-z0-9._+\-]+(/[A-Za-z0-9._+\-]+)*\z")] + private static partial Regex SafeDirectory(); + + /// + /// , read from a rule whose .claude sits in the directory the + /// cwd (empty for the cwd itself), as a glob that matches the same files relative to the cwd; null when either holds + /// what a pointer cannot carry exactly. + /// + public static string? Rebase(string below, string glob) + { + if (!IsSafe(glob) || (below.Length > 0 && !IsSafeDirectory(below))) return null; + + if (below.Length == 0) return glob; + + var negation = glob.StartsWith('!') ? "!" : ""; + var pattern = glob[negation.Length..]; + + return negation + (IsAnchored(pattern) ? $"/{below}/{pattern.TrimStart('/')}" : $"/{below}/**/{pattern}"); + } + + /// + /// A pointer rule's frontmatter for : each one a double-quoted list item. A glob that ends in + /// /** is written with one more, because the CLI drops one from what it reads (), so + /// of the frontmatter gives back exactly . + /// + public static string Frontmatter(IEnumerable globs) => + "---\npaths:\n" + string.Concat(globs.Select(glob => $" - \"{(glob.EndsWith("/**", StringComparison.Ordinal) ? glob + "/**" : glob)}\"\n")) + "---\n"; + + /// Whether the glob holds only what a pointer re-emits exactly, and no empty, . or .. segment; one leading and one trailing slash are gitignore's own anchor and directory marks. + private static bool IsSafe(string glob) + { + if (!SafeGlob().IsMatch(glob)) return false; + + var pattern = glob.TrimStart('!'); + var inner = pattern[(pattern.StartsWith('/') ? 1 : 0)..]; + + return (inner.EndsWith('/') ? inner[..^1] : inner).Split('/').All(segment => segment.Length > 0 && segment != "." && segment != ".."); + } + + private static bool IsSafeDirectory(string below) => SafeDirectory().IsMatch(below) && below.Split('/').All(segment => segment != "." && segment != ".."); + + /// gitignore's rule, as the ignore library applies it: a slash anywhere but at the end ties the pattern to its directory; otherwise it matches at any depth. + private static bool IsAnchored(string pattern) => pattern.TrimEnd('/').Contains('/'); +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.cs index 2dee5653c..0fe26f978 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeRuleScope.cs @@ -70,9 +70,16 @@ internal static partial class ClaudeRuleScope if (!fence.Success) return null; var frontmatter = fence.Groups[1].Value; - var globs = Expand(Strings(PathsOf(frontmatter), frontmatter.Length)).Select(glob => glob.EndsWith("/**", StringComparison.Ordinal) ? glob[..^3] : glob).Where(glob => glob.Length > 0).ToList(); - return globs.Count == 0 || globs.All(glob => glob == "**") ? null : globs; + return Normalise(Expand(Strings(PathsOf(frontmatter), frontmatter.Length))); + } + + /// The CLI's last step on a rule's globs (dgs in 2.1.263): a trailing /** dropped, an empty glob with it; null — unconditional — when none is left or nothing but **. + public static IReadOnlyList? Normalise(IEnumerable globs) + { + var normalised = globs.Select(glob => glob.EndsWith("/**", StringComparison.Ordinal) ? glob[..^3] : glob).Where(glob => glob.Length > 0).ToList(); + + return normalised.Count == 0 || normalised.All(glob => glob == "**") ? null : normalised; } /// Whether , the start of a rule, opens a fence it does not close — its opening line cut included — so that more of the rule could still change what makes of it. diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Budget.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Budget.cs index 91af6e878..04f1f2777 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Budget.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Budget.cs @@ -80,8 +80,8 @@ private sealed record Check(string? Escape, long ImportedBytes); /// /// The outside-link guard of one build: whether a directory's memory reaches outside the physical /// or cannot be checked, and why — each directory checked once against the build's - /// . The workspace is resolved by the same walker as everything its memory reaches, so the - /// two sides of the comparison cannot disagree on a link. + /// , whichever route asks, an --add-dir or a pointer. The workspace is resolved by the + /// same walker as everything its memory reaches, so the two sides of the comparison cannot disagree on a link. /// private sealed class Guard(string workspace, Budget budget) { diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Nested.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Nested.cs index 466d4f953..715e79684 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Nested.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Nested.cs @@ -12,18 +12,22 @@ namespace CodeSpace.Core.Services.Agents.Harnesses.Claude; /// against 2.1.263). So every such directory is added after the workspace and its repositories, shallowest first — when /// all of them together fit and , the bytes of the files /// their memory imports counted too. Past that none is: a partial pick would load arbitrary packages up front while the -/// one the run works in stays out, and the timeline says why. Each one is checked like any root () as -/// the walk finds it, which is how its imports are counted, so memory that reaches outside the workspace leaves its -/// directory out by name. A directory left out still counts against the budget, and one past the directory bound is not -/// checked at all, so the guard reads the imports of at most directories that hold -/// memory files — beside those whose only memory is scoped rules, which it checks for imports — within the build's -/// . +/// one the run works in stays out. Each of them is then pointed at instead, so a run that reads a file below one is told +/// where its memory is (ClaudeWorkspaceMemory.Pointers.cs), and the timeline says why. Each one is checked like any root +/// () as the walk finds it, which is how its imports are counted, so memory that reaches outside the +/// workspace leaves its directory out by name. A directory left out still counts against the budget, and one past the +/// directory bound is not checked for it at all, so the guard reads the imports of at most +/// directories that hold memory files — beside those whose only memory is scoped +/// rules, which it checks for imports while the set still fits — within the build's . +/// +/// The same walk finds every rule scoped by paths:, in the workspace, its repositories and any directory +/// below them, which no --add-dir loads and each of which gets a pointer of its own. /// /// The walk never follows a link, and never enters .git, node_modules or another dot-directory, so /// memory under .github stays where it is, and so does memory a read reaches through a directory link: the CLI -/// keys nested memory by the path read, the walk by the path it walked. A directory whose path below the cwd holds -/// anything but 's characters is not added: the name came from the repository and rides -/// the argv. The bounds (, , and the build's +/// keys nested memory by the path read, the walk and its pointers by the path walked. A directory whose path below the +/// cwd holds anything but 's characters is not added or pointed at: the name came from the +/// repository and rides the argv. The bounds (, , and the build's /// ) only cap what one build spends; memory past them is not found, and the timeline says so. Every /// file is opened no-follow, non-blocking and only if regular, and nothing that resolves outside the workspace is opened /// at all: such memory counts as held, with no bytes, and the guard then leaves its directory out. @@ -48,37 +52,55 @@ internal static partial class ClaudeWorkspaceMemory private static readonly EnumerationOptions Listing = new() { AttributesToSkip = 0, IgnoreInaccessible = true }; - /// The nested directories to add in place, in walk order, and one sentence for each thing the walk left out. - private sealed record Nested(IReadOnlyList Directories, IReadOnlyList Notices); + /// + /// What the walk found, in walk order; the nested directories to add in place — every one that holds memory when + /// they all fit, none when they do not, which says — and one sentence for each thing the + /// walk left out. + /// + private sealed record Nested(IReadOnlyList Found, IReadOnlyList InPlace, bool OverBudget, IReadOnlyList Notices); + + /// One directory's findings: the memory it would load in place (none for the workspace and its repositories, which are added already) and every rule there the CLI scopes by paths:. + private sealed record Found(string Directory, NestedMemory? Memory, IReadOnlyList Rules); - /// One memory file the CLI reads from a directory: its bytes, and whether it is a rule scoped by paths:, which loads in place only what it imports. One that resolves outside the workspace has no bytes and is held as memory, so the guard then leaves its directory out. - private sealed record MemoryFile(long Bytes, bool Scoped); + /// A nested directory's memory files, as the cwd spells them, and their bytes together. + private sealed record NestedMemory(IReadOnlyList Files, long Bytes); + + /// A rule the CLI scopes by paths:, as the cwd spells it, and the globs it reads there (). + private sealed record ScopedRule(string File, IReadOnlyList Globs); + + /// One memory file the CLI reads from a directory, as the cwd spells it: its bytes, and its globs when it is a rule scoped by paths:. One that resolves outside the workspace has no bytes and no globs, so it is held as memory and its directory then left out. + private sealed record MemoryFile(string Path, long Bytes, IReadOnlyList? Globs); /// - /// One build's walk below . are added already and are not nested - /// memory, though the walk goes through them; is the physical root memory may resolve - /// anywhere inside; checks each directory to add, and holds the build's budget. + /// One build's walk from down. are added already, so their memory is + /// not nested memory, though their scoped rules are found and the walk goes through them; + /// is the physical root memory may resolve anywhere inside; checks each directory to add, and + /// holds the build's budget. /// private sealed class NestedWalk(string cwd, IEnumerable roots, string workspace, Guard guard) { private readonly string _cwd = Path.TrimEndingDirectorySeparator(cwd); private readonly HashSet _roots = roots.Select(Path.TrimEndingDirectorySeparator).ToHashSet(StringComparer.Ordinal); private readonly string _physicalCwd = PhysicalPath.File(cwd) ?? cwd; + private readonly List _found = []; private readonly List<(string Directory, long Bytes)> _held = []; private readonly List _notices = []; + private bool _overBudget; private int _entries; private bool _stopped; - /// Every directory with memory, while the set still fits in place; none, and why, once it does not. Where the build's budget runs out the walk stops, and what it found so far is planned (the budget's notice says so). + /// Everything the walk finds, and every nested directory with memory to add in place when the set fits; none, and why, when it does not. Where the build's budget runs out the walk stops, and what it found so far is planned (the budget's notice says so). public Nested Plan() { try { foreach (var directory in Walk()) { - if (MemoryOf(directory) is not { } bytes || !IsSafe(directory)) continue; + if (Look(directory) is not { } found) continue; + + _found.Add(found); - if (!Hold(directory, bytes)) return OverBudget(); + if (!_overBudget && LoadsInPlace(found)) _overBudget = !Hold(found); } } catch (MemoryBudgetSpentException) @@ -86,10 +108,12 @@ public Nested Plan() // The walk stops here; BudgetNotice says so. } - return new Nested(_held.Select(held => held.Directory).ToList(), _notices); + if (!_overBudget) return new Nested(_found, _held.Select(held => held.Directory).ToList(), false, _notices); + + return new Nested(_found, [], true, [.. _notices, $"Left the memory of every nested directory out of the run's first request: together it spans more than {MaxInPlaceDirectories} directories or {MaxInPlaceBytes} bytes. A read below one of them points the run at that directory's memory instead."]); } - /// Every directory below the cwd that is no root, breadth first and by name within a level, to deep. + /// The cwd and every directory below it, breadth first and by name within a level, to deep. private IEnumerable Walk() { var pending = new Queue<(string Directory, int Depth)>(); @@ -98,7 +122,7 @@ private IEnumerable Walk() while (pending.TryDequeue(out var current) && Examine()) { - if (current.Depth > 0 && !_roots.Contains(current.Directory)) yield return current.Directory; + yield return current.Directory; var children = Subdirectories(current.Directory); @@ -117,39 +141,50 @@ private static List Subdirectories(string directory) => Names(() => new DirectoryInfo(directory).EnumerateDirectories("*", Listing).Where(child => !child.Attributes.HasFlag(FileAttributes.ReparsePoint) && !child.Name.StartsWith('.') && child.Name != "node_modules").Select(child => child.Name)); /// - /// The bytes of memory the CLI would load from in place, before what that memory - /// imports, or null when it would load nothing: its CLAUDE.md, its .claude/CLAUDE.md and each rule it - /// reads without globs. A directory whose only memory is rules scoped by paths: loads in place just what - /// those import, so it is one to add when they import anything — or reach outside, which the guard then says. + /// What holds: the memory the CLI would load from it in place — its CLAUDE.md, + /// its .claude/CLAUDE.md and each rule it reads without globs, for a directory that is no root — and its + /// scoped rules. None when it holds neither, or when its path may not ride the argv. /// - private long? MemoryOf(string directory) + private Found? Look(string directory) { - var physical = Path.Combine(_physicalCwd, Path.GetRelativePath(_cwd, directory)); - var files = FileAt(Resolve(physical, "CLAUDE.md")).Concat(DotClaude(Path.GetRelativePath(_cwd, Path.Combine(directory, ".claude")), Resolve(physical, ".claude"))).ToList(); + var isRoot = _roots.Contains(directory); + var files = FilesOf(directory, directory == _cwd ? _physicalCwd : Path.Combine(_physicalCwd, Path.GetRelativePath(_cwd, directory))).ToList(); + var memory = files.Where(file => file.Globs is null).ToList(); + var rules = files.Where(file => file.Globs is not null).Select(file => new ScopedRule(file.Path, file.Globs!)).ToList(); + var held = isRoot || memory.Count == 0 ? null : new NestedMemory(memory.Select(file => file.Path).ToList(), memory.Sum(file => file.Bytes)); + + if (held is null && rules.Count == 0) return null; + + return isRoot || IsSafe(directory) ? new Found(directory, held, rules) : null; + } - if (files.Any(file => !file.Scoped)) return files.Where(file => !file.Scoped).Sum(file => file.Bytes); + /// Whether a nested directory loads anything in place: its memory, or — when its only memory is scoped rules — what those import, or reach outside, which the guard then says. + private bool LoadsInPlace(Found found) + { + if (_roots.Contains(found.Directory)) return false; - return files.Count > 0 && Imports(directory) ? 0 : null; + return found.Memory is not null || (guard.Of(found.Directory) is var check && (check.ImportedBytes > 0 || check.Escape is not null)); } - /// Whether the directory's memory imports anything the CLI reads, or reaches where the guard leaves it out. - private bool Imports(string directory) => guard.Of(directory) is var check && (check.ImportedBytes > 0 || check.Escape is not null); + /// Every memory file the CLI reads from the directory at , spelled below . + private IEnumerable FilesOf(string directory, string physical) => + FileAt(Path.Combine(directory, "CLAUDE.md"), Resolve(physical, "CLAUDE.md")).Concat(DotClaude(Path.Combine(directory, ".claude"), Resolve(physical, ".claude"))); /// What a .claude directory holds: its CLAUDE.md and its rules. One that resolves outside the workspace is held whole. private IEnumerable DotClaude(string spelled, string? dotClaude) { if (dotClaude is null || !Directory.Exists(dotClaude)) return []; - if (!PhysicalPath.StaysInside(workspace, dotClaude)) return [new MemoryFile(0, false)]; + if (!PhysicalPath.StaysInside(workspace, dotClaude)) return [new MemoryFile(spelled, 0, null)]; - return FileAt(Resolve(dotClaude, "CLAUDE.md")).Concat(Rules(Path.Combine(spelled, "rules"), Resolve(dotClaude, "rules"), new HashSet(StringComparer.Ordinal))); + return FileAt(Path.Combine(spelled, "CLAUDE.md"), Resolve(dotClaude, "CLAUDE.md")).Concat(Rules(Path.Combine(spelled, "rules"), Resolve(dotClaude, "rules"), new HashSet(StringComparer.Ordinal))); } /// /// Each rule under a rules folder, folders followed once each, by name; a folder that resolves outside the /// workspace is held with no bytes. An entry that is a link counts only while it resolves inside the cwd: the CLI /// skips one that leads anywhere else (2.1.263), so a rule linked into a sibling repository the cwd does not hold - /// is no memory of this directory. + /// is no memory of this directory, and gets no pointer. /// private IEnumerable Rules(string spelled, string? folder, HashSet seen) { @@ -157,7 +192,7 @@ private IEnumerable Rules(string spelled, string? folder, HashSet Rules(string spelled, string? folder, HashSetA memory file: none for nothing there or a directory, no bytes for one outside the workspace, its length for a regular file inside it. - private IEnumerable FileAt(string? physical) + private IEnumerable FileAt(string spelled, string? physical) { if (physical is null || Directory.Exists(physical)) return []; - if (!PhysicalPath.StaysInside(workspace, physical)) return [new MemoryFile(0, false)]; + if (!PhysicalPath.StaysInside(workspace, physical)) return [new MemoryFile(spelled, 0, null)]; - return Length(physical) is { } length ? [new MemoryFile(length, false)] : []; + return Length(physical) is { } length ? [new MemoryFile(spelled, length, null)] : []; } - /// A rule with its length, scoped when the CLI reads globs from its frontmatter (); none for one that is unreadable, or whose frontmatter runs past what the runner reads, which is said by its path below the cwd, . - private MemoryFile? Rule(string relative, string physical) + /// A rule with its length and the globs the CLI reads from its frontmatter (); none for one that is unreadable, or whose frontmatter runs past what the runner reads, which is said. + private MemoryFile? Rule(string spelled, string physical) { if (Frontmatter(physical) is not { } read) return null; - if (!read.Cut) return new MemoryFile(read.Length, ClaudeRuleScope.Read(read.Text) is not null); + if (!read.Cut) return new MemoryFile(spelled, read.Length, ClaudeRuleScope.Read(read.Text)); - Note($"Left the rule '{Printable(relative)}' unclassified: its frontmatter runs past {MaxScannedBytes} bytes, more than the runner reads to tell whether paths: scope it."); + Note($"Left the rule '{Printable(Path.GetRelativePath(_cwd, spelled))}' unclassified: its frontmatter runs past {MaxScannedBytes} bytes, more than the runner reads to tell whether paths: scope it."); return null; } @@ -253,18 +288,16 @@ private bool IsSafe(string directory) return false; } - /// One more nested directory to add in place, counted with the bytes the files its memory imports hold; false once the set no longer fits. One past the directory bound does not fit whatever it holds, so the guard never reads it. - private bool Hold(string directory, long bytes) + /// One more nested directory to add in place, counted with the bytes the files its memory imports hold; false once the set no longer fits. One past the directory bound does not fit whatever it holds, so the guard never reads it for this. + private bool Hold(Found found) { if (_held.Count == MaxInPlaceDirectories) return false; - _held.Add((directory, bytes + guard.Of(directory).ImportedBytes)); + _held.Add((found.Directory, (found.Memory?.Bytes ?? 0) + guard.Of(found.Directory).ImportedBytes)); return _held.Sum(held => held.Bytes) <= MaxInPlaceBytes; } - private Nested OverBudget() => new([], [.. _notices, $"Left the memory of every nested directory out of this run: together it spans more than {MaxInPlaceDirectories} directories or {MaxInPlaceBytes} bytes, more than a run loads before its first request."]); - /// One more entry examined; false, and said once, when that is more than the walk examines. Throws once the build's budget is spent. private bool Examine() { diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Pointers.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Pointers.cs new file mode 100644 index 000000000..7ea268151 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.Pointers.cs @@ -0,0 +1,219 @@ +using System.Text.RegularExpressions; +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.Harnesses.Claude; + +/// +/// Pointer rules: repository memory one Read away. A rule scoped by paths: never loads from an added directory, +/// and nested memory past the in-place budget is not added at all, yet the unpinned CLI attached both once the run read +/// a file they cover. The pinned CLI still attaches a rule of the user's own (CLAUDE_CONFIG_DIR/rules/**/*.md) +/// whose paths: match a file its Read tool opens, matched relative to the run's cwd. So the run's config home gets +/// one such rule per scoped repository rule and per nested directory that holds memory, +/// rules/codespace-repository-NNN.md, numbered in walk order. Its paths: are the repository rule's own, +/// rebased onto the cwd () — or /<directory> for a directory's memory, +/// which the CLI reads exactly as the /<directory>/** it was observed to match — so it attaches on the reads +/// that attached the repository's own; its body is one sentence of the runner's naming the files to read. A nested +/// directory loaded in place gets one too: the CLI starts an Explore or Plan subagent without project memory, yet +/// attached a directory's memory there once the subagent read below it, and it still attaches a user rule there. Its +/// sentence says to read the files only if they are not already in context, as they are in the main conversation. +/// +/// No repository byte is ever written there. The user scope reads external imports, and nothing under the +/// config home is the repository's to vouch for, so a pointer carries the runner's sentence and the files' absolute +/// paths, each in backticks and of letters, digits and . _ + - / only — never an @, anywhere — and its +/// globs re-emitted, never copied. A memory file whose path holds anything else is left out of its directory's pointer by +/// name. A rule the CLI loaded with no globs would load before the first request with a user's authority, so every +/// pointer is read back through the CLI's own parse () and kept only when that gives +/// exactly the globs written: scoped, and scoped as meant — a glob holding ---, which brace expansion can make, +/// would close the frontmatter early. What a pointer cannot carry exactly gets none, and the timeline says so. +/// +/// A pointer names files the model may then read, so the directory it points into passes the same guard as any +/// added directory (), against the same build budget, and one whose memory reaches outside the +/// workspace gets none. Each pointer is at most — a directory's names its memory files while +/// it fits and counts the rest — and all of them together at most , so a repository +/// that plants thousands of rules neither fills the launch frame nor ends a run on the read that attaches its pointer. +/// At most pointers are written and as many directories checked for them; past any bound +/// the timeline says so. +/// +internal static partial class ClaudeWorkspaceMemory +{ + /// The most pointer rules one run's config home carries, and directories checked for them. Pinned by a test. + internal const int MaxPointerRules = 128; + + /// The most bytes one pointer rule holds, its frontmatter and its sentence together. Pinned by a test. + internal const int MaxPointerBytes = 4096; + + /// The most bytes all of one run's pointer rules hold together. Pinned by a test. + internal const int MaxPointerTotalBytes = 128 * 1024; + + /// Where a pointer rule sits in the config home, before its three-digit number and .md. + internal const string PointerRulePrefix = "rules/codespace-repository-"; + + /// The characters a path a pointer names may hold. + [GeneratedRegex(@"^[A-Za-z0-9._+\-/]+\z")] + private static partial Regex SafePointerPath(); + + private const string UnsafePointerPath = "its path holds a character other than a letter, a digit or one of . _ + - /"; + + /// The pointer rules for one build, in order, and one sentence for each one the runner could not write. + private sealed record Pointers(IReadOnlyList Files, IReadOnlyList Notices); + + /// One pointer the walk calls for: into 's directory, naming , or naming the directory's own memory when that is null. + private sealed record PointerSource(Found Found, ScopedRule? Rule); + + /// + /// One build's pointer rules, the paths they name spelled from . + /// are the directories whose left-out notice the timeline already carries — every one offered to --add-dir. + /// + private sealed class PointerRules(string cwd, Guard guard, IEnumerable announced) + { + private readonly List _files = []; + private readonly List _notices = []; + private readonly HashSet _announced = announced.Select(Path.TrimEndingDirectorySeparator).ToHashSet(StringComparer.Ordinal); + private readonly HashSet _directories = new(StringComparer.Ordinal); + private int _bytes; + private bool _full; + + /// A pointer for every nested directory's memory and every scoped rule the walk found — in walk order, a directory's memory before its rules — until a bound is reached. + public Pointers For(Nested nested) + { + foreach (var source in Sources(nested)) + { + if (_full) break; + + if (!Admits(source.Found.Directory)) + { + _notices.Add($"Left the repository rules and nested memory past the first {MaxPointerRules} pointers out of this run: the runner writes no more."); + break; + } + + if (LeftOut(source.Found.Directory)) continue; + + if (source.Rule is { } rule) PointAtRule(source.Found.Directory, rule); + else PointAtMemory(source.Found, nested.OverBudget); + } + + return new Pointers(_files, _notices); + } + + private static IEnumerable Sources(Nested nested) => + nested.Found.SelectMany(found => (found.Memory is not null ? [new PointerSource(found, null)] : Array.Empty()).Concat(found.Rules.Select(rule => new PointerSource(found, rule)))); + + /// Whether one more pointer into stays within the bounds: files, and as many directories checked. + private bool Admits(string directory) + { + if (_files.Count == MaxPointerRules) return false; + + return _directories.Contains(directory) || (_directories.Count < MaxPointerRules && _directories.Add(directory)); + } + + /// Whether the guard leaves the directory out; said once, unless the timeline already says it — or says the budget ran out before it was checked. + private bool LeftOut(string directory) + { + if (guard.Of(directory).Escape is not { } why) return false; + + if (why != Unchecked && _announced.Add(Path.TrimEndingDirectorySeparator(directory))) _notices.Add(Notice(cwd, directory, why)); + + return true; + } + + /// A scoped rule's pointer: its globs rebased onto the cwd, and a sentence naming the rule. + private void PointAtRule(string directory, ScopedRule rule) + { + var subject = $"the rule '{Printable(Path.GetRelativePath(cwd, rule.File))}'"; + var globs = rule.Globs.Select(glob => ClaudeRuleScope.Rebase(Below(directory), glob)).ToList(); + var unsafeGlob = globs.IndexOf(null); + + if (!SafePointerPath().IsMatch(rule.File)) Refuse(subject, UnsafePointerPath); + else if (unsafeGlob >= 0) Refuse(subject, $"its paths: hold '{Printable(rule.Globs[unsafeGlob])}', which no pointer can carry exactly"); + else Point(subject, globs!, $"The repository rule `{rule.File}` applies to the file you just read. Read it now and follow it for files it matches."); + } + + /// A nested directory's pointer: every file below it, and a sentence naming its memory files — each whose path no pointer may carry left out by name. One loaded in place that gets none still loads; only its pointer is left out. + private void PointAtMemory(Found found, bool overBudget) + { + var below = Below(found.Directory); + var subject = overBudget ? $"the memory in '{Printable(below)}'" : $"the pointer to the memory in '{Printable(below)}'"; + var files = found.Memory!.Files.Where(file => SafePointerPath().IsMatch(file)).ToList(); + var unnamed = found.Memory.Files.Except(files).ToList(); + + if (!SafePointerPath().IsMatch(found.Directory)) + { + Refuse(subject, UnsafePointerPath); + return; + } + + if (unnamed.Count > 0) _notices.Add($"Left the memory file '{Printable(Path.GetRelativePath(cwd, unnamed[0]))}'{(unnamed.Count > 1 ? $" and {unnamed.Count - 1} more like it" : "")} out of the pointer to '{Printable(below)}': {UnsafePointerPath}."); + + if (files.Count == 0) return; + + if (DirectorySentence(found.Directory, below, files, overBudget) is { } sentence) Point(subject, [$"/{below}"], sentence); + else Refuse(subject, $"its pointer would run past {MaxPointerBytes} bytes"); + } + + /// + /// A directory pointer's sentence: past the in-place budget, that its memory was not preloaded; in place, where it + /// is should it not be in context. It names in order while the pointer stays within + /// , and counts the rest — all of which sit under the directory's .claude, as + /// its CLAUDE.md comes first. Null when not even the first name fits. + /// + private static string? DirectorySentence(string directory, string below, IReadOnlyList files, bool overBudget) + { + var lead = overBudget ? $"Repository instructions for `{below}/` were not preloaded: " : $"Repository instructions for `{below}/` are in "; + var tail = overBudget ? $". Read them now and follow them while you work under `{below}/`." : $". Read them now if they are not already in your context, and follow them while you work under `{below}/`."; + var room = MaxPointerBytes - ClaudeRuleScope.Frontmatter([$"/{below}"]).Length - lead.Length - tail.Length - 1; + var named = new List(); + + foreach (var file in files) + { + var more = files.Count - named.Count - 1; + + if (Listed(named.Append(file)).Length + (more > 0 ? Remainder(directory, more).Length : 0) > room) break; + + named.Add(file); + } + + return named.Count == 0 ? null : lead + Listed(named) + (named.Count < files.Count ? Remainder(directory, files.Count - named.Count) : "") + tail; + } + + private static string Listed(IEnumerable files) => string.Join(", ", files.Select(file => $"`{file}`")); + + private static string Remainder(string directory, int count) => $" and {count} more memory files under `{directory}/.claude/`"; + + /// + /// The pointer file, kept only when the CLI's own parse of it gives back exactly — never + /// unconditional, never scoped other than meant — and while it and every pointer before it stay within + /// and . Past the total no pointer follows. Every + /// byte a pointer holds is ASCII, so its length is its size. + /// + private void Point(string subject, IReadOnlyList globs, string body) + { + var content = ClaudeRuleScope.Frontmatter(globs) + body + "\n"; + + if (ClaudeRuleScope.Read(content)?.SequenceEqual(globs) != true) Refuse(subject, "the CLI would read its pointer with other globs than the runner wrote"); + else if (content.Length > MaxPointerBytes) Refuse(subject, $"its pointer would run past {MaxPointerBytes} bytes"); + else if (_bytes + content.Length > MaxPointerTotalBytes) Fill(); + else Write(content); + } + + private void Write(string content) + { + _bytes += content.Length; + _files.Add(new ConfigHomeFile { RelativePath = $"{PointerRulePrefix}{_files.Count:000}.md", Content = content }); + } + + private void Fill() + { + _full = true; + _notices.Add($"Left the repository rules and nested memory past the first {_files.Count} pointers out of this run: together they would run past {MaxPointerTotalBytes} bytes."); + } + + private void Refuse(string subject, string why) => _notices.Add($"Left {subject} out of this run: {why}."); + + /// The directory relative to the cwd, empty for the cwd itself. + private string Below(string directory) => Path.GetRelativePath(cwd, directory) switch + { + "." => "", + var relative => relative, + }; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.cs index eda94e4bc..feedd4240 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeWorkspaceMemory.cs @@ -32,6 +32,9 @@ namespace CodeSpace.Core.Services.Agents.Harnesses.Claude; /// closed: a ~ import names the run's own home, which under confinement is its config home and does not exist /// yet when the invocation is built, so there is nothing to resolve; the CLI's refusal is its only guard. /// +/// A pointer rule (ClaudeWorkspaceMemory.Pointers.cs) names files the model may read, so the directory it points +/// into is checked the same way first, and one that reaches outside gets no pointer. +/// /// Every file is opened no-follow, non-blocking and only if regular (), /// so a FIFO in a repository cannot hang the build; the CLI reads no FIFO either. The bounds below, and the build's /// over every directory together, only cap what one build may spend; what cannot be checked within @@ -57,8 +60,8 @@ internal static partial class ClaudeWorkspaceMemory /// The longest file or directory name a notice repeats; a longer one is cut. Pinned by a test. internal const int MaxNoticeNameLength = 120; - /// The directories to add, in order, and one sentence for each directory or walk bound that left memory out. - internal sealed record Plan(IReadOnlyList Directories, IReadOnlyList Notices); + /// The directories to add, in order; the pointer rules the run's config home carries (ClaudeWorkspaceMemory.Pointers.cs); and one sentence for each directory, rule or bound that left memory out. + internal sealed record Plan(IReadOnlyList Directories, IReadOnlyList Pointers, IReadOnlyList Notices); /// /// The plan for one build. The workspace and its repositories are checked first, so no tree below them can spend the @@ -67,20 +70,21 @@ internal sealed record Plan(IReadOnlyList Directories, IReadOnlyList public static Plan For(AgentTask task) { - if (string.IsNullOrWhiteSpace(task.WorkspaceDirectory)) return new Plan([], []); + if (string.IsNullOrWhiteSpace(task.WorkspaceDirectory)) return new Plan([], [], []); var roots = RootDirectories(task).ToList(); - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) return new Plan(roots, []); + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) return new Plan(roots, [], []); var workspace = PhysicalPath.File(ProvisionedRoot(task)) ?? ProvisionedRoot(task); var guard = new Guard(workspace, new Budget()); var rootsLeftOut = LeftOut(guard, roots); var nested = new NestedWalk(task.WorkspaceDirectory, roots, workspace, guard).Plan(); - var leftOut = rootsLeftOut.Concat(LeftOut(guard, nested.Directories)).ToList(); - var notices = LeftOutNotices(task.WorkspaceDirectory, guard, leftOut).Concat(nested.Notices).ToList(); + var leftOut = rootsLeftOut.Concat(LeftOut(guard, nested.InPlace)).ToList(); + var pointers = new PointerRules(task.WorkspaceDirectory, guard, roots.Concat(nested.InPlace)).For(nested); + var notices = LeftOutNotices(task.WorkspaceDirectory, leftOut).Concat(nested.Notices).Concat(pointers.Notices).Concat(guard.Budget.Spent ? [BudgetNotice] : []).ToList(); - return new Plan(roots.Concat(nested.Directories).Except(leftOut.Select(item => item.Root), StringComparer.Ordinal).ToList(), notices); + return new Plan(roots.Concat(nested.InPlace).Except(leftOut.Select(item => item.Root), StringComparer.Ordinal).ToList(), pointers.Files, notices); } /// @@ -115,9 +119,9 @@ private static string ProvisionedRoot(AgentTask task) private static List<(string Root, string Why)> LeftOut(Guard guard, IEnumerable roots) => roots.Select(root => (Root: root, Why: guard.Of(root).Escape)).Where(item => item.Why is not null).Select(item => (item.Root, item.Why!)).ToList(); - /// A notice for each directory left out by name, and one for all of them the budget left unchecked — which says too that the walk may have found less. - private static IEnumerable LeftOutNotices(string workspace, Guard guard, IEnumerable<(string Root, string Why)> leftOut) => - leftOut.Where(item => item.Why != Unchecked).Select(item => Notice(workspace, item.Root, item.Why)).Concat(guard.Budget.Spent ? [BudgetNotice] : []); + /// A notice for each directory left out by name; those the budget left unchecked share , which says too that the walk may have found less. + private static IEnumerable LeftOutNotices(string workspace, IEnumerable<(string Root, string Why)> leftOut) => + leftOut.Where(item => item.Why != Unchecked).Select(item => Notice(workspace, item.Root, item.Why)); private static string Notice(string workspace, string root, string why) => $"Left the memory in {Place(workspace, root)} out of this run: {why}."; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/RealHarnessWorkspaceMemoryTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/RealHarnessWorkspaceMemoryTests.cs index 07a2120a4..3f1b965f9 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/RealHarnessWorkspaceMemoryTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/RealHarnessWorkspaceMemoryTests.cs @@ -17,8 +17,9 @@ namespace CodeSpace.IntegrationTests.Workflows; /// /// A repository's memory run through the production pipeline: does the CLI get its workspace added back for memory, and -/// does the run's timeline say why not when it doesn't — for a CLAUDE.md committed as a symlink — and does a -/// nested directory holding memory ride the same --add-dir, committed or written by an earlier round? +/// does the run's timeline say why not when it doesn't — for a CLAUDE.md committed as a symlink — does a +/// nested directory holding memory ride the same --add-dir, committed or written by an earlier round, and does +/// each round's config home point at the scoped rules and the over-budget directories its workspace holds? /// /// 🟡 Medium-mock (Rule 12): the real DI-wired , the real /// , LocalGitWorkspaceProvider cloning a file:// bare remote (the symlink @@ -106,6 +107,41 @@ public async Task A_nested_claude_md_is_added_in_place_and_one_a_round_writes_is notices.ShouldBeEmpty("nothing was left out"); } + [Fact] + public async Task A_large_tree_and_its_scoped_rules_reach_each_round_as_pointer_rules_its_own_config_home_carries() + { + // Past the in-place budget no nested directory is added; each is pointed at from the round's config home, beside + // a pointer for the committed scoped rule, whose glob the fake reads back rebased onto the cwd — but not pkg-out, + // whose CLAUDE.md links outside the workspace: the guard leaves it without one, and says so once. The draft + // round's agent writes a scoped rule of its own: the revision's fresh config home points at it, the draft's did not. + if (OperatingSystem.IsWindows()) return; // the fake CLI is a /bin/sh script + + var packages = Enumerable.Range(0, ClaudeWorkspaceMemory.MaxInPlaceDirectories + 1).Select(i => $"pkg-{i:00}").ToList(); + var files = packages.ToDictionary(package => $"{package}/CLAUDE.md", _ => "Keep the package's API stable.\n"); + var directories = string.Join(' ', packages.Select(package => package == "pkg-00" ? "/pkg-00 /pkg-00/**/*.ts" : $"/{package}")); + + files["pkg-00/.claude/rules/ts.md"] = "---\npaths: \"*.ts\"\n---\nType every export.\n"; + + using var outside = new OutsideFile(); + using var remote = new BareRemote(); + await remote.SeedAsync(CheckScript, claudeMdTarget: "AGENTS.md", files: files, links: new Dictionary { ["pkg-out/CLAUDE.md"] = outside.Path }); + using var cli = new MemoryCheckingFakeCli($". ; {directories}", $". ; /lib/py/*.py {directories}", draftAction: "mkdir -p lib/.claude/rules && printf -- '---\\npaths: py/*.py\\n---\\nPython.\\n' > lib/.claude/rules/py.md", pointers: true); + + var (teamId, userId) = await SeedTeamAsync(); + var repoId = await SeedBoundRepositoryAsync(teamId, remote.Url); + var runId = await CreateRunAsync(teamId, userId, repoId, cli.Env()); + + await ExecuteRealAsync(runId); + + var (run, result) = await LoadAsync(runId); + var notices = (await LoadEventsAsync(runId)).Where(e => e.Text.StartsWith(NoticePrefix, StringComparison.Ordinal)).ToList(); + + run.Status.ShouldBe(AgentRunStatus.Succeeded, $"the fake CLI fails a round whose argv or config-home pointers are not what its workspace holds when it starts; error: {run.Error}; launches: {string.Join(", ", cli.Launches())}"); + cli.Launches().ShouldBe(new[] { $". ; {directories}", $". ; /lib/py/*.py {directories}" }, "each round's own config home points at what its workspace holds when the round starts"); + result.ReviseRounds.ShouldBe(1, "fixture check: the drafted round failed its check, so the executor built a second spec for the revision"); + notices.Select(e => e.Text).ShouldBe(new[] { $"Left the memory of every nested directory out of the run's first request: together it spans more than {ClaudeWorkspaceMemory.MaxInPlaceDirectories} directories or {ClaudeWorkspaceMemory.MaxInPlaceBytes} bytes. A read below one of them points the run at that directory's memory instead. Left the memory in 'pkg-out' out of this run: CLAUDE.md resolves outside the workspace." }, "said once, in one event: the revision leaves out what the draft did"); + } + /// What CLAUDE.md links to for . private static string Target(string where, OutsideFile outside) => where == Outside ? outside.Path : where; @@ -220,7 +256,7 @@ public void Dispose() } } - /// A bare local remote whose one commit holds the contract's check, an AGENTS.md, a CLAUDE.md committed as a symlink, and any other files given. GUID-suffixed; best-effort cleanup. + /// A bare local remote whose one commit holds the contract's check, an AGENTS.md, a CLAUDE.md committed as a symlink, and any other files and symlinks given. GUID-suffixed; best-effort cleanup. private sealed class BareRemote : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "cs-memory-remote-" + Guid.NewGuid().ToString("N")); @@ -234,7 +270,7 @@ public BareRemote() public string Url => new Uri(_bare).AbsoluteUri; - public async Task SeedAsync(string checkScript, string claudeMdTarget, IReadOnlyDictionary? files = null) + public async Task SeedAsync(string checkScript, string claudeMdTarget, IReadOnlyDictionary? files = null, IReadOnlyDictionary? links = null) { await Git(_root, "init", "--bare", "-b", "main", _bare); @@ -254,6 +290,12 @@ public async Task SeedAsync(string checkScript, string claudeMdTarget, IReadOnly await File.WriteAllTextAsync(Path.Combine(seed, relative), content); } + foreach (var (relative, target) in links ?? new Dictionary()) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(seed, relative))!); + File.CreateSymbolicLink(Path.Combine(seed, relative), target); + } + await Git(seed, "add", "-A"); await Git(seed, "commit", "-m", "seed"); await Git(seed, "push", "origin", "main"); @@ -275,7 +317,8 @@ public void Dispose() /// /// The fake claude: records the directories the argv it was spawned with names after --add-dir — - /// absent for none, else . for the first, the workspace, and each other one relative to it — exits 9 + /// absent for none, else . for the first, the workspace, and each other one relative to it — and, when + /// armed to, after ; every glob the pointer rules in its $CLAUDE_CONFIG_DIR carry, file by file; exits 9 /// when that is not what the test expects of its round, and otherwise drafts feature.txt — running the draft /// action as it does, when given one — or writes the revision, when its goal is the executor's revise instruction, /// and prints a successful stream-json result. Named and staged with the markers, so a real-CLI @@ -290,13 +333,15 @@ private sealed class MemoryCheckingFakeCli : IDisposable private readonly string _draft; private readonly string _revision; private readonly string _draftAction; + private readonly string _pointers; /// The directories the draft round's argv must add, as the fake records them: absent, . or . pkg. /// The same, for the revision. /// A shell command the draft round runs in its workspace, or null for none. - public MemoryCheckingFakeCli(string draft, string revision, string? draftAction) + /// Whether each launch's record also carries the globs of the pointer rules in its config home. + public MemoryCheckingFakeCli(string draft, string revision, string? draftAction, bool pointers = false) { - (_draft, _revision, _draftAction) = (draft, revision, draftAction ?? ""); + (_draft, _revision, _draftAction, _pointers) = (draft, revision, draftAction ?? "", pointers ? "1" : ""); Directory.CreateDirectory(_directory); _launches = Path.Combine(_directory, "launches.txt"); @@ -309,6 +354,10 @@ public MemoryCheckingFakeCli(string draft, string revision, string? draftAction) [ "$listing" = 1 ] || continue if [ -z "$first" ]; then first=$arg; memory=.; else memory="$memory ${arg#"$first"/}"; fi done + if [ -n "$FAKE_POINTERS" ]; then + globs=$(cat "$CLAUDE_CONFIG_DIR"/{{ClaudeWorkspaceMemory.PointerRulePrefix}}*.md 2>/dev/null | sed -n 's/^ - "\(.*\)"$/\1/p' | tr '\n' ' ') + memory="$memory ; ${globs% }" + fi printf '%s\n' "$memory" >> "$FAKE_LAUNCHES" case "$goal" in {{AgentRunExecutor.ReviseInstructionPrefix}}*) expected=$FAKE_EXPECT_REVISION ;; *) expected=$FAKE_EXPECT_DRAFT ;; esac [ "$memory" = "$expected" ] || { echo "expected the memory directories '$expected', argv: $*" >&2; exit 9; } @@ -325,7 +374,7 @@ public MemoryCheckingFakeCli(string draft, string revision, string? draftAction) Environment.SetEnvironmentVariable(ClaudeCodeHarness.CommandEnvVar, script); } - public IReadOnlyDictionary Env() => new Dictionary { ["FAKE_LAUNCHES"] = _launches, ["FAKE_EXPECT_DRAFT"] = _draft, ["FAKE_EXPECT_REVISION"] = _revision, ["FAKE_DRAFT_ACTION"] = _draftAction }; + public IReadOnlyDictionary Env() => new Dictionary { ["FAKE_LAUNCHES"] = _launches, ["FAKE_EXPECT_DRAFT"] = _draft, ["FAKE_EXPECT_REVISION"] = _revision, ["FAKE_DRAFT_ACTION"] = _draftAction, ["FAKE_POINTERS"] = _pointers }; /// What each launch's argv said, in order. public IReadOnlyList Launches() => File.Exists(_launches) ? File.ReadAllLines(_launches) : Array.Empty(); diff --git a/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs index 5053a0402..b23d47f66 100644 --- a/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs @@ -134,6 +134,17 @@ public async Task A_standard_claude_run_reads_nested_memory_in_place() await arms.ClaudeReadsNestedMemoryInPlaceAsync(AgentAutonomyLevel.Standard, repositories: 1, Lane); } + [Fact] + public async Task A_standard_claude_run_attaches_a_scoped_rule_pointer_after_a_matching_read() + { + // The shipped posture against a scoped rule: bypassPermissions reads as plan mode does, and the pointer must + // attach on the covered read alone, carrying none of the rule's text and none of what it imports. + if (!NonRootWorker.Require()) return; + + using var arms = new RepositoryConfigE2ETests(output); + await arms.ClaudeAttachesAScopedRulePointerAsync(AgentAutonomyLevel.Standard, repositories: 1, Lane); + } + [Fact] public async Task A_standard_allowlist_claude_run_still_runs_its_own_stop_hook_beside_the_sealed_egress_settings() { diff --git a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.NestedMemory.cs b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.NestedMemory.cs index 43333583a..aaaed392c 100644 --- a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.NestedMemory.cs +++ b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.NestedMemory.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using CodeSpace.Core.Services.Agents.Harnesses.Claude; using CodeSpace.Core.Services.Agents.Sandbox.Isolation; using CodeSpace.Messages.Agents; @@ -11,8 +13,8 @@ namespace CodeSpace.SandboxTests; /// memory once the run read a file below it, but an --add-dir naming that subdirectory loads its CLAUDE.md, /// its .claude/CLAUDE.md and its rules without paths: before the first request, labelled as project /// instructions, with its in-repository imports — a scoped rule's too, though not the rule — and, as for the workspace, -/// none of its settings. The harness adds every such directory when all of them fit the in-place budget, and none past it -/// (ClaudeWorkspaceMemory). +/// none of its settings. The harness adds every such directory when all of them fit the in-place budget, and none past it, +/// where it points the run at each one instead (ClaudeWorkspaceMemory). /// /// Same fidelity as the class: the pinned binary, the production argv and runner, the production broker; only the /// model is scripted, and it asks for nothing, so whatever nested memory reaches a request got there before the model @@ -55,8 +57,12 @@ public sealed partial class RepositoryConfigE2ETests /// /// One more nested directory than loads in place: none of them is added, every one's memory stays out of every - /// request, and the launch says why. The workspace's own memory still loads — the fixture check that this run loads - /// memory at all. Root lane, Confined. + /// request, and the launch says why. Each is pointed at instead: the scripted model reads a file below one, and that + /// read's result names that directory's memory and no other's. Two more directories reach outside the workspace — one + /// whose CLAUDE.md links out, one whose scoped rule imports an outside file, both where a confined run could + /// read them — and the guard on what a pointer points into leaves both without one: reads below them attach nothing, + /// the launch names them, and nothing outside reaches any request. The workspace's own memory still loads — the + /// fixture check that this run loads memory at all. Root lane, Confined. /// [Fact] public async Task A_claude_run_over_the_in_place_budget_loads_no_nested_memory_up_front() @@ -73,22 +79,106 @@ public async Task A_claude_run_over_the_in_place_budget_loads_no_nested_memory_u var workspace = NewWorkspace(repositories: 1); var repo = workspace.Repositories[0]; var nested = Enumerable.Range(0, ClaudeWorkspaceMemory.MaxInPlaceDirectories + 1).Select(i => $"NESTED-{i:00}").ToList(); + var outside = NewOutsideDirectory(); + var reads = new[] { "pkg-03/notes.txt", "out-link/notes.txt", "out-imp/a.ts" }.Select(read => Path.Combine(repo.Directory, read)).ToList(); + File.WriteAllText(Path.Combine(outside, "linked.md"), $"{Mention(repo, "OUTSIDE-NESTED")}\n"); + File.WriteAllText(Path.Combine(outside, "imported.md"), $"{Mention(repo, "OUTSIDE-IMPORT")}\n"); repo.Commit("CLAUDE.md", $"{Mention(repo, "MEMORY")}\n"); + repo.Commit("pkg-03/notes.txt", $"{SurfaceText(repo, "NOTES")}\n"); + repo.CommitLink("out-link/CLAUDE.md", Path.Combine(outside, "linked.md")); + repo.Commit("out-link/notes.txt", $"{SurfaceText(repo, "NOTES")}\n"); + repo.Commit("out-imp/.claude/rules/ts.md", $"---\npaths: \"*.ts\"\n---\nTypes.\n\n@{Path.Combine(outside, "imported.md")}\n"); + repo.Commit("out-imp/a.ts", $"{SurfaceText(repo, "NOTES")}\n"); foreach (var surface in nested) repo.Commit($"pkg-{surface[^2..]}/CLAUDE.md", $"{Mention(repo, surface)}\n"); - var (spec, run, upstream) = await RunAsync(harness, workspace, tier, task => task); + var (spec, run, upstream) = await RunAsync(harness, workspace, tier, task => task, ReadingUpstream(workspace, reads)); + + IReadOnlyList pointed = DirectoryPointer().Matches(ToolResult(upstream, 0)).Select(match => match.Groups[1].Value).ToList(); BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); (upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? "").ShouldContain(SurfaceText(repo, "MEMORY"), Case.Sensitive, $"fixture check: the repository's own memory loads, or nested memory staying out proves nothing. {Diagnosis(harnessKind, spec, run, upstream)}"); + Enumerable.Range(0, reads.Count).Where(step => !ToolResult(upstream, step).Contains(SurfaceText(repo, "NOTES"), StringComparison.Ordinal)).ShouldBeEmpty($"fixture check: every scripted read handed its file back. {Diagnosis(harnessKind, spec, run, upstream)}"); AddedDirectories(spec).ShouldBe(new[] { repo.Directory }, "past the budget no nested directory is added"); - spec.LaunchNotices.ShouldBe(new[] { $"Left the memory of every nested directory out of this run: together it spans more than {ClaudeWorkspaceMemory.MaxInPlaceDirectories} directories or {ClaudeWorkspaceMemory.MaxInPlaceBytes} bytes, more than a run loads before its first request." }); - nested.Where(surface => upstream.Requests.Any(r => r.Body.Contains(SurfaceText(repo, surface), StringComparison.Ordinal))).ShouldBeEmpty($"no nested memory may load up front past the budget. {Diagnosis(harnessKind, spec, run, upstream)}"); + spec.LaunchNotices.ShouldBe(new[] + { + $"Left the memory of every nested directory out of the run's first request: together it spans more than {ClaudeWorkspaceMemory.MaxInPlaceDirectories} directories or {ClaudeWorkspaceMemory.MaxInPlaceBytes} bytes. A read below one of them points the run at that directory's memory instead.", + "Left the memory in 'out-imp' out of this run: a file .claude/rules/ts.md imports resolves outside the workspace.", + "Left the memory in 'out-link' out of this run: CLAUDE.md resolves outside the workspace.", + }); + PointerFiles(spec).Count.ShouldBe(nested.Count, "one pointer per nested directory, and none into a directory whose memory reaches outside"); + nested.Where(surface => upstream.Requests.Any(r => r.Body.Contains(SurfaceText(repo, surface), StringComparison.Ordinal))).ShouldBeEmpty($"no nested memory may load up front past the budget, nor through a pointer, which names it only. {Diagnosis(harnessKind, spec, run, upstream)}"); + PointersUpFront(upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? "").ShouldBeEmpty($"a pointer is no instruction up front. {Diagnosis(harnessKind, spec, run, upstream)}"); + pointed.ShouldBe(new[] { "pkg-03" }, $"a read below pkg-03 attaches its pointer, naming {Path.Combine(repo.Directory, "pkg-03", "CLAUDE.md")}, and no other directory's. {Diagnosis(harnessKind, spec, run, upstream)}"); + ToolResult(upstream, 0).ShouldContain($"`{Path.Combine(repo.Directory, "pkg-03", "CLAUDE.md")}`", Case.Sensitive, Diagnosis(harnessKind, spec, run, upstream)); + new[] { 1, 2 }.Where(step => PointersUpFront(ToolResult(upstream, step)).Any()).ShouldBeEmpty($"a read below a directory whose memory reaches outside attaches no pointer into it. {Diagnosis(harnessKind, spec, run, upstream)}"); + new[] { "OUTSIDE-NESTED", "OUTSIDE-IMPORT" }.Where(surface => upstream.Requests.Any(r => r.Body.Contains(SurfaceText(repo, surface), StringComparison.Ordinal))).ShouldBeEmpty($"nothing outside the workspace may reach the model. {Diagnosis(harnessKind, spec, run, upstream)}"); output.WriteLine($"{RanMarker} nested-over-budget {harnessKind} single-repo {tier} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null} directories={nested.Count}"); } + /// + /// Nested memory loaded in place reaches an Explore subagent. The CLI starts Explore (and Plan) without project + /// memory, so what an --add-dir loads never reaches it; unpinned, the CLI attached a directory's memory there + /// once the subagent read below it. The scripted main loop delegates to Explore in the foreground — the plan-mode + /// classifier's verdict scripted to let it start — and Explore's script reads pkg/a.ts. + /// The unpinned control must attach pkg/CLAUDE.md to that read — the drift detector for this route — and the + /// production run must load the memory in place for the main loop, keep it out of the subagent's requests, as the CLI + /// does, and attach the directory's pointer to the subagent's read instead. Root lane, Confined. + /// + [Fact] + public async Task An_explore_subagent_is_pointed_at_nested_memory_loaded_in_place() + { + const string harnessKind = ClaudeCodeHarness.HarnessKind; + const AgentAutonomyLevel tier = AgentAutonomyLevel.Confined; + const string explore = "file search specialist"; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories: 1); + var repo = workspace.Repositories[0]; + var read = Path.Combine(repo.Directory, "pkg", "a.ts"); + + repo.Commit("CLAUDE.md", $"{Mention(repo, "MEMORY")}\n"); + repo.Commit("pkg/CLAUDE.md", $"{Mention(repo, "NESTED-MEMORY")}\n"); + repo.Commit("pkg/a.ts", $"{SurfaceText(repo, "READ")}\n"); + + ScriptedModelUpstream Delegating() => new([], $"DONE-{workspace.Nonce}") + { + ClaudeCalls = [new ScriptedToolCall("Agent", new JsonObject { ["subagent_type"] = "Explore", ["description"] = "Look at the package", ["prompt"] = "Read pkg/a.ts and say what it holds.", ["run_in_background"] = false })], + Subagent = new ScriptedSubagent(explore, [new ScriptedToolCall("Read", new JsonObject { ["file_path"] = read })]), + ClassifierAllows = true, + }; + + var (controlSpec, controlRun, control) = await RunAsync(harness, workspace, tier, task => task, Delegating(), reshape: WithoutTheSettingsPinOrPointers); + var (spec, run, upstream) = await RunAsync(harness, workspace, tier, task => task, Delegating()); + + var subagentRead = ToolResult(upstream, 0, ScriptedModelUpstream.SubagentToolIdPrefix); + var subagentRequests = upstream.Requests.Where(r => r.Body.Contains(explore, StringComparison.Ordinal)).ToList(); + + BrokerViolations(controlRun, control, hostile, workspace).ShouldBeEmpty($"fixture check: the unpinned control must run to its answer. {Diagnosis(harnessKind, controlSpec, controlRun, control)}"); + ToolResult(control, 0, ScriptedModelUpstream.SubagentToolIdPrefix).ShouldContain(SurfaceText(repo, "NESTED-MEMORY"), Case.Sensitive, $"drift detector: unpinned, the CLI attached pkg/CLAUDE.md to the Explore subagent's read below pkg/. {Diagnosis(harnessKind, controlSpec, controlRun, control)}"); + + BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + subagentRead.ShouldContain(SurfaceText(repo, "READ"), Case.Sensitive, $"fixture check: the subagent's scripted read handed pkg/a.ts back. {Diagnosis(harnessKind, spec, run, upstream)}"); + AddedDirectories(spec).ShouldBe(new[] { repo.Directory, Path.Combine(repo.Directory, "pkg") }, "fixture check: pkg/ loads in place"); + (upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? "").ShouldContain(SurfaceText(repo, "NESTED-MEMORY"), Case.Sensitive, $"fixture check: the main loop has pkg/CLAUDE.md in place. {Diagnosis(harnessKind, spec, run, upstream)}"); + subagentRequests.ShouldNotContain(r => r.Body.Contains(SurfaceText(repo, "NESTED-MEMORY"), StringComparison.Ordinal), $"fixture check: the CLI starts Explore without project memory, so memory in place never reaches it. {Diagnosis(harnessKind, spec, run, upstream)}"); + subagentRead.ShouldContain($"Repository instructions for `pkg/` are in `{Path.Combine(repo.Directory, "pkg", "CLAUDE.md")}`", Case.Sensitive, $"the subagent's read below pkg/ must attach the directory's pointer. {Diagnosis(harnessKind, spec, run, upstream)}"); + PointersUpFront(upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? "").ShouldBeEmpty($"a pointer is no instruction up front. {Diagnosis(harnessKind, spec, run, upstream)}"); + + output.WriteLine($"{RanMarker} nested-subagent-pointer {harnessKind} single-repo {tier} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null} subagentRequests={subagentRequests.Count}"); + } + + /// The directory a nested directory's pointer names. + [GeneratedRegex("Repository instructions for `([^`]+)/` were not preloaded")] + private static partial Regex DirectoryPointer(); + /// /// The nested-memory arm, for either lane: a workspace of repositories, each with a /// pkg/ holding every kind of memory the CLI loads in place beside hostile settings and an MCP server, and each @@ -132,6 +222,8 @@ internal async Task ClaudeReadsNestedMemoryInPlaceAsync(AgentAutonomyLevel tier, missing.ShouldBeEmpty($"nested memory loaded in place must be in the run's first request. {Diagnosis(harnessKind, spec, run, upstream)}"); first.ShouldContain("/pkg/CLAUDE.md (project instructions, checked into the codebase)", Case.Sensitive, $"in place, a nested CLAUDE.md is the repository's own instructions, not the user's. {Diagnosis(harnessKind, spec, run, upstream)}"); leaked.ShouldBeEmpty($"what nested memory must not bring in reached the model. {Diagnosis(harnessKind, spec, run, upstream)}"); + PointerFiles(spec).Count.ShouldBe(3 * repositories, "each repository's pkg/ is pointed at for a subagent, and its and imp/'s scoped rules for a read they match"); + PointersUpFront(first).ShouldBeEmpty($"no pointer may load before the first request: one the CLI read without globs would carry the user's authority. {Diagnosis(harnessKind, spec, run, upstream)}"); upstream.Requests.ShouldNotContain(r => r.Body.Contains(homeText, StringComparison.Ordinal), $"a ~ import in memory loaded in place must stay unread: under bubblewrap HOME is the config home, beside the run's MCP token. {Diagnosis(harnessKind, spec, run, upstream)}"); output.WriteLine($"{RanMarker} {(lane == "root" ? "" : lane + " ")}nested-in-place {harnessKind} {(repositories == 1 ? "single-repo" : "multi-repo")} {tier} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null} hostileConnections={hostile.Connections}"); diff --git a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.ScopedRules.cs b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.ScopedRules.cs new file mode 100644 index 000000000..551360fcd --- /dev/null +++ b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.ScopedRules.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Enums; +using Shouldly; + +namespace CodeSpace.SandboxTests; + +/// +/// Rules scoped by paths:, pointed at. No --add-dir loads a scoped rule, and the settings pin shuts the +/// route by which the unpinned CLI attached one once its Read tool opened a file the rule covers. The harness writes a +/// pointer rule of the user's own into the run's config home instead (ClaudeWorkspaceMemory), whose +/// paths: are the repository rule's rebased onto the cwd and whose body is one sentence of the runner's naming +/// the rule. The pinned CLI attaches it on the read the repository's own would have attached on, and on no other. +/// +/// Same fidelity as the class: the pinned binary, the production argv and runner, the production broker; only the +/// model is scripted, and it opens files with the CLI's own Read tool, so what attaches is the CLI's doing. The drift +/// detector () runs one corpus of glob +/// shapes through the unpinned CLI, which attaches the repository's rules natively, and through the production run, +/// read for read: the rebase mirrors the CLI's gitignore anchoring, its brace and comma splitting and its /** +/// strip, so a CLI that changes any of them fails it. A pointer is runner text only: the rule's own text never reaches +/// the model through it, nor what that text imports — which, copied into the config home, the CLI would read before +/// the first request (the positive control). +/// +public sealed partial class RepositoryConfigE2ETests +{ + /// + /// The differential corpus: each rule's file below .claude/rules of the repository or its pkg/, its + /// paths: as written — with whatever else its frontmatter holds after it — and whether the CLI scopes it; the + /// one it does not loads up front in both runs and is no read's to attach. c-dir ends in a slash, which anchors + /// nothing; c-deep still ends in /** after the CLI drops one, so its pointer must write one more; and + /// c-big's frontmatter closes past the 4 KiB the runner reads first. + /// + private static readonly (string File, string Paths, bool Scoped)[] PointerCorpus = + [ + (".claude/rules/c-src.md", "[\"src/**\"]", true), + (".claude/rules/c-ts.md", "\"*.ts\"", true), + (".claude/rules/c-brace.md", "\"{lib/a,b}\"", true), + (".claude/rules/c-comma.md", "\"docs/x, y\"", true), + (".claude/rules/c-neg.md", "[\"gen/**/*.txt\", \"!gen/keep/*.txt\"]", true), + (".claude/rules/c-anch.md", "[\"/top/**\"]", true), + (".claude/rules/c-star.md", "[\"**\"]", false), + (".claude/rules/c-deep.md", "\"src/**/**\"", true), + (".claude/rules/c-big.md", $"[\"docs/big/**\"]\ndescription: {new string('x', 4200)}", true), + ("pkg/.claude/rules/c-nested.md", "[\"*.md\"]", true), + ("pkg/.claude/rules/c-dir.md", "[\"gen/\"]", true), + ]; + + /// + /// The reads the differential makes in the first repository: those no rule covers first — a negation, a glob anchored + /// elsewhere, a nested rule's directory missed — so one that wrongly attached is seen before a later read could + /// attach the same rule rightly; then one for each rule. other/src/a.txt is covered by c-src alone: the + /// CLI drops src/**'s /** and reads src, which matches at any depth, while c-deep's + /// src/** is anchored, so only src/a.txt attaches it. pkg/x/gen/f.txt is below a gen + /// directory two levels into pkg/, which c-dir's gen/ covers at any depth. + /// + private static readonly string[] PointerReads = ["nomatch/z.txt", "gen/keep/b.txt", "other/lib/a/f.txt", "other/top/f.txt", "nopkg/n.md", "other/src/a.txt", "src/a.txt", "x/y.ts", "lib/a/f.txt", "docs/x/f.txt", "docs/big/f.txt", "gen/a.txt", "pkg/d/n.md", "pkg/x/gen/f.txt", "top/t.txt"]; + + [Theory] + [InlineData(1)] + [InlineData(2)] + public Task A_scoped_rule_reaches_the_model_only_after_a_read_it_matches(int repositories) => ClaudeAttachesAScopedRulePointerAsync(AgentAutonomyLevel.Confined, repositories, lane: "root"); + + /// + /// The drift detector (Rule 12.5): the corpus run unpinned — the production spec without --setting-sources user + /// and without its pointers, so the CLI attaches each repository's rules itself — and run as production builds it. + /// For every read, the rules attached must be the same; the negatives must attach none in either; the production + /// run must carry one pointer per scoped rule and none for the rule the CLI reads without globs, and none of them may + /// be in its first request, where an unconditional pointer would load; and nothing a rule says may reach the + /// production run's model. Root lane, Confined. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task Pointer_rules_attach_where_the_unpinned_cli_attached_project_rules(int repositories) + { + const string harnessKind = ClaudeCodeHarness.HarnessKind; + const AgentAutonomyLevel tier = AgentAutonomyLevel.Confined; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories); + var reads = PointerReads.Select(read => Path.Combine(workspace.Repositories[0].Directory, read)).Concat(workspace.Repositories.Skip(1).Select(repo => Path.Combine(repo.Directory, "x", "y.ts"))).ToList(); + + foreach (var repo in workspace.Repositories) PlantPointerCorpus(repo); + + var (unpinnedSpec, unpinnedRun, unpinned) = await RunAsync(harness, workspace, tier, task => task, ReadingUpstream(workspace, reads), reshape: WithoutTheSettingsPinOrPointers); + var (spec, run, upstream) = await RunAsync(harness, workspace, tier, task => task, ReadingUpstream(workspace, reads)); + + var first = upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? ""; + var native = reads.Select((_, step) => Attached(workspace, ToolResult(unpinned, step), NativeRule())).ToList(); + var pointed = reads.Select((_, step) => Attached(workspace, ToolResult(upstream, step), PointedRule())).ToList(); + var described = string.Join("; ", reads.Select((read, step) => $"{Path.GetRelativePath(workspace.Directory, read)}: native [{string.Join(", ", native[step])}] pointed [{string.Join(", ", pointed[step])}]")); + + BrokerViolations(unpinnedRun, unpinned, hostile, workspace).ShouldBeEmpty($"fixture check: the unpinned run must run to its answer. {Diagnosis(harnessKind, unpinnedSpec, unpinnedRun, unpinned)}"); + unpinnedSpec.Args.ShouldNotContain("--setting-sources", "fixture check: the control is the unpinned CLI"); + native.Take(5).ShouldAllBe(attached => attached.Count == 0, $"fixture check: the corpus's negatives attach nothing natively, or they test nothing. {described}"); + native.Count(attached => attached.Count > 0).ShouldBe(PointerCorpus.Count(rule => rule.Scoped) + repositories - 1, $"fixture check: natively, each scoped rule attaches on exactly one read, or the corpus distinguishes less than it claims. {described}"); + + BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + PointerFiles(spec).Count.ShouldBe(PointerCorpus.Count(rule => rule.Scoped) * repositories, "one pointer per scoped rule of every repository"); + PointerFiles(spec).ShouldNotContain(file => file.Content.Contains("c-star.md", StringComparison.Ordinal), "the rule the CLI reads without globs loads in place: a pointer to it would be unconditional"); + PointersUpFront(first).ShouldBeEmpty($"no pointer may load before the first request: one the CLI read without globs would carry the user's authority. {Diagnosis(harnessKind, spec, run, upstream)}"); + Enumerable.Range(0, reads.Count).Where(step => !ToolResult(upstream, step).Contains(SurfaceText(workspace.Repositories[step < PointerReads.Length ? 0 : step - PointerReads.Length + 1], "READ"), StringComparison.Ordinal)).ShouldBeEmpty($"fixture check: every scripted read handed its file back. {Diagnosis(harnessKind, spec, run, upstream)}"); + pointed.ShouldBe(native, $"the pointers must attach on exactly the reads the unpinned CLI attached the repository's own rules on. {described}"); + reads.Select((_, step) => Attached(workspace, ToolResult(upstream, step), NativeRule())).ShouldAllBe(attached => attached.Count == 0, $"the pinned run attaches no repository rule itself, only pointers to them. {described}"); + CorpusTexts(workspace, scoped: true).Where(text => upstream.Requests.Any(r => r.Body.Contains(text, StringComparison.Ordinal))).ShouldBeEmpty($"a pointer carries no repository text: no scoped rule's own text may reach the production run's model. {Diagnosis(harnessKind, spec, run, upstream)}"); + CorpusTexts(workspace, scoped: false).Where(text => !first.Contains(text, StringComparison.Ordinal)).ShouldBeEmpty($"fixture check: the rule the CLI reads with no globs loads before the first request, as it did unpinned. {Diagnosis(harnessKind, spec, run, upstream)}"); + + output.WriteLine($"{RanMarker} pointer-differential {harnessKind} {(repositories == 1 ? "single-repo" : "multi-repo")} {tier} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null} reads={reads.Count} attached={native.Count(attached => attached.Count > 0)}"); + } + + /// + /// The scoped-rule arm, for either lane: each repository's .claude/rules/ts.md scoped to sub/**/*.ts, + /// whose text tries what a copied rule would do in the config home — an import climbing to the run's MCP declaration + /// there, and the same through ~ (HOME is the config home under bubblewrap). The scripted model reads the + /// first repository's other/sub/x.ts, which the rule's anchored glob does not cover, then its + /// sub/x.ts. The pointer must be in no request up front or after the first read, and in the second read's + /// result; another repository's pointer in none; and neither the rule's text nor the declaration's token in any. + /// The positive control writes each pointer as the repository's rule itself, which hands the token to the model. + /// + internal async Task ClaudeAttachesAScopedRulePointerAsync(AgentAutonomyLevel tier, int repositories, string lane) + { + const string harnessKind = ClaudeCodeHarness.HarnessKind; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories); + var read = workspace.Repositories[0]; + var token = $"REPO-MCP-TOKEN-{workspace.Nonce}"; + var home = NewDirectory("repo-config-home"); + var reads = new[] { Path.Combine(read.Directory, "other", "sub", "x.ts"), Path.Combine(read.Directory, "sub", "x.ts") }; + + File.WriteAllText(Path.Combine(home, ".mcp.json"), McpDeclaration(token)); + + foreach (var repo in workspace.Repositories) PlantScopedRule(repo); + + Task<(SandboxSpec Spec, Run Run, ScriptedModelUpstream Upstream)> Launch(Func reshape) => + RunAsync(harness, workspace, tier, task => task with { Environment = new Dictionary(task.Environment) { ["HOME"] = home } }, ReadingUpstream(workspace, reads), production => reshape(production with { ConfigHomeFiles = [.. production.ConfigHomeFiles, new ConfigHomeFile { RelativePath = ".mcp.json", Content = McpDeclaration(token) }] })); + + var (controlSpec, controlRun, control) = await Launch(WithPointersCopiedFromTheirRules); + var (spec, run, upstream) = await Launch(production => production); + + var first = upstream.Requests.FirstOrDefault(OffersTools)?.Body ?? ""; + var pointer = RuleSentence(ScopedRuleOf(read)); + + BrokerViolations(controlRun, control, hostile, workspace).ShouldBeEmpty($"fixture check: the control must run to its answer. {Diagnosis(harnessKind, controlSpec, controlRun, control)}"); + control.Requests.ShouldContain(r => r.Body.Contains(token, StringComparison.Ordinal), $"positive control: a repository rule copied into the config home imports the run's MCP declaration, so the token reaching no request below is the pointer's doing. {Diagnosis(harnessKind, controlSpec, controlRun, control)}"); + + BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + reads.Select((_, step) => ToolResult(upstream, step)).Zip(new[] { "OTHER-TS", "SUB-TS" }).Where(pair => !pair.First.Contains(SurfaceText(read, pair.Second), StringComparison.Ordinal)).ShouldBeEmpty($"fixture check: both scripted reads handed their file back. {Diagnosis(harnessKind, spec, run, upstream)}"); + first.ShouldContain(SurfaceText(read, "MEMORY"), Case.Sensitive, $"fixture check: the repository's own memory loads, so its directory passed the guard and could be pointed into. {Diagnosis(harnessKind, spec, run, upstream)}"); + spec.ConfigHomeFiles.Where(file => file.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal)).Select(file => (file.RelativePath, file.Content)).ShouldBe(workspace.Repositories.Select((repo, i) => ($"{ClaudeWorkspaceMemory.PointerRulePrefix}{i:000}.md", ExpectedPointer(workspace, repo))), "one pointer per repository, its glob rebased onto the cwd, its body the runner's sentence alone"); + first.ShouldNotContain(pointer, Case.Sensitive, $"a scoped rule's pointer is no instruction up front. {Diagnosis(harnessKind, spec, run, upstream)}"); + ToolResult(upstream, 0).ShouldNotContain(ClaudeWorkspaceMemory.PointerRulePrefix.Split('/')[1], Case.Sensitive, $"other/sub/x.ts is no file the anchored sub/**/*.ts covers, so no pointer may attach on it. {Diagnosis(harnessKind, spec, run, upstream)}"); + ToolResult(upstream, 1).ShouldContain(pointer, Case.Sensitive, $"sub/x.ts is covered: its read must attach the pointer to the rule. {Diagnosis(harnessKind, spec, run, upstream)}"); + workspace.Repositories.Skip(1).Where(repo => upstream.Requests.Any(r => r.Body.Contains(RuleSentence(ScopedRuleOf(repo)), StringComparison.Ordinal))).Select(repo => repo.Directory).ShouldBeEmpty($"a read in one repository attaches no other repository's pointer. {Diagnosis(harnessKind, spec, run, upstream)}"); + workspace.Repositories.Where(repo => upstream.Requests.Any(r => r.Body.Contains(SurfaceText(repo, "SCOPED-RULE"), StringComparison.Ordinal))).Select(repo => repo.Directory).ShouldBeEmpty($"a pointer carries no repository text: the rule's own text reaches no request. {Diagnosis(harnessKind, spec, run, upstream)}"); + upstream.Requests.ShouldNotContain(r => r.Body.Contains(token, StringComparison.Ordinal), $"nothing the rule imports may load: the run's MCP token reached the model. {Diagnosis(harnessKind, spec, run, upstream)}"); + + output.WriteLine($"{RanMarker} {(lane == "root" ? "" : lane + " ")}scoped-rule-pointer {harnessKind} {(repositories == 1 ? "single-repo" : "multi-repo")} {tier} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null}"); + } + + /// One repository's memory, its rule scoped to sub/**/*.ts with what a copied rule would import, and the two files the arm reads. + private static void PlantScopedRule(Repository repo) + { + repo.Commit("CLAUDE.md", $"{Mention(repo, "MEMORY")}\n"); + repo.Commit(".claude/rules/ts.md", $"---\npaths:\n - \"sub/**/*.ts\"\n---\n{Mention(repo, "SCOPED-RULE")}\n\n@../.mcp.json\n\n@~/.mcp.json\n"); + repo.Commit("sub/x.ts", $"{SurfaceText(repo, "SUB-TS")}\n"); + repo.Commit("other/sub/x.ts", $"{SurfaceText(repo, "OTHER-TS")}\n"); + } + + /// The differential corpus in one repository (), each rule carrying its own text, and every file the reads open, each carrying the repository's READ text. + private static void PlantPointerCorpus(Repository repo) + { + foreach (var (file, paths, _) in PointerCorpus) repo.Commit(file, $"---\npaths: {paths}\n---\n{Mention(repo, CorpusSlug(file))}\n"); + + foreach (var read in PointerReads.Append("x/y.ts").Distinct()) repo.Commit(read, $"{SurfaceText(repo, "READ")}\n"); + } + + /// The text slug of one corpus rule, from its file name. + private static string CorpusSlug(string file) => Path.GetFileNameWithoutExtension(file).ToUpperInvariant(); + + /// The text every corpus rule of every repository carries that the CLI scopes, or that it does not. + private static IEnumerable CorpusTexts(Workspace workspace, bool scoped) => + workspace.Repositories.SelectMany(repo => PointerCorpus.Where(rule => rule.Scoped == scoped).Select(rule => SurfaceText(repo, CorpusSlug(rule.File)))); + + /// A scripted model that reads each of with the CLI's own Read tool, in order, then answers. + private static ScriptedModelUpstream ReadingUpstream(Workspace workspace, IEnumerable reads) => + new([], $"DONE-{workspace.Nonce}") { ClaudeCalls = reads.Select(path => new ScriptedToolCall("Read", new JsonObject { ["file_path"] = path })).ToList() }; + + /// The unpinned control: the production spec without its settings pin and without its pointer rules, so the CLI attaches the repository's rules itself. + private static SandboxSpec WithoutTheSettingsPinOrPointers(SandboxSpec spec) + { + var args = spec.Args.ToList(); + var at = args.IndexOf("--setting-sources"); + + args.RemoveRange(at, 2); + + return spec with { Args = args, ConfigHomeFiles = spec.ConfigHomeFiles.Where(file => !file.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal)).ToList() }; + } + + /// The positive control: every pointer rule replaced by the text of the repository rule it names, as a harness that copied repository rules into the config home would write it. + private static SandboxSpec WithPointersCopiedFromTheirRules(SandboxSpec spec) => spec with + { + ConfigHomeFiles = spec.ConfigHomeFiles.Select(file => file.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal) ? file with { Content = File.ReadAllText(PointedRule().Match(file.Content).Groups[1].Value) } : file).ToList(), + }; + + /// A repository's scoped rule, as the cwd spells it. + private static string ScopedRuleOf(Repository repo) => Path.Combine(repo.Directory, ".claude", "rules", "ts.md"); + + /// The sentence a pointer to carries — the production template, spelled out. + private static string RuleSentence(string rule) => $"The repository rule `{rule}` applies to the file you just read. Read it now and follow it for files it matches."; + + /// The whole pointer file a repository's scoped rule gets: its glob as written at a single repository's root, rebased below the workspace root otherwise. + private static string ExpectedPointer(Workspace workspace, Repository repo) + { + var below = Path.GetRelativePath(workspace.Directory, repo.Directory); + + return $"---\npaths:\n - \"{(below == "." ? "sub/**/*.ts" : $"/{below}/sub/**/*.ts")}\"\n---\n{RuleSentence(ScopedRuleOf(repo))}\n"; + } + + /// A declaration carrying as its bearer, as the run's MCP declaration carries its run token. + private static string McpDeclaration(string token) => new JsonObject { ["mcpServers"] = new JsonObject { ["codespace"] = new JsonObject { ["type"] = "http", ["url"] = "http://127.0.0.1:9/mcp", ["headers"] = new JsonObject { ["Authorization"] = $"Bearer {token}" } } } }.ToJsonString(); + + /// The text of the tool_result the CLI sent back for the scripted call at — the main loop's, or a subagent's by its — empty when none did. + private static string ToolResult(ScriptedModelUpstream upstream, int step, string prefix = ScriptedModelUpstream.ToolIdPrefix) => + upstream.Requests.Select(r => TryParse(r.Body)).OfType().SelectMany(body => Items(body, "messages")).SelectMany(message => Items(message, "content")) + .Where(block => Text(block, "type") == "tool_result" && Text(block, "tool_use_id") == prefix + step) + .Select(block => block.TryGetProperty("content", out var content) ? content.ToString() : "").FirstOrDefault() ?? ""; + + /// Every way a pointer's sentence starts, a rule's or a directory's, that a run's first request holds: none may. + private static IEnumerable PointersUpFront(string first) => new[] { "The repository rule `", "Repository instructions for `" }.Where(start => first.Contains(start, StringComparison.Ordinal)); + + /// The pointer rules a spec's config home carries. + private static List PointerFiles(SandboxSpec spec) => spec.ConfigHomeFiles.Where(file => file.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal)).ToList(); + + /// Every rule finds in one result, relative to the workspace and sorted: the same rule however the CLI spells the workspace (the physical path, under macOS's /var link). + private static IReadOnlyList Attached(Workspace workspace, string result, Regex pattern) + { + var name = Path.GetFileName(workspace.Directory) + "/"; + + return pattern.Matches(result).Select(match => match.Groups[1].Value).Where(path => path.Contains(name, StringComparison.Ordinal)).Select(path => path[(path.IndexOf(name, StringComparison.Ordinal) + name.Length)..]).Where(path => Path.GetFileName(path).StartsWith("c-", StringComparison.Ordinal) && Path.GetFileName(path) != "c-star.md").Distinct().Order(StringComparer.Ordinal).ToList(); + } + + /// A repository rule the CLI attaches itself: its contents, headed by its path. + [GeneratedRegex(@"Contents of ([^\s:\\]+/\.claude/rules/[^\s:\\]+\.md)")] + private static partial Regex NativeRule(); + + /// A repository rule a pointer names. + [GeneratedRegex("The repository rule `([^`]+)`")] + private static partial Regex PointedRule(); +} diff --git a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs index cbb0d78f2..08d8f0cb7 100644 --- a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs @@ -36,15 +36,21 @@ namespace CodeSpace.SandboxTests; /// .claude/rules file without paths:, and sub/CLAUDE.md, whose directory the harness adds in place. /// What it drops must not reach any request, run its commands or be named on the CLI's init line: a skill /// (frontmatter hooks, ! shell), a command (! shell), an agent (permissionMode, hooks, -/// mcpServers), CLAUDE.local.md, a rule scoped by paths:, and the output style the repository's -/// settings select. The unpinned CLI attached the scoped rule only once the run opened a file below sub/, and ran +/// mcpServers), CLAUDE.local.md, the text of a rule scoped by paths: — which a pointer names +/// instead, so its own text never reaches the model — and the output style the repository's settings select. The +/// unpinned CLI attached the scoped rule only once the run opened a file below sub/, and ran /// a skill's or command's commands only once invoked, so the scripted model opens sub/notes.txt with the CLI's own /// Read tool, invokes the skill and the command, and delegates to the agent (). The single-repo Codex arm also names /// a repository skill whose agents/openai.yaml depends on an MCP server, which must not start. A repository whose /// memory links outside the workspace is left out of the run whole, against a positive control that adds it back /// (), and nested memory is /// loaded in place within its budget and not past it, with nothing it must not load -/// (). +/// (). A scoped rule, and nested memory +/// past the budget, is pointed at: the pointer attaches on the reads that attached the repository's own, and on no other +/// (, +/// ), and so is nested memory loaded in +/// place, for the Explore subagent the CLI starts without it +/// (). /// /// Fidelity: 🟢 HIGH for everything but the model. The pinned CLI binaries, the production harness argv /// (), the production (bubblewrap where the @@ -130,7 +136,7 @@ public sealed partial class RepositoryConfigE2ETests(ITestOutputHelper output) : ["COMMAND"] = "a command", ["AGENT"] = "an agent", ["LOCAL-MEMORY"] = "CLAUDE.local.md", - ["SCOPED-RULE"] = "a rule scoped by paths:", + ["SCOPED-RULE"] = "the text of a rule scoped by paths:, which only a pointer names", ["OUTPUT-STYLE"] = "the output style its settings select", }; diff --git a/backend/tests/CodeSpace.SandboxTests/ScriptedModelUpstream.cs b/backend/tests/CodeSpace.SandboxTests/ScriptedModelUpstream.cs index 2366afc61..29ac2f0fa 100644 --- a/backend/tests/CodeSpace.SandboxTests/ScriptedModelUpstream.cs +++ b/backend/tests/CodeSpace.SandboxTests/ScriptedModelUpstream.cs @@ -22,8 +22,11 @@ namespace CodeSpace.SandboxTests; /// internal sealed class ScriptedModelUpstream(IReadOnlyList commands, string finalText) : HttpMessageHandler { - /// The prefix every scripted tool call's id carries, so a later turn can count how many it has already answered. - private const string ToolIdPrefix = "toolu_review_"; + /// The prefix every scripted tool call's id carries, followed by its step, so a later turn can count how many it has already answered and a test can find each call's result. + internal const string ToolIdPrefix = "toolu_review_"; + + /// The same for the calls of 's script, which runs in a conversation of its own. + internal const string SubagentToolIdPrefix = "toolu_subagent_"; private readonly ConcurrentQueue _requests = new(); @@ -39,6 +42,20 @@ internal sealed class ScriptedModelUpstream(IReadOnlyList commands, stri /// public IReadOnlyList? ClaudeCalls { get; init; } + /// + /// The script of a subagent the main loop starts (with an Agent call in ): a turn whose + /// system prompt holds its marker is one of that subagent's, and calls the next of its calls, then answers. Its + /// conversation holds none of the main loop's calls, nor the main loop's its, so each counts its own. + /// + public ScriptedSubagent? Subagent { get; init; } + + /// + /// Whether the permission classifier's side query — the one that carries the run's <transcript> — gets the + /// verdict that lets the call through (<block>no</block>), so a plan-mode run may start a read-only + /// subagent. Off, the classifier gets no verdict it can parse and the CLI refuses the call, which other arms rely on. + /// + public bool ClassifierAllows { get; init; } + /// Every request the broker relayed, in arrival order — the model-side ground truth of what the CLI sent. public IReadOnlyList Requests => _requests.ToArray(); @@ -55,22 +72,22 @@ protected override async Task SendAsync(HttpRequestMessage if (path.EndsWith("/responses", StringComparison.Ordinal)) return ResponsesTurn(json); - if (path.EndsWith("/messages", StringComparison.Ordinal)) return MessagesTurn(json); + if (path.EndsWith("/messages", StringComparison.Ordinal)) return MessagesTurn(json, body); return Json("""{"input_tokens":11}"""); } - private HttpResponseMessage MessagesTurn(JsonObject? request) + private HttpResponseMessage MessagesTurn(JsonObject? request, string body) { var model = Text(request?["model"]) ?? "scripted-model"; var hasTools = request?["tools"] is JsonArray { Count: > 0 }; var streaming = request?["stream"] is JsonValue stream && stream.TryGetValue(out var on) && on; - // A side query (no tools: a title, a summary) answers plainly; only the main loop runs the script. - if (!hasTools) return streaming ? Sse(AnthropicText(model, "ok")) : Json(AnthropicMessage(model, new JsonArray(new JsonObject { ["type"] = "text", ["text"] = "ok" }), "end_turn")); + // A side query (no tools: a title, a summary, a classifier's verdict) answers plainly; only the main loop runs the script. + if (!hasTools) return SideAnswer(model, streaming, ClassifierAllows && body.Contains("", StringComparison.Ordinal) ? "no" : "ok"); - var step = AnsweredToolCalls(request!["messages"] as JsonArray); - var script = ClaudeCalls ?? commands.Select(BashCall).ToList(); + var (script, prefix) = Subagent is { } subagent && (request!["system"]?.ToJsonString() ?? "").Contains(subagent.SystemMarker, StringComparison.Ordinal) ? (subagent.Calls, SubagentToolIdPrefix) : (ClaudeCalls ?? commands.Select(BashCall).ToList(), ToolIdPrefix); + var step = AnsweredToolCalls(request!["messages"] as JsonArray, prefix); if (step < script.Count) { @@ -79,12 +96,15 @@ private HttpResponseMessage MessagesTurn(JsonObject? request) if (!offered.Contains(call.Name)) return Unscriptable($"The CLI offered no {call.Name} tool for this script to call; it offered: {string.Join(", ", offered)}"); - return streaming ? Sse(AnthropicToolUse(model, step, call)) : Json(AnthropicMessage(model, new JsonArray(new JsonObject { ["type"] = "tool_use", ["id"] = ToolIdPrefix + step, ["name"] = call.Name, ["input"] = call.Input.DeepClone() }), "tool_use")); + return streaming ? Sse(AnthropicToolUse(model, prefix + step, call)) : Json(AnthropicMessage(model, new JsonArray(new JsonObject { ["type"] = "tool_use", ["id"] = prefix + step, ["name"] = call.Name, ["input"] = call.Input.DeepClone() }), "tool_use")); } return streaming ? Sse(AnthropicText(model, FinalText)) : Json(AnthropicMessage(model, new JsonArray(new JsonObject { ["type"] = "text", ["text"] = FinalText }), "end_turn")); } + private static HttpResponseMessage SideAnswer(string model, bool streaming, string text) => + streaming ? Sse(AnthropicText(model, text)) : Json(AnthropicMessage(model, new JsonArray(new JsonObject { ["type"] = "text", ["text"] = text }), "end_turn")); + private HttpResponseMessage ResponsesTurn(JsonObject? request) { var answered = (request?["input"] as JsonArray)?.Count(item => Text(item?["type"]) == "function_call_output") ?? 0; @@ -113,13 +133,13 @@ private static (string Tool, JsonObject Arguments) ShellCall(JsonArray? tools, s private static ScriptedToolCall BashCall(string command) => new("Bash", new JsonObject { ["command"] = command, ["description"] = "Read the change under review" }); - private static int AnsweredToolCalls(JsonArray? messages) => - messages?.Count(message => Text(message?["role"]) == "assistant" && message!["content"] is JsonArray blocks && blocks.Any(block => Text(block?["type"]) == "tool_use" && Text(block!["id"])?.StartsWith(ToolIdPrefix, StringComparison.Ordinal) == true)) ?? 0; + private static int AnsweredToolCalls(JsonArray? messages, string prefix) => + messages?.Count(message => Text(message?["role"]) == "assistant" && message!["content"] is JsonArray blocks && blocks.Any(block => Text(block?["type"]) == "tool_use" && Text(block!["id"])?.StartsWith(prefix, StringComparison.Ordinal) == true)) ?? 0; - private static IEnumerable<(string Event, JsonObject Data)> AnthropicToolUse(string model, int step, ScriptedToolCall call) + private static IEnumerable<(string Event, JsonObject Data)> AnthropicToolUse(string model, string id, ScriptedToolCall call) { yield return AnthropicStart(model); - yield return ("content_block_start", new JsonObject { ["type"] = "content_block_start", ["index"] = 0, ["content_block"] = new JsonObject { ["type"] = "tool_use", ["id"] = ToolIdPrefix + step, ["name"] = call.Name, ["input"] = new JsonObject() } }); + yield return ("content_block_start", new JsonObject { ["type"] = "content_block_start", ["index"] = 0, ["content_block"] = new JsonObject { ["type"] = "tool_use", ["id"] = id, ["name"] = call.Name, ["input"] = new JsonObject() } }); yield return ("content_block_delta", new JsonObject { ["type"] = "content_block_delta", ["index"] = 0, ["delta"] = new JsonObject { ["type"] = "input_json_delta", ["partial_json"] = call.Input.ToJsonString() } }); yield return ("content_block_stop", new JsonObject { ["type"] = "content_block_stop", ["index"] = 0 }); yield return ("message_delta", new JsonObject { ["type"] = "message_delta", ["delta"] = new JsonObject { ["stop_reason"] = "tool_use", ["stop_sequence"] = null }, ["usage"] = new JsonObject { ["output_tokens"] = 7 } }); @@ -200,3 +220,6 @@ internal sealed record RecordedRequest(string Method, string Path, string Body); /// One call a scripted Claude turn makes: the CLI tool's name as the request offers it, and the tool's input. internal sealed record ScriptedToolCall(string Name, JsonObject Input); + +/// A subagent's script: the text its system prompt carries, which tells its turns from the main loop's, and the calls it makes. +internal sealed record ScriptedSubagent(string SystemMarker, IReadOnlyList Calls); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeCodeHarnessTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeCodeHarnessTests.cs index 85b4c181d..78678c08d 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeCodeHarnessTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeCodeHarnessTests.cs @@ -248,7 +248,85 @@ public void Nested_memory_rides_the_one_variadic_add_dir_after_the_workspace_and args.ShouldNotContain("--plugin-dir"); args.ShouldNotContain("--agents"); spec.Environment[ClaudeCodeHarness.AdditionalDirectoriesMemoryEnvVar].ShouldBe("1"); - spec.ConfigHomeFiles.ShouldBeEmpty("nested memory loads where it is: no repository byte is copied into the config home"); + spec.ConfigHomeFiles.Select(file => file.RelativePath).ShouldBe(new[] { $"{ClaudeWorkspaceMemory.PointerRulePrefix}000.md", $"{ClaudeWorkspaceMemory.PointerRulePrefix}001.md" }, "nested memory loads where it is; the config home only points a subagent at it, lib/ first"); + spec.ConfigHomeFiles.ShouldAllBe(file => !file.Content.Contains("Package memory.", StringComparison.Ordinal) && !file.Content.Contains("Tabs.", StringComparison.Ordinal), "no repository byte is copied into the config home"); + } + + [Fact] + public void Pointer_rules_ride_the_config_home_beside_persona_skills_the_stop_hook_and_a_restored_transcript() + { + // Each config-home file has its own place — skills/, projects/, the hook and its settings.json, rules/ — so a + // pointer neither replaces nor shadows any of them, and the runner writes every one where the CLI reads it. + if (OperatingSystem.IsWindows()) return; + + using var tree = new TempTree(); + var workspace = tree.Directory("ws"); + + tree.File("ws/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + + var task = Task() with + { + WorkspaceDirectory = workspace, + WorkspaceRepositoryDirectories = new[] { workspace }, + Skills = new[] { new AgentSkill { Slug = "tdd", Description = "d", Body = "b" } }, + Acceptance = new SupervisorAcceptanceSpec { Command = new[] { "sh", "check.sh" } }, + ResumeFromSessionId = "sess-p", + RestoredTranscript = "{\"line\":1}\n", + }; + + var files = Harness.BuildInvocation(task).ConfigHomeFiles; + + files.Select(f => f.RelativePath).ShouldBe(new[] { "skills/tdd/SKILL.md", ClaudeTranscriptPath.For(workspace, "sess-p"), InLoopAcceptanceHook.ScriptRelativePath, "settings.json", "rules/codespace-repository-000.md" }, "skills, the transcript and the Stop hook as before, the pointer after them"); + files.Single(f => f.RelativePath == "settings.json").Content.ShouldNotContain("rules", Case.Sensitive, "the pointer is a rule file of its own, never a setting"); + + var configHome = Path.Combine(Path.GetTempPath(), "cs-pointer-home-" + Guid.NewGuid().ToString("N")); + try + { + LocalProcessRunner.WriteConfigHomeFiles(files, configHome); + + File.ReadAllText(Path.Combine(configHome, "rules", "codespace-repository-000.md")).ShouldContain($"`{Path.Combine(workspace, ".claude", "rules", "ts.md")}`"); + File.Exists(Path.Combine(configHome, "skills", "tdd", "SKILL.md")).ShouldBeTrue(); + File.Exists(Path.Combine(configHome, ClaudeTranscriptPath.For(workspace, "sess-p"))).ShouldBeTrue(); + } + finally + { + if (Directory.Exists(configHome)) Directory.Delete(configHome, recursive: true); + } + } + + [Theory] + [InlineData("scoped rules")] + [InlineData("nested memory in place beside a scoped rule")] + [InlineData("nested memory past the budget")] + public void Whatever_memory_is_pointed_at_the_settings_pin_stays_and_no_repository_surface_rides_the_config_home(string shape) + { + // The invariant every plan keeps: one --setting-sources user, no --plugin-dir or --agents, and nothing in the + // config home but the runner's own files — here only pointers, each a rule under rules/ that holds no '@'. + if (OperatingSystem.IsWindows()) return; + + using var tree = new TempTree(); + var workspace = tree.Directory("ws"); + + tree.File("ws/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes, as @docs/types.md says.\n"); + tree.File("ws/.claude/skills/x/SKILL.md", "---\nname: x\ndescription: d\n---\nb\n"); + tree.File("ws/.claude/agents/a.md", "---\nname: a\ndescription: d\n---\nb\n"); + + if (shape != "scoped rules") tree.File("ws/pkg/CLAUDE.md", "Package.\n"); + + if (shape == "nested memory past the budget") + { + for (var i = 0; i < ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) tree.File($"ws/pkg-{i:00}/CLAUDE.md", "Package.\n"); + } + + var spec = Harness.BuildInvocation(Task() with { WorkspaceDirectory = workspace, WorkspaceRepositoryDirectories = new[] { workspace } }); + var args = spec.Args.ToList(); + + args.Count(arg => arg == "--setting-sources").ShouldBe(1, shape); + args[args.IndexOf("--setting-sources") + 1].ShouldBe("user", shape); + args.ShouldNotContain("--plugin-dir", shape); + args.ShouldNotContain("--agents", shape); + spec.ConfigHomeFiles.ShouldNotBeEmpty($"fixture check: {shape} is pointed at"); + spec.ConfigHomeFiles.ShouldAllBe(f => f.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal) && !f.Content.Contains('@') && !f.IsExecutable, shape); } [Fact] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeNestedMemoryTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeNestedMemoryTests.cs index 5b6c1b2a7..b19ace82b 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeNestedMemoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeNestedMemoryTests.cs @@ -15,7 +15,7 @@ namespace CodeSpace.UnitTests.Workflows; [Trait("Category", "Unit")] public sealed class ClaudeNestedMemoryTests : IDisposable { - private const string OverBudgetNotice = "Left the memory of every nested directory out of this run: together it spans more than 16 directories or 32768 bytes, more than a run loads before its first request."; + private const string OverBudgetNotice = "Left the memory of every nested directory out of the run's first request: together it spans more than 16 directories or 32768 bytes. A read below one of them points the run at that directory's memory instead."; private const string BudgetNotice = "Left any memory the runner had not yet found or checked out of this run: finding and checking it would take more than one build spends (65536 paths, 1048576 path components, 67108864 bytes)."; @@ -318,10 +318,11 @@ public void The_build_budget_is_pinned() } [Fact] - public void A_nested_directory_left_out_for_its_link_still_counts_against_the_budget() + public void A_nested_directory_left_out_for_its_link_still_counts_against_the_budget_and_gets_no_pointer() { - // The budget is spent before any directory's memory is followed to where it leads, so the guard runs on at most - // MaxInPlaceDirectories nested directories in one build however many link outside. + // The budget is spent before any directory's memory is followed to where it leads, so the in-place guard runs on + // at most MaxInPlaceDirectories nested directories in one build however many link outside. Past the budget each + // directory is pointed at instead, and the one that links outside is left out of that by name. if (OperatingSystem.IsWindows()) return; for (var i = 0; i < ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) _tree.File($"ws/pkg-{i:00}/CLAUDE.md", "Package.\n"); @@ -331,7 +332,9 @@ public void A_nested_directory_left_out_for_its_link_still_counts_against_the_bu var plan = Plan(); plan.Directories.ShouldBe(new[] { _workspace }); - plan.Notices.ShouldBe(new[] { OverBudgetNotice }); + plan.Notices.ShouldBe(new[] { OverBudgetNotice, "Left the memory in 'pkg-99' out of this run: CLAUDE.md resolves outside the workspace." }); + plan.Pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxInPlaceDirectories, "every directory but the one linked outside"); + plan.Pointers.ShouldNotContain(pointer => pointer.Content.Contains("pkg-99", StringComparison.Ordinal)); } [Fact] @@ -405,7 +408,11 @@ public void A_nested_directory_whose_path_could_not_ride_the_argv_safely_is_left var plan = Plan(); plan.Directories.ShouldBe(new[] { _workspace, At("packages/@scope/ui+web_v1.2-x") }, "the characters a package path commonly holds still ride the argv"); - plan.Notices.ShouldBe(new[] { $"Left the memory in '{said}' out of this run: its path holds a character other than a letter, a digit or one of . _ @ + - /." }); + plan.Notices.ShouldBe(new[] + { + $"Left the memory in '{said}' out of this run: its path holds a character other than a letter, a digit or one of . _ @ + - /.", + "Left the pointer to the memory in 'packages/@scope/ui+web_v1.2-x' out of this run: its path holds a character other than a letter, a digit or one of . _ + - /.", + }, "an @ rides the argv, but no pointer holds one: the directory loads in place and no subagent is pointed at it"); } [Fact] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudePointerRulesTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudePointerRulesTests.cs new file mode 100644 index 000000000..9dbebd473 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudePointerRulesTests.cs @@ -0,0 +1,532 @@ +using System.Text.RegularExpressions; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; +using CodeSpace.Messages.Agents; +using Shouldly; +using YamlDotNet.RepresentationModel; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// Pins the pointer rules a Claude run's config home carries (): one per rule scoped +/// by paths: anywhere in the workspace, and one per nested directory past the in-place budget, each a runner +/// sentence naming the files to read under paths: rebased onto the cwd — and never a repository byte, an +/// @, or globs the CLI would read as unconditional. Each case lays out a real tree under a GUID temp root reached +/// through a symlink (), as production never resolves a workspace path. +/// +[Trait("Category", "Unit")] +public sealed partial class ClaudePointerRulesTests : IDisposable +{ + private const string OverBudgetNotice = "Left the memory of every nested directory out of the run's first request: together it spans more than 16 directories or 32768 bytes. A read below one of them points the run at that directory's memory instead."; + + private const string BudgetNotice = "Left any memory the runner had not yet found or checked out of this run: finding and checking it would take more than one build spends (65536 paths, 1048576 path components, 67108864 bytes)."; + + private readonly TempTree _tree = new(); + private readonly string _workspace; + + public ClaudePointerRulesTests() { _workspace = _tree.Directory("ws"); } + + public void Dispose() => _tree.Dispose(); + + [Fact] + public void A_scoped_rule_gets_one_pointer_that_names_it_under_its_own_globs_and_holds_none_of_its_text() + { + if (OperatingSystem.IsWindows()) return; + + _tree.File("ws/.claude/rules/ts.md", "---\npaths:\n - \"src/**/*.ts\"\n---\nREPO-RULE-TEXT: see @docs/rule-notes.md\n"); + + var plan = Plan(); + + plan.Directories.ShouldBe(new[] { _workspace }, "a scoped rule never loads in place"); + plan.Notices.ShouldBeEmpty(); + plan.Pointers.ShouldHaveSingleItem().RelativePath.ShouldBe("rules/codespace-repository-000.md"); + plan.Pointers[0].IsExecutable.ShouldBeFalse(); + plan.Pointers[0].Content.ShouldBe($"---\npaths:\n - \"src/**/*.ts\"\n---\nThe repository rule `{At(".claude/rules/ts.md")}` applies to the file you just read. Read it now and follow it for files it matches.\n"); + } + + [Fact] + public void Past_the_in_place_budget_each_nested_directory_gets_a_pointer_naming_its_memory_files_before_its_rules() + { + if (OperatingSystem.IsWindows()) return; + + for (var i = 0; i <= ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) _tree.File($"ws/pkg-{i:00}/CLAUDE.md", "Package.\n"); + + _tree.File("ws/pkg-00/.claude/CLAUDE.md", "Package, again.\n"); + _tree.File("ws/pkg-00/.claude/rules/style.md", "Tabs.\n"); + _tree.File("ws/pkg-00/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + + var plan = Plan(); + + plan.Directories.ShouldBe(new[] { _workspace }, "past the budget no nested directory is added"); + plan.Notices.ShouldBe(new[] { OverBudgetNotice }); + plan.Pointers.Select(pointer => pointer.RelativePath).ShouldBe(Enumerable.Range(0, ClaudeWorkspaceMemory.MaxInPlaceDirectories + 2).Select(i => $"rules/codespace-repository-{i:000}.md"), "numbered in walk order"); + plan.Pointers[0].Content.ShouldBe($"---\npaths:\n - \"/pkg-00\"\n---\nRepository instructions for `pkg-00/` were not preloaded: `{At("pkg-00/CLAUDE.md")}`, `{At("pkg-00/.claude/CLAUDE.md")}`, `{At("pkg-00/.claude/rules/style.md")}`. Read them now and follow them while you work under `pkg-00/`.\n"); + Globs(plan.Pointers[1]).ShouldBe(new[] { "/pkg-00/**/*.ts" }, "a directory's own memory comes before its rules"); + plan.Pointers.Skip(2).Select(Globs).ShouldBe(Enumerable.Range(1, ClaudeWorkspaceMemory.MaxInPlaceDirectories).Select(i => new[] { $"/pkg-{i:00}" })); + } + + [Fact] + public void Within_the_budget_nested_memory_loads_in_place_and_is_pointed_at_for_a_subagent_beside_its_scoped_rules() + { + // An Explore or Plan subagent starts without project memory, so memory loaded in place reaches it only through a + // pointer; the unpinned CLI attached it there on the subagent's first read below the directory. + if (OperatingSystem.IsWindows()) return; + + _tree.File("ws/pkg/CLAUDE.md", "Package.\n"); + _tree.File("ws/pkg/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/lib/.claude/rules/py.md", "---\npaths: [\"/x/**\", \"!x/gen\"]\n---\nPython.\n"); + + var plan = Plan(); + + plan.Directories.ShouldBe(new[] { _workspace, At("pkg") }, "a directory with only a scoped rule that imports nothing holds no memory to load in place"); + plan.Pointers.Where(IsRulePointer).Select(Described).ShouldBe(new[] { "/lib/x,!/lib/x/gen -> lib/.claude/rules/py.md", "/pkg/**/*.ts -> pkg/.claude/rules/ts.md" }); + plan.Pointers.Single(pointer => !IsRulePointer(pointer)).Content.ShouldBe($"---\npaths:\n - \"/pkg\"\n---\nRepository instructions for `pkg/` are in `{At("pkg/CLAUDE.md")}`. Read them now if they are not already in your context, and follow them while you work under `pkg/`.\n"); + plan.Pointers.Select(pointer => pointer.RelativePath).ShouldBe(Enumerable.Range(0, 3).Select(i => $"rules/codespace-repository-{i:000}.md"), "numbered in walk order, a directory's memory before its rules"); + plan.Notices.ShouldBeEmpty(); + } + + [Theory] + [InlineData("single-repo")] + [InlineData("multi-repo at its root")] + [InlineData("multi-repo at its primary repository")] + public void Every_scoped_rule_below_the_cwd_is_rebased_onto_it_and_none_outside_it_is_pointed_at(string shape) + { + if (OperatingSystem.IsWindows()) return; + + var (cwd, repositories, expected) = PlantLayout(shape); + + var plan = ClaudeWorkspaceMemory.For(new AgentTask { Goal = "g", Harness = ClaudeCodeHarness.HarnessKind, WorkspaceDirectory = cwd, WorkspaceRepositoryDirectories = repositories }); + + plan.Pointers.Select(Described).ShouldBe(expected, shape); + plan.Notices.ShouldBeEmpty(shape); + } + + [Fact] + public void Rules_are_pointed_at_in_walk_order_folders_followed_by_name() + { + if (OperatingSystem.IsWindows()) return; + + _tree.File("ws/.claude/rules/c.md", "---\npaths: c\n---\nC.\n"); + _tree.File("ws/.claude/rules/b.md", "---\npaths: b\n---\nB.\n"); + _tree.File("ws/.claude/rules/a/z.md", "---\npaths: z\n---\nZ.\n"); + _tree.File("ws/.claude/rules/plain.md", "Unscoped: loads with the workspace.\n"); + _tree.File("ws/deep/er/.claude/rules/d.md", "---\npaths: d\n---\nD.\n"); + _tree.File("ws/aa/.claude/rules/e.md", "---\npaths: e\n---\nE.\n"); + + Plan().Pointers.Select(pointer => (pointer.RelativePath, Globs(pointer).Single())).ShouldBe(new[] + { + ("rules/codespace-repository-000.md", "z"), + ("rules/codespace-repository-001.md", "b"), + ("rules/codespace-repository-002.md", "c"), + ("rules/codespace-repository-003.md", "/aa/**/e"), + ("rules/codespace-repository-004.md", "/deep/er/**/d"), + }); + } + + [Theory] + [InlineData("ws/.claude/rules/x.md", "---\npaths: \"my dir/*.ts\"\n---\nR.\n", "Left the rule '.claude/rules/x.md' out of this run: its paths: hold 'my dir/*.ts', which no pointer can carry exactly.")] + [InlineData("ws/.claude/rules/x.md", "---\npaths: \"@scope/**\"\n---\nR.\n", "Left the rule '.claude/rules/x.md' out of this run: its paths: hold '@scope', which no pointer can carry exactly.")] + [InlineData("ws/.claude/rules/x.md", "---\npaths: [\"gen/**\", \"!gen/my keep\"]\n---\nR.\n", "Left the rule '.claude/rules/x.md' out of this run: its paths: hold '!gen/my keep', which no pointer can carry exactly.")] + [InlineData("ws/.claude/rules/x.md", "---\npaths: \"{a,b,c\"\n---\nR.\n", "Left the rule '.claude/rules/x.md' out of this run: its paths: hold '{a,b,c', which no pointer can carry exactly.")] + [InlineData("ws/.claude/rules/my rule.md", "---\npaths: src\n---\nR.\n", "Left the rule '.claude/rules/my rule.md' out of this run: its path holds a character other than a letter, a digit or one of . _ + - /.")] + [InlineData("ws/packages/@scope/ui/.claude/rules/x.md", "---\npaths: src\n---\nR.\n", "Left the rule 'packages/@scope/ui/.claude/rules/x.md' out of this run: its path holds a character other than a letter, a digit or one of . _ + - /.")] + public void A_rule_no_pointer_can_carry_exactly_gets_none_and_is_named(string rule, string text, string notice) + { + // One glob it cannot carry and the whole rule goes: dropping only that glob could drop a negation and widen the rule. + if (OperatingSystem.IsWindows()) return; + + _tree.File(rule, text); + + var plan = Plan(); + + plan.Pointers.ShouldBeEmpty(); + plan.Notices.ShouldBe(new[] { notice }); + } + + [Fact] + public void A_nested_directory_past_the_budget_whose_path_holds_an_at_sign_gets_no_pointer_and_is_named() + { + // An @ rides the argv safely, so the directory would load in place within the budget; a pointer holds none at all. + if (OperatingSystem.IsWindows()) return; + + for (var i = 0; i < ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) _tree.File($"ws/pkg-{i:00}/CLAUDE.md", "Package.\n"); + + _tree.File("ws/packages/@scope/CLAUDE.md", "Scoped package.\n"); + + var plan = Plan(); + + plan.Pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxInPlaceDirectories); + plan.Notices.ShouldBe(new[] { OverBudgetNotice, "Left the memory in 'packages/@scope' out of this run: its path holds a character other than a letter, a digit or one of . _ + - /." }); + } + + [Theory] + [InlineData("a scoped rule beside a nested CLAUDE.md that imports an outside file", "Left the memory in 'pkg' out of this run: a file CLAUDE.md imports resolves outside the workspace.")] + [InlineData("a scoped rule that imports an outside file, alone in its directory", "Left the memory in 'pkg' out of this run: a file .claude/rules/ts.md imports resolves outside the workspace.")] + [InlineData("a scoped rule in a .claude directory linked outside", "Left the memory in 'pkg' out of this run: .claude resolves outside the workspace.")] + [InlineData("a scoped rule beside the workspace's own CLAUDE.md linked outside", "Left the memory in the workspace out of this run: CLAUDE.md resolves outside the workspace.")] + public void A_directory_whose_memory_reaches_outside_the_workspace_gets_no_pointer_and_is_named_once(string shape, string notice) + { + // A pointer names files the model may read, so the directory it points into passes the same guard as an --add-dir. + if (OperatingSystem.IsWindows()) return; + + var secret = _tree.File("outside/secret.md", "OUTSIDE"); + const string scoped = "---\npaths: \"*.ts\"\n---\nTypes.\n"; + + switch (shape) + { + case "a scoped rule beside a nested CLAUDE.md that imports an outside file": + _tree.File("ws/pkg/CLAUDE.md", "See @../../outside/secret.md\n"); + _tree.File("ws/pkg/.claude/rules/ts.md", scoped); + break; + case "a scoped rule that imports an outside file, alone in its directory": + _tree.File("ws/pkg/.claude/rules/ts.md", scoped + "See @../../../../outside/secret.md\n"); + break; + case "a scoped rule in a .claude directory linked outside": + _tree.File("outside/dot/rules/ts.md", scoped); + _tree.Link("ws/pkg/.claude", Path.Combine(_tree.Root, "outside", "dot")); + break; + case "a scoped rule beside the workspace's own CLAUDE.md linked outside": + _tree.Link("ws/CLAUDE.md", secret); + _tree.File("ws/.claude/rules/ts.md", scoped); + break; + } + + var plan = Plan(); + + plan.Pointers.ShouldBeEmpty(shape); + plan.Notices.ShouldBe(new[] { notice }, shape); + } + + [Fact] + public void No_pointer_holds_an_at_sign_a_repository_byte_or_anything_but_paths_in_its_frontmatter() + { + // The property behind every pointer, over a tree that tries each way in: rule text full of imports and mentions, + // globs and names with @, an email in nested memory, a dot-directory and node_modules, both kinds of pointer. + if (OperatingSystem.IsWindows()) return; + + // An import that resolves outside would leave its directory out (the guard), so these name nothing that exists. + const string text = "REPO-TEXT @/cs-no-such-directory/passwd @~/.mcp.json @\"~/.mcp.json\" 。@x mail@example.com\n"; + + for (var i = 0; i <= ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) _tree.File($"ws/pkg-{i:00}/CLAUDE.md", text); + + _tree.File("ws/.claude/rules/a.md", $"---\npaths: [\"src/**\", \"!src/gen\", \"*.ts\"]\n---\n{text}"); + _tree.File("ws/.claude/rules/b.md", $"---\npaths: \"@x/**\"\n---\n{text}"); + _tree.File("ws/pkg-03/.claude/rules/c.md", $"---\npaths: \"{{lib/a,b}}\"\n---\n{text}"); + _tree.File("ws/web/@org/ui/.claude/rules/d.md", $"---\npaths: src\n---\n{text}"); + _tree.File("ws/web/@org/ui/CLAUDE.md", text); + _tree.File("ws/.github/.claude/rules/e.md", $"---\npaths: src\n---\n{text}"); + _tree.File("ws/node_modules/x/.claude/rules/f.md", $"---\npaths: src\n---\n{text}"); + + var plan = Plan(); + + plan.Pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxInPlaceDirectories + 1 + 2, "fixture check: every directory pointer and the a.md and c.md rule pointers"); + plan.Pointers.ShouldAllBe(pointer => !pointer.Content.Contains('@') && !pointer.Content.Contains("REPO-TEXT", StringComparison.Ordinal) && !pointer.Content.Contains('\'')); + plan.Pointers.SelectMany(pointer => pointer.Content.Split('\n')).ShouldNotContain(line => MentionStart().IsMatch(line)); + plan.Pointers.ShouldAllBe(pointer => pointer.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal) && !pointer.RelativePath.Contains("..")); + plan.Pointers.Select(FrontmatterKeys).ShouldAllBe(keys => keys.SequenceEqual(new[] { "paths" })); + plan.Pointers.ShouldAllBe(pointer => ClaudeRuleScope.Read(pointer.Content) != null, "no pointer is unconditional"); + } + + [Fact] + public void A_run_carries_no_more_pointers_than_the_bound_and_says_so() + { + if (OperatingSystem.IsWindows()) return; + + for (var i = 0; i <= ClaudeWorkspaceMemory.MaxPointerRules; i++) _tree.File($"ws/.claude/rules/r{i:000}.md", $"---\npaths: src/{i}\n---\nRule.\n"); + + var plan = Plan(); + + plan.Pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxPointerRules); + plan.Pointers[^1].RelativePath.ShouldBe("rules/codespace-repository-127.md"); + plan.Notices.ShouldBe(new[] { "Left the repository rules and nested memory past the first 128 pointers out of this run: the runner writes no more." }); + } + + [Fact] + public void A_run_checks_no_more_directories_for_pointers_than_the_bound_and_says_so() + { + // Every directory's memory reaches outside, so none gets a pointer: the guard, which reads each one's imports, + // still runs on no more than the bound. + if (OperatingSystem.IsWindows()) return; + + _tree.File("outside/secret.md", "OUTSIDE"); + + for (var i = 0; i <= ClaudeWorkspaceMemory.MaxPointerRules; i++) _tree.File($"ws/d{i:000}/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nSee @../../../../outside/secret.md\n"); + + var plan = Plan(); + + // Each directory's rule imports a file, so the first seventeen also count against the in-place budget. + plan.Pointers.ShouldBeEmpty(); + plan.Notices.Count.ShouldBe(ClaudeWorkspaceMemory.MaxPointerRules + 2); + plan.Notices[0].ShouldBe(OverBudgetNotice); + plan.Notices[^1].ShouldBe("Left the repository rules and nested memory past the first 128 pointers out of this run: the runner writes no more."); + plan.Notices.ShouldNotContain(notice => notice.Contains($"'d{ClaudeWorkspaceMemory.MaxPointerRules:000}'", StringComparison.Ordinal), "the directory past the bound is never checked"); + } + + [Fact] + public void A_rule_the_cli_reads_without_globs_loads_in_place_and_gets_no_pointer_of_its_own() + { + if (OperatingSystem.IsWindows()) return; + + _tree.File("ws/pkg/.claude/rules/all.md", "---\npaths: \"**\"\n---\nEverywhere.\n"); + + var plan = Plan(); + + plan.Directories.ShouldBe(new[] { _workspace, At("pkg") }); + plan.Pointers.ShouldNotContain(pointer => IsRulePointer(pointer)); + plan.Pointers.ShouldHaveSingleItem().Content.ShouldContain($"are in `{At("pkg/.claude/rules/all.md")}`", Case.Sensitive, "the directory's own pointer names it as memory"); + } + + [Fact] + public void A_scoped_rule_written_after_one_build_is_pointed_at_in_the_next() + { + // Every build walks again — a revise round's included — so a rule an earlier round's agent wrote is pointed at. + if (OperatingSystem.IsWindows()) return; + + Plan().Pointers.ShouldBeEmpty("fixture check: no rule yet"); + + _tree.File("ws/lib/.claude/rules/py.md", "---\npaths: \"*.py\"\n---\nPython.\n"); + + Plan().Pointers.Select(Globs).ShouldBe(new[] { new[] { "/lib/**/*.py" } }); + } + + [Fact] + public void A_directory_of_ten_thousand_rules_gets_a_pointer_within_its_byte_bound_that_counts_what_it_cannot_name() + { + // Past the budget a directory's pointer named every memory file: thousands of rules, empty ones included, made one + // pointer megabytes long, and the read that attached it ended the run. + if (OperatingSystem.IsWindows()) return; + + PlantOverBudget(); + + for (var i = 0; i < 10000; i++) _tree.File($"ws/pkg-00/.claude/rules/r{i:00000}.md", ""); + + var pointer = Plan().Pointers[0]; + var named = Regex.Matches(pointer.Content, @"`(/[^`]+\.md)`").Select(match => match.Groups[1].Value).ToList(); + + pointer.Content.Length.ShouldBeLessThanOrEqualTo(ClaudeWorkspaceMemory.MaxPointerBytes); + named[0].ShouldBe(At("pkg-00/CLAUDE.md"), "the directory's CLAUDE.md is named first"); + named.Count.ShouldBeGreaterThan(1, "as many files are named as fit"); + pointer.Content.ShouldContain($" and {10001 - named.Count} more memory files under `{At("pkg-00")}/.claude/`. Read them now", Case.Sensitive); + } + + [Fact] + public void A_launch_whose_tree_names_thousands_of_long_memory_paths_still_fits_its_frame() + { + // Three directories of 6300 rules each, every path 900-odd bytes: named one by one, their pointers outgrew the + // 16 MiB launch frame and the launch was refused as if its goal were too long. + if (OperatingSystem.IsWindows()) return; + + var folder = string.Join('/', "abc".Select(letter => new string(letter, 250))); + + PlantOverBudget(); + + for (var directory = 0; directory < 3; directory++) + { + for (var i = 0; i < 6300; i++) _tree.File($"ws/pkg-{directory:00}/.claude/rules/{folder}/r{i:00000}.md", ""); + } + + var spec = new ClaudeCodeHarness().BuildInvocation(new AgentTask { Goal = "g", Harness = ClaudeCodeHarness.HarnessKind, WorkspaceDirectory = _workspace, WorkspaceRepositoryDirectories = [_workspace] }); + var pointers = spec.ConfigHomeFiles.Where(file => file.RelativePath.StartsWith(ClaudeWorkspaceMemory.PointerRulePrefix, StringComparison.Ordinal)).ToList(); + + pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxInPlaceDirectories + 1, "fixture check: one pointer per directory past the budget"); + pointers.ShouldAllBe(pointer => pointer.Content.Length <= ClaudeWorkspaceMemory.MaxPointerBytes); + pointers.Sum(pointer => pointer.Content.Length).ShouldBeLessThanOrEqualTo(ClaudeWorkspaceMemory.MaxPointerTotalBytes); + NativeLaunchProtocol.FitsTheFrame(spec).ShouldBeTrue(); + } + + [Fact] + public void All_pointers_together_stay_within_their_byte_bound_and_say_where_they_stopped() + { + if (OperatingSystem.IsWindows()) return; + + var globs = string.Join(", ", Enumerable.Range(0, 40).Select(i => $"\"src/area-{i:00}/**/*.ts\"")); + + for (var i = 0; i < 120; i++) _tree.File($"ws/.claude/rules/r{i:000}.md", $"---\npaths: [{globs}]\n---\nRule.\n"); + + var plan = Plan(); + var notice = plan.Notices.ShouldHaveSingleItem(); + + plan.Pointers.Sum(pointer => pointer.Content.Length).ShouldBeLessThanOrEqualTo(ClaudeWorkspaceMemory.MaxPointerTotalBytes); + plan.Pointers.Count.ShouldBeLessThan(120, "fixture check: the pointers together run past the bound"); + notice.ShouldBe($"Left the repository rules and nested memory past the first {plan.Pointers.Count} pointers out of this run: together they would run past {ClaudeWorkspaceMemory.MaxPointerTotalBytes} bytes."); + } + + [Fact] + public void A_memory_file_whose_path_no_pointer_may_carry_is_left_out_of_its_directorys_pointer_alone() + { + // The directory's own CLAUDE.md stays named: one unsafe name refused the whole pointer, blaming the directory. + if (OperatingSystem.IsWindows()) return; + + PlantOverBudget(); + _tree.File("ws/pkg-05/.claude/rules/team notes.md", "Team notes.\n"); + _tree.File("ws/pkg-05/.claude/rules/my style.md", "Style.\n"); + + var plan = Plan(); + + plan.Pointers.Count.ShouldBe(ClaudeWorkspaceMemory.MaxInPlaceDirectories + 1); + plan.Pointers[5].Content.ShouldContain($"were not preloaded: `{At("pkg-05/CLAUDE.md")}`. Read them now", Case.Sensitive); + plan.Notices.ShouldBe(new[] { OverBudgetNotice, "Left the memory file 'pkg-05/.claude/rules/my style.md' and 1 more like it out of the pointer to 'pkg-05': its path holds a character other than a letter, a digit or one of . _ + - /." }); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void At_a_primary_repository_cwd_a_rule_linked_into_the_sibling_gets_no_pointer(bool intoSibling, bool pointed) + { + // The CLI skips a rules entry that links outside its cwd, so it never attached the rule; neither may a pointer. + if (OperatingSystem.IsWindows()) return; + + var (cwd, sibling) = (At("primary"), At("sibling")); + + _tree.File("ws/sibling/shared-rules/ts.md", "---\npaths: \"*.ts\"\n---\nA sibling's rule.\n"); + _tree.File("ws/primary/shared-rules/ts.md", "---\npaths: \"*.ts\"\n---\nThe cwd's own rule.\n"); + _tree.Link("ws/primary/.claude/rules/ts.md", intoSibling ? "../../../sibling/shared-rules/ts.md" : "../../shared-rules/ts.md"); + + var plan = ClaudeWorkspaceMemory.For(new AgentTask { Goal = "g", Harness = ClaudeCodeHarness.HarnessKind, WorkspaceDirectory = cwd, WorkspaceRepositoryDirectories = [cwd, sibling] }); + + plan.Pointers.Count.ShouldBe(pointed ? 1 : 0); + plan.Notices.ShouldBeEmpty(); + } + + [Fact] + public void A_scoped_rule_whose_frontmatter_runs_past_the_head_gets_its_pointer() + { + // Classified from its first 4 KiB alone, the rule read as unconditional and got no pointer, and nothing said so. + if (OperatingSystem.IsWindows()) return; + + var globs = Enumerable.Range(0, 120).Select(i => $"d{i:000}/*.md").Append("src/**/*.ts").ToList(); + + _tree.File("ws/pkg/.claude/rules/big.md", $"---\ndescription: {new string('d', 2500)}\npaths:\n{string.Concat(globs.Select(glob => $" - \"{glob}\"\n"))}---\nBig.\n"); + + File.ReadAllText(At("pkg/.claude/rules/big.md")).LastIndexOf("---\n", StringComparison.Ordinal).ShouldBeGreaterThan(ClaudeRuleScope.MaxHeadBytes, "fixture check: the fence closes past the head"); + + var plan = Plan(); + + plan.Directories.ShouldBe(new[] { _workspace }, "a scoped rule loads nothing in place"); + Globs(plan.Pointers.ShouldHaveSingleItem()).ShouldBe(globs.Select(glob => glob.StartsWith("src/", StringComparison.Ordinal) ? "/pkg/src/**/*.ts" : $"/pkg/{glob}")); + plan.Notices.ShouldBeEmpty(); + } + + [Fact] + public void A_glob_that_would_close_its_pointers_frontmatter_gets_no_pointer_and_is_named() + { + // Brace expansion makes ---x from {-,b}--x: written into a pointer, the CLI would close its frontmatter there and + // read the pointer with other globs than the runner wrote. + if (OperatingSystem.IsWindows()) return; + + _tree.File("ws/.claude/rules/x.md", "---\npaths: \"{-,b}--x\"\n---\nR.\n"); + + var plan = Plan(); + + ClaudeRuleScope.Read(File.ReadAllText(At(".claude/rules/x.md"))).ShouldBe(new[] { "---x", "b--x" }, "fixture check: the CLI scopes the rule to both globs"); + plan.Pointers.ShouldBeEmpty(); + plan.Notices.ShouldBe(new[] { "Left the rule '.claude/rules/x.md' out of this run: the CLI would read its pointer with other globs than the runner wrote." }); + } + + [Theory] + [InlineData("each directory's own links into a long chain")] + [InlineData("one shared file of links into a long chain")] + public async Task Checking_every_directory_a_pointer_points_into_stays_within_the_build_budget(string shape) + { + // Each pointed directory was checked against a budget of its own: 128 small directories whose memory climbs a long + // chain of links held the build for minutes. Shared, the chain is walked once; apart, the budget runs out. + if (OperatingSystem.IsWindows()) return; + + var shared = shape.StartsWith("one", StringComparison.Ordinal); + + PlantChain(); + + for (var i = 0; i < 20; i++) _tree.Link($"ws/.y/i{i:000}", "../.x/L0"); + + for (var directory = 0; directory < ClaudeWorkspaceMemory.MaxPointerRules; directory++) + { + if (!shared) for (var i = 0; i < 20; i++) _tree.Link($"ws/.y/d{directory:000}/i{i:000}", "../../.x/L0"); + + _tree.File($"ws/d{directory:000}/CLAUDE.md", string.Concat(Enumerable.Range(0, 20).Select(i => shared ? $"@../.y/i{i:000} " : $"@../.y/d{directory:000}/i{i:000} "))); + } + + var plan = await Task.Run(Plan).WaitAsync(TimeSpan.FromSeconds(30)); + + plan.Pointers.Count.ShouldBe(shared ? ClaudeWorkspaceMemory.MaxPointerRules : plan.Pointers.Count, shape); + plan.Notices.Contains(BudgetNotice).ShouldBe(!shared, shape); + } + + [Fact] + public void The_pointer_bounds_and_file_names_are_pinned() + { + // Committed values, changed by PR: how many pointers one run carries, how large, and where in its config home. + ClaudeWorkspaceMemory.MaxPointerRules.ShouldBe(128); + ClaudeWorkspaceMemory.MaxPointerBytes.ShouldBe(4096); + ClaudeWorkspaceMemory.MaxPointerTotalBytes.ShouldBe(131072); + ClaudeWorkspaceMemory.PointerRulePrefix.ShouldBe("rules/codespace-repository-"); + } + + /// The CLI's own import grammar's start: an @ at the start of a line, after whitespace (JavaScript's) or after CJK punctuation. + [GeneratedRegex("(^|[\\s  。、?!])@")] + private static partial Regex MentionStart(); + + private ClaudeWorkspaceMemory.Plan Plan() => + ClaudeWorkspaceMemory.For(new AgentTask { Goal = "g", Harness = ClaudeCodeHarness.HarnessKind, WorkspaceDirectory = _workspace, WorkspaceRepositoryDirectories = [_workspace] }); + + /// The path at below the workspace, as the workspace spells it. + private string At(string relative) => Path.Combine(_workspace, relative); + + /// The globs the CLI reads from a pointer. + private static string[] Globs(ConfigHomeFile pointer) => ClaudeRuleScope.Read(pointer.Content).ShouldNotBeNull($"{pointer.RelativePath} must be scoped").ToArray(); + + /// A rule pointer as its globs and the rule it names, relative to the workspace. + private string Described(ConfigHomeFile pointer) => $"{string.Join(',', Globs(pointer))} -> {Path.GetRelativePath(_workspace, Named(pointer))}"; + + /// One more nested directory than loads in place, pkg-00 to pkg-16, each with a CLAUDE.md. + private void PlantOverBudget() + { + for (var i = 0; i <= ClaudeWorkspaceMemory.MaxInPlaceDirectories; i++) _tree.File($"ws/pkg-{i:00}/CLAUDE.md", "Package.\n"); + } + + /// A chain of 39 links, ws/.x/L0 on, each target climbing d/../ 150 times before it names the next link; the last names ws/.x/real.md. + private void PlantChain() + { + var padding = string.Concat(Enumerable.Repeat("d/../", 150)); + + _tree.Directory("ws/.x/d"); + _tree.File("ws/.x/real.md", "Real.\n"); + + for (var hop = 38; hop >= 0; hop--) _tree.Link($"ws/.x/L{hop}", padding + (hop == 38 ? "real.md" : $"L{hop + 1}")); + } + + /// Whether the pointer names a scoped rule rather than a directory's memory. + private static bool IsRulePointer(ConfigHomeFile pointer) => pointer.Content.Contains("The repository rule `", StringComparison.Ordinal); + + /// The rule a rule pointer names. + private static string Named(ConfigHomeFile pointer) => Regex.Match(pointer.Content, "The repository rule `([^`]+)`").Groups[1].Value; + + /// The keys of a pointer's frontmatter mapping. + private static IEnumerable FrontmatterKeys(ConfigHomeFile pointer) + { + var yaml = new YamlStream(); + yaml.Load(new StringReader(pointer.Content.Split("---\n")[1])); + return ((YamlMappingNode)yaml.Documents[0].RootNode).Children.Keys.Select(key => ((YamlScalarNode)key).Value!); + } + + private (string Cwd, string[] Repositories, string[] Expected) PlantLayout(string shape) + { + switch (shape) + { + case "single-repo": + _tree.File("ws/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/pkg/.claude/rules/md.md", "---\npaths: [\"docs/*.md\", \"/top/**\"]\n---\nDocs.\n"); + return (_workspace, [_workspace], ["*.ts -> .claude/rules/ts.md", "/pkg/docs/*.md,/pkg/top -> pkg/.claude/rules/md.md"]); + case "multi-repo at its root": + _tree.File("ws/r2/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/r1/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/r1/sub/.claude/rules/x.md", "---\npaths: \"/x/**\"\n---\nX.\n"); + return (_workspace, [At("r1"), At("r2")], ["/r1/**/*.ts -> r1/.claude/rules/ts.md", "/r2/**/*.ts -> r2/.claude/rules/ts.md", "/r1/sub/x -> r1/sub/.claude/rules/x.md"]); + case "multi-repo at its primary repository": + _tree.File("ws/repo-a/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/repo-a/lib/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nTypes.\n"); + _tree.File("ws/repo-b/.claude/rules/ts.md", "---\npaths: \"*.ts\"\n---\nA sibling the cwd does not hold.\n"); + return (At("repo-a"), [At("repo-a"), At("repo-b")], ["*.ts -> repo-a/.claude/rules/ts.md", "/lib/**/*.ts -> repo-a/lib/.claude/rules/ts.md"]); + default: + throw new ArgumentOutOfRangeException(nameof(shape), shape, null); + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeRuleScopeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeRuleScopeTests.cs index af026dd78..7f1f77354 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeRuleScopeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeRuleScopeTests.cs @@ -109,4 +109,135 @@ public void A_brace_pattern_past_the_clis_budget_stays_as_it_is() public void The_head_bound_is_pinned() => // A committed value, changed by PR: how much of each rule the walk reads to classify it. ClaudeRuleScope.MaxHeadBytes.ShouldBe(4096); + + // ── Rebasing a rule's globs onto the cwd, for a pointer rule ── + + [Theory] + [InlineData("", "src/**/*.ts", "src/**/*.ts")] // the rule's directory is the cwd: as written + [InlineData("", "*.ts", "*.ts")] + [InlineData("", "/top", "/top")] + [InlineData("", "!gen", "!gen")] + [InlineData("a", "*.ts", "/a/**/*.ts")] // no slash: any depth below the rule's directory + [InlineData("a", "src", "/a/**/src")] + [InlineData("a", "src/", "/a/**/src/")] // a trailing slash alone anchors nothing + [InlineData("a", "x?.ts", "/a/**/x?.ts")] + [InlineData("a", "**", "/a/**/**")] + [InlineData("a", "lib/a", "/a/lib/a")] // a slash in the middle: below the rule's directory only + [InlineData("a", "/x", "/a/x")] // a leading slash: the same + [InlineData("a", "/x/", "/a/x/")] + [InlineData("a", "**/x", "/a/**/x")] + [InlineData("a", "!gen", "!/a/**/gen")] // a negation keeps negating + [InlineData("a", "!lib/gen", "!/a/lib/gen")] + [InlineData("pkg/sub", "x/*.md", "/pkg/sub/x/*.md")] + [InlineData("repo-1/pkg_v1.2+x", "*.md", "/repo-1/pkg_v1.2+x/**/*.md")] + public void A_glob_is_rebased_onto_the_cwd_as_gitignore_anchors_it(string below, string glob, string rebased) => + ClaudeRuleScope.Rebase(below, glob).ShouldBe(rebased); + + [Theory] + [InlineData("a\"b")] + [InlineData("a'b")] + [InlineData("a\\b")] + [InlineData("a b")] + [InlineData("a\nb")] + [InlineData("a\tb")] + [InlineData("a`b")] + [InlineData("a:b")] + [InlineData("#a")] + [InlineData("a#b")] + [InlineData("[ab]")] + [InlineData("{a,b}")] + [InlineData("a,b")] + [InlineData("@scope/x")] + [InlineData("a/@b")] + [InlineData("~/x")] + [InlineData("$HOME")] + [InlineData("a|b")] + [InlineData("a>b")] + [InlineData("a%b")] + [InlineData("a&b")] + [InlineData("na\u00efve")] + [InlineData("a!b")] + [InlineData("!!a")] + [InlineData("!")] + [InlineData("")] + [InlineData("/")] + [InlineData("//abs")] + [InlineData("a//b")] + [InlineData("x//")] + [InlineData("..")] + [InlineData("../x")] + [InlineData("a/../b")] + [InlineData("!../x")] + [InlineData(".")] + [InlineData("./x")] + [InlineData("a/./b")] + public void A_glob_a_pointer_cannot_carry_exactly_is_refused(string glob) + { + ClaudeRuleScope.Rebase("a", glob).ShouldBeNull(glob); + ClaudeRuleScope.Rebase("", glob).ShouldBeNull(glob); + } + + [Theory] + [InlineData("my pkg")] + [InlineData("@scope/x")] + [InlineData("a/../b")] + [InlineData("./a")] + [InlineData("/a")] + [InlineData("a/")] + [InlineData("a//b")] + public void A_directory_a_pointer_cannot_name_exactly_is_refused(string below) => + ClaudeRuleScope.Rebase(below, "*.ts").ShouldBeNull(below); + + [Fact] + public void Every_rebased_rule_reads_back_through_the_clis_own_parse_scoped_and_as_written() + { + // The output invariant: a pointer's frontmatter, read the way the CLI reads it, gives back exactly the rebased + // globs — never none, never ** alone (which would load the pointer before the first request at the user's + // authority), never with a trailing /** the CLI drops, which would change what a glob anchors. + var rules = new[] + { + "---\npaths:\n - \"src/**/*.ts\"\n - lib/x\n---\n", + "---\npaths: \"*.ts\"\n---\n", + "---\npaths: src/**\n---\n", + "---\npaths: src/**/**\n---\n", + "---\npaths: [\"**\", \"!gen\"]\n---\n", + "---\npaths: \"{lib/a,b}\"\n---\n", + "---\npaths: \"docs/x, y\"\n---\n", + "---\npaths:\n - \"gen/**/*.txt\"\n - \"!gen/keep/*.txt\"\n---\n", + "---\npaths: [\"/top/**\", \"**/\", \"x?.md\", \"a/\"]\n---\n", + "---\npaths: '2024'\n---\n", + }; + + foreach (var rule in rules) + { + var globs = ClaudeRuleScope.Read(rule).ShouldNotBeNull($"fixture check: {rule} is scoped"); + + foreach (var below in new[] { "", "a", "repo-1/pkg", "x.y/z_w+v-u" }) + { + var rebased = globs.Select(glob => ClaudeRuleScope.Rebase(below, glob).ShouldNotBeNull($"{glob} below '{below}'")).ToList(); + + ClaudeRuleScope.Read(ClaudeRuleScope.Frontmatter(rebased) + "Body.\n").ShouldBe(rebased, $"{rule} below '{below}'"); + } + } + } + + [Fact] + public void A_glob_ending_in_slash_star_star_is_written_with_one_more_so_the_cli_reads_it_as_meant() + { + // src/**/** reads as src/** — anchored to the rule's directory. Written as src/**, the CLI would drop that /** + // too and read src, which matches a src directory at any depth. + var globs = ClaudeRuleScope.Read("---\npaths: src/**/**\n---\n").ShouldNotBeNull(); + + globs.ShouldBe(new[] { "src/**" }, "fixture check"); + ClaudeRuleScope.Frontmatter(globs).ShouldBe("---\npaths:\n - \"src/**/**\"\n---\n"); + ClaudeRuleScope.Normalise(new[] { "src/**/**" }).ShouldBe(new[] { "src/**" }); + } + + [Theory] + [InlineData("**")] + [InlineData("**/**|/**")] + [InlineData("|")] + [InlineData("")] + public void Globs_the_cli_normalises_to_nothing_or_star_star_alone_are_unconditional(string globs) => + ClaudeRuleScope.Normalise(globs.Length == 0 ? [] : globs.Split('|')).ShouldBeNull(); }