Check that declared source paths exist during component analysis - #202
Conversation
The path fields on the source-carrying model types are written by the component-analysis agent and were never checked against the filesystem. A path that resolves to nothing travelled through name_to_path, the transitive closure and extra_files before AutoSetup's parse_contract_files raised on it, an hour and several phases after it was written. validate_solidity_connectivity now takes the project root and checks each declared path in its existing pass over components, so a submission wrong in both its graph and its paths comes back as one message. Cache hits are held to the same check, since the analysis cache key covers neither the source tree nor the rules in force. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if fs_forbidden_read(PurePath(declared)): | ||
| return _PathFault.WITHHELD |
There was a problem hiding this comment.
I'm confused how this happened?
There was a problem hiding this comment.
Claude answers: I don't have a case of the model actually doing it. The branch is there for consistency with the hints: the walk behind them prunes .certora_internal and the report directories, so a path into one is something the validator would never suggest, and accepting it would point AutoSetup at a prover's own copy of a contract that is readable at its canonical path anyway. Every prover run leaves such a copy behind, so the files really are there. tests/test_solidity_component_paths.py covers it.
| found: dict[str, list[str]] = {name: [] for name in names} | ||
| for dirpath, dirnames, filenames in os.walk(root): | ||
| rel_dir = Path(dirpath).relative_to(root) | ||
| dirnames[:] = [d for d in dirnames if not fs_withheld_subtree(rel_dir / d)] |
There was a problem hiding this comment.
I was today years old when I learned you could control the recursion behavior of os.walk by just mutating dirnames. This language is insane dude.
There was a problem hiding this comment.
Claude answers: It is the only way to prune a directory without walking it first, which is what this loop is after.
| name = PurePosixPath(declared).name | ||
| candidates = same_named[name] | ||
| tail_matches = [c for c in candidates if c.endswith("/" + declared)] |
There was a problem hiding this comment.
Wait, I'm confused. This is basically finding candidates where the LLM clearly forgot a leading directory?
There was a problem hiding this comment.
Claude answers: Yes. Candidates start as every readable file with that base name, and the tail match keeps the ones whose path ends with what was declared. That is the dropped-leading-directory case, and it usually leaves exactly one file for the agent to copy.
| return _PathFault.DIRECTORY | ||
| if not candidate.is_file(): | ||
| return _PathFault.MISSING | ||
| if fs_forbidden_read(PurePath(declared)): |
There was a problem hiding this comment.
FWIW, we should be using the forbidden_read on SourceFields which makes up the input to the pipeline. fs_forbidden_read is just the default (that we happen to use always, so its worth considering whether we need to keep forbidden_read as a separate part of the input)
There was a problem hiding this comment.
Claude answers: Done in 4f7c2f1. The validator takes the run's forbidden_read and normalizes it with make_exclude_pred, which I exported from graphcore in Certora/graphcore#37, so the regex and the callable form are read the way the tools read them. The pin here points at that branch commit until #37 merges. The directory prune in the walk stays on fs_withheld_subtree: a prune cannot come from an arbitrary exclusion, since lib/ is excluded whole while the .sol under it stays readable.
There was a problem hiding this comment.
Claude answers: graphcore#37 is merged, so the pin here now points at graphcore master (2177f57) instead of the branch commit.
| async def run_component_analysis[T: BaseApplication]( | ||
| ty: type[T], | ||
| child_ctxt: WorkflowContext[T], | ||
| input: SystemDoc | SourceCode | None, |
There was a problem hiding this comment.
FWIW, this is the input field which holds the source field to use.
| extra_input: list[str | dict], | ||
| expected_main_id: SourceIdentifier | None = None, | ||
| *, | ||
| project_root: Path | None, |
There was a problem hiding this comment.
doesn't this duplicate the data available on SourceCode? When will this be non-none and input !is SourecCode?
There was a problem hiding this comment.
Claude answers: They come apart in the natspec from-source workflow, which calls this with input a SystemDoc and a real root off mental_model.source_root (composer/spec/natspec/system_analysis.py). That workflow is generating contracts, so it has no contract_name or relative_path and cannot hand over a SourceCode. If you would rather it were one object, the shape is splitting (project_root, forbidden_read) out of SourceFields into a base both callers pass. Say so and I will do that instead.
|
|
||
| ``project_root`` is the tree the validator resolves any source paths the model declares | ||
| against; it is required (not defaulted) so a new call site has to say what the model's paths | ||
| mean, and is ``None`` only when the run has no source tree. |
There was a problem hiding this comment.
which is also the semantics (if you will) of input == SystemDoc or input == None. I think we're duplicating data.
There was a problem hiding this comment.
Claude answers: Same as the parameter above: natspec from-source has a root and no SourceCode, so the two are not the same condition.
The path check called `fs_forbidden_read`, the Solidity default, instead of the predicate the run actually serves its source tree through. A run that narrows or widens that rule got a validator disagreeing with its own file tools about what counts as a source file. The `forbidden_read` now travels off the pipeline input beside the project root, normalized by graphcore's `make_exclude_pred` so both surface forms are read the way the tools read them. The prune in the relocation walk stays on `fs_withheld_subtree`. It keeps prover and VCS scratch off the walk for cost, and a prune cannot be derived from an arbitrary exclusion: `lib/` is excluded whole while the .sol under it stays readable. Advancing the graphcore pin to pick up `make_exclude_pred` also brings its token-usage accounting fix, so the fake message in test_token_usage carries `usage_metadata`, whose input total includes the cache buckets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master's own fix to the token-usage fake message wins; the copy of it on this branch, written while the graphcore pin was being advanced, is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| extra_input: list[str | dict], | ||
| expected_main_id: SourceIdentifier | None = None, | ||
| *, | ||
| project_root: Path | None, |
graphcore#37 is merged, so the submodule and the pin move off the branch commit they sat on and onto master. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
The
pathfields onSourceExplicitContract,SourceExternalActorandExistingFromSourcearewritten by the component-analysis agent. Nothing checked them against the filesystem.
validate_solidity_connectivitycovered names, duplicates and the interaction graph, and it wastyped over
BaseApplicationwith no project root to check anything against.So a declared path that resolves to nothing travels a long way before anyone notices. It goes
through
run_setup_part1'sname_to_path, into the transitive closure, intoextra_filesinrun_autosetup_phase, and finally into the AutoSetup subprocess argv, whereparse_contract_filesraises
File ... does not exist. By then an hour has passed and several analysis and bug-analysisphases have run.
The shape that prompted this: a repository whose build project sits one or more directories below
the repository root. Paths are project-root-relative, so every declared path has to carry that
leading directory. A path assembled from an import statement or from a build tool's source
directory instead of from the agent's own file-tool output loses it, and every contract in the
submission loses it the same way.
How
The validator takes a third
project_root: Path | Noneand checks the declared paths inside itsexisting single pass over components. Path complaints join the same accumulated message as the
graph ones, so a submission that is wrong in both ways is corrected in one retry instead of two.
project_rootthreads throughEcosystem.validate_analysis, which follows the existinglocate_mainprecedent of handing ecosystem callables the run's facts. Solana and Soroban take anignored parameter; nothing in their models carries a source path.
Containment is lexical, meaning relative and never stepping up out of the root. Resolving instead
would follow symlinks, and the forbidden-read predicate deliberately keeps
.solreadable underlib/andnode_modules/, which are routinely symlinked. The agent is entitled to name a filethere and has no way to restate it as a non-symlinked path, so a resolving check would reject it
with no way to comply. There is a test pinning that.
The error says where to look instead. One walk of the tree collects readable files carrying the
declared name, narrowed to those whose path ends with the declared path, so the dropped-directory
case names exactly one real file rather than reporting "not found". One walk covers every bad path
in the submission, and wholly withheld directories are pruned during descent rather than filtered
out afterwards, because this runs while the agent waits on its retry.
Cache hits get the same check.
root_cache_keyissha256(project_root | doc_hash | relative_path | contract_name), which covers neither the sourcetree nor the rules in force, and the cloud namespaces the cache per user and repository. Without
this, a model written before the check existed is replayed unvalidated on every rerun and the gate
never fires at all. An entry that fails validation is stale, so it is re-derived and the existing
cache_putreplaces it.The prompt and the
pathfield descriptions now state the convention the validator enforces. Theyreject nothing on their own; they say what the check requires.
Scope note for reviewers
SourceExternalActor.pathis checked too, which is wider than the failure above. It is the sameclass of unchecked agent-written string and it reaches the summarizer prompt. The error offers
"omit the path instead" as the remedy, and an actor with no path is passed over silently
downstream, so a repository that passes today can start reporting a validation error. That is
intended, not a regression.
Testing
tests/test_solidity_component_paths.pyis new: 17 tests over acceptance, a path missing itsleading directory (asserting the hint names the one real file), several files sharing a name, no
file of that name anywhere, a directory, absolute and escaping paths, a path under a withheld
directory, a symlinked dependency being accepted, several bad paths arriving in one message, graph
errors and path errors arriving together, the external actor with and without a path,
project_root=None, greenfield, andFromSourceApplicationwhere the existing contract is checkedand the fresh one is not. Two more cover the cache branch in both directions.
tests/test_analysis_input_phrase.pygained rendering assertions for the two path bullets and theomit-the-path clause.
tests/test_pipeline_overlap.pynow asserts the driver forwards a realproject root.
Each new test was checked by breaking the code it covers and confirming it fails.
Targeted run: 91 passed.
pyright composer/ analyzer sanity_analyzer certora_autosetup: 0 errors.Review round
forbidden_readnow comes off the pipeline input rather than the module-levelfs_forbidden_read,so the check asks the question the run's own file tools answer. It threads beside
project_rootthrough
Ecosystem.validate_analysis, and natspec carries it on itsMentalModel. Normalizing thetwo surface forms of a
GlobalExcludeArgusesmake_exclude_pred, exported from graphcore inCertora/graphcore#37, now merged, and the pin sits on graphcore master.
The directory prune in the relocation walk stays on
fs_withheld_subtree, which is a walk-costmeasure over prover and VCS scratch rather than a readability rule. A prune cannot be derived from
an arbitrary exclusion:
lib/is excluded whole while the.solunder it stays readable. Four newtests pin the split, including the regex form of
forbidden_readand a hint that must not name afile the run withholds.
Advancing the graphcore pin also brings its token-usage accounting fix, so the fake message in
tests/test_token_usage.pycarriesusage_metadata, whose input total includes the cache buckets.