Repository navigation
fix(read): c3_read reads outside the project root; act setup failures reported as failures (2.129.0) - #176
Merged
Conversation
A path outside the project root did not get a refusal. It got an uncaught ValueError out of Path.relative_to in handle_read, surfaced to the caller as "Error calling tool 'c3_read': '...' is not in the subpath of '...'". Nothing in the tool ever checked containment. validate_file_path is a ghost-path heuristic (type names, heredoc markers, redirect artifacts), and access_guard.canonicalize returns rel="" with NO denial for an outside path — tests/test_access_guard.py has always asserted that. The accidental boundary was worth nothing as a control: - Access Guard rules match the absolute canonical path as well as the project-relative one (Rule.matches tests `canon`), so the builtin dotenv and vault denies, and user deny globs, already bound on outside paths. That is where the protection lives, and it is untouched here. - The PreToolUse hook deliberately stands down outside the root, so native Read went there unimpeded. Refusing only pushed the agent off the budgeted, guarded, telemetered tool onto the one with none of it. It also leaked. full.exists() was checked before the throw, so an existing outside file produced the subpath error while a missing one produced "File not found" — a distinguishable-response probe over the whole filesystem, which is what the R2 rule quoted three lines above that code exists to prevent. What the root still decides is INDEXING. file_memory is keyed by project-relative path and feeds the text index, the map cache and prune_stale, so an outside file is served from a transient record (FileMemoryStore.build_transient_record): same parser, same schema, never persisted, never looked up. It is built lazily — a lines=[a,b] slice must not pay for a full parse it never uses. Directory maps outside the root extract symbols the same transient way, under the same file/deadline/byte bounds. Every outside response opens with a [c3-read:external] banner. Related facts are suppressed there (project knowledge keyed by a key the file does not have), and telemetry carries external: true. c3_compress had the identical crash at the same expression and is fixed the same way; project_key() and external_banner() are shared so the two map surfaces cannot drift apart on where a file may live. A hard root boundary remains available as an Access Guard rule. It is not a property of the tool. Claude-Session: https://claude.ai/code/session_01H2xJVonLH3ZKjDUQa4h3eS
…s" (#173) act can fail BEFORE any workflow step runs: the runner image cannot be pulled, the daemon refuses, the container never starts. ci_runner handed that log to ci_failures.parse like any other failure, so it came back parser: unparsed with a log tail — and c3_ci(action='failures') answered "0 parsed failures" for what was really "the runner image could not be obtained". ci_act.setup_failure() recognises it from act's own "Failure - Set up job" marker and names the cause: registry auth (with the docker logout fix), missing image, unreachable daemon, out of disk, or the daemon's own words. The job is reported with parser: container-setup and that reason. Status stays FAILED — a job that could not run is never a green light, and demoting it to a not-run status would change what PARTIAL_PASS means. A step failure is explicitly NOT a setup failure: the marker gates it, so a job that reached its steps still goes to the parsers and pytest or ruff output inside a container still yields {file,line,message}. c3_ci(action='doctor') now reports whether the runner image is present locally, without pulling ~1 GB to find out. Note what a pre-flight probe cannot do, and why the classification has to live in the runner: in the reported case the image WAS present and `docker pull` succeeded, while act's own pull failed because act sends the stored registry credential where the CLI sends none for a public image. Only a real act run shows that. tests/test_ci_act.py's one container test now skips with that reason instead of failing on `parser != 'ruff'` when no step ever ran, which is the reported symptom. The classification itself is pinned by unit tests against the exact log from the issue; the live symptom does not currently reproduce on this machine, so the fix is verified against that log rather than against the failure. Claude-Session: https://claude.ai/code/session_01H2xJVonLH3ZKjDUQa4h3eS
Version bumped in BOTH sites (pyproject.toml and cli/c3.py __version__) — test_version_sync exists because v2.56.0 shipped with them apart. Claude-Session: https://claude.ai/code/session_01H2xJVonLH3ZKjDUQa4h3eS
Landscape survey of file/repo mapping approaches for coding agents (Aider's tree-sitter + PageRank repo map, MCP graph servers, IDE symbol indexers, static ARCHITECTURE.md generators, plain tree dumps) with a feature matrix. Background reading for C3's own file-map work; it describes other systems and asserts nothing about C3's behaviour. Claude-Session: https://claude.ai/code/session_01H2xJVonLH3ZKjDUQa4h3eS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes, released as 2.129.0.
1.
c3_readoutside the project root (the main change)The claim under review was "
c3_readrefuses paths outside the project root." It is true in effect and false in mechanism — and the mechanism is the bug.An outside path never got a refusal. It got an uncaught
ValueErrorout ofPath.relative_toinhandle_read, which reached the caller as:Nothing in the tool checked containment.
validate_file_pathis a ghost-path heuristic (Python type names, heredoc markers, redirect artifacts).access_guard.canonicalizereturnsrel=""with no denial for an outside path —tests/test_access_guard.py::test_dotdot_escape_leaves_rel_emptyhas always asserted exactly that.Why the boundary was worth nothing as a control
Rule.matchestests the absolute canonical path as well as the project-relative one, so the builtin dotenv/vault denies and any userdenyglob already bound on outside paths. That is where the protection lives, and this PR does not touch it.tests/test_hook_pretool_enforce.py). NativeReadalready went there with zero friction. Refusing only pushed the agent off the budgeted, guarded, telemetered tool onto the one with none of that.It also leaked
full.exists()was checked before the throw, so an existing outside file produced the subpath error and a missing one producedFile not found. That is a distinguishable-response probe over the whole filesystem — the exact thing the R2 rule quoted three lines above that code exists to prevent. Closed here.What changed
Outside paths are now read: source, maps, symbol reads, directory maps. What the root still decides is indexing —
file_memoryis keyed by project-relative path and feeds the text index, the map cache andprune_stale, so an outside file is served from a transient record (FileMemoryStore.build_transient_record): same parser, same schema, never persisted, never looked up. Built lazily — alines=[a,b]slice must not pay for a full parse it never uses.Every outside response opens with a banner:
Related facts are suppressed there (project knowledge keyed by a key the file does not have); telemetry carries
external: true.c3_compresshad the identical crash at the same expression and is fixed the same way.project_key()andexternal_banner()are shared so the two map surfaces cannot drift apart on where a file may live.A hard root boundary remains available as an Access Guard rule. It is not a property of the tool.
2. act setup failures reported as failures (#173)
actcan fail before any workflow step runs — image pull, unreachable daemon, container never starts.ci_runnerhanded that log toci_failures.parselike any other failure, so it came backparser: unparsedandc3_ci(action='failures')answered "0 parsed failures" for what was really "the runner image could not be obtained".ci_act.setup_failure()now recognises it from act's ownFailure - Set up jobmarker and names the cause: registry auth (with thedocker logoutfix), missing image, unreachable daemon, out of disk, or the daemon's own words. Reported asparser: container-setupwith that reason. Status staysFAILED— a job that could not run is never a green light, and demoting it to a not-run status would change whatPARTIAL_PASSmeans.A step failure is explicitly not a setup failure: the marker gates it, so a job that reached its steps still goes to the parsers.
doctorreports whether the runner image is present locally, without pulling ~1 GB to find out. Note what a pre-flight probe cannot do, and why classification has to live in the runner: in the reported case the image was present anddocker pullsucceeded, while act's own pull failed because act sends the stored registry credential where the CLI sends none for a public image.Tests
4051 passed, 7 skipped;ruff check .clean. Verified on the final tree, after the last edit.tests/test_read_outside_root.py(new, 22 tests) pins four things:list_tracked() == []and no store files after source, map, symbol and directory reads; inside reads still indexdenyglob both refuse an outside path, with no content in the responsePlus laziness: an outside line read parses zero times; map and symbol reads parse exactly once. And
c3_compressparity, so the two surfaces cannot diverge.For #173, the classification is pinned by unit tests against the exact log from the issue. The live symptom does not currently reproduce on this machine (
availability()reports ok, image present, and the container test passes), so that fix is verified against the reported log rather than against the failure itself.https://claude.ai/code/session_01H2xJVonLH3ZKjDUQa4h3eS