Skip to content

fix(read): c3_read reads outside the project root; act setup failures reported as failures (2.129.0) - #176

Merged
drknowhow merged 4 commits into
mainfrom
fix/read-outside-project-root
Sep 9, 2026
Merged

drknowhow merged 4 commits into
mainfrom
fix/read-outside-project-root

Conversation

@drknowhow

Copy link
Copy Markdown
Owner

Two fixes, released as 2.129.0.

1. c3_read outside the project root (the main change)

The claim under review was "c3_read refuses 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 ValueError out of Path.relative_to in handle_read, which reached the caller as:

Error calling tool 'c3_read': 'C:\Windows\win.ini' is not in the subpath of '<project>'

Nothing in the tool checked containment. validate_file_path is a ghost-path heuristic (Python type names, heredoc markers, redirect artifacts). access_guard.canonicalize returns rel="" with no denial for an outside path — tests/test_access_guard.py::test_dotdot_escape_leaves_rel_empty has always asserted exactly that.

Why the boundary was worth nothing as a control

  • Access Guard is already root-independent. Rule.matches tests the absolute canonical path as well as the project-relative one, so the builtin dotenv/vault denies and any user deny glob already bound on outside paths. That is where the protection lives, and this PR does not touch it.
  • The native fallback was wide open. The PreToolUse hook deliberately stands down outside the root — "a call aimed outside the project root is none of this hook's business" (tests/test_hook_pretool_enforce.py). Native Read already 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 produced File 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_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. Built lazily — a lines=[a,b] slice must not pay for a full parse it never uses.

Every outside response opens with a banner:

[c3-read:external] C:/Users/me/other-repo/lib.py — outside the project root; served but not indexed, and absent from this project's search, map cache and related facts.

Related facts are suppressed there (project knowledge keyed by a key the file does not have); 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.

2. act setup failures reported as failures (#173)

act can fail before any workflow step runs — image pull, unreachable daemon, container never starts. ci_runner handed that log to ci_failures.parse like any other failure, so it came back parser: unparsed and c3_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 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. Reported as parser: container-setup with 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.

doctor 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 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.

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:

Served source by lines, map, symbols, unknown-symbol fallback, directory map, batch mixing inside + outside
Labelled banner on every outside response, naming the absolute path; never on an inside one
Never indexed list_tracked() == [] and no store files after source, map, symbol and directory reads; inside reads still index
Still guarded a builtin dotenv deny and a project deny glob both refuse an outside path, with no content in the response

Plus laziness: an outside line read parses zero times; map and symbol reads parse exactly once. And c3_compress parity, 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

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
@drknowhow
drknowhow merged commit c2f569f into main Sep 9, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant