Repository navigation
fix(artifacts): prevent disk paths from colliding with archive members - #793
yashrajp22 wants to merge 18 commits into
Conversation
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @yashrajp22, thank you for finding a real collision between disk paths and archive virtual paths and fixing it at the one place where nested results are merged into the caches! Keeping the bytes read from disk and making the collision fail closed means the layout you reported can no longer end complete/SAFE with exit 0.
Value and readiness: The problem is real on main. With main's code (graph.invoke, use_llm=False, and the CLI), a disk file bundle.zip!/payload.txt holding an injection payload next to a bundle.zip whose member payload.txt is benign gives two analyzed inventory rows for the same path, raw_file_cache holding the benign archive bytes, no P1, a complete SAFE result and CLI exit 0. The hidden .bundle.zip variant behaves the same way. With the PR's two source hunks transcribed onto main, that layout gives one failed/artifact_path_collision row, keeps the disk bytes, reports P1 and YR4, and ends with status failed, execution_successful=false and exit 2. An ordinary ZIP with no collision is unchanged. No other route to a collision exists, so every collision now fails closed. The change is not ready for final maintainer review yet because of finding 1: the reservation applies to every real path containing !/, not just to colliding ones. A benign skill with any directory whose name ends in ! now fails with exit 2, an executable in such a directory gets a false SC9 HIGH and DO_NOT_INSTALL, --exclude cannot avoid it, and a new test locks this in. The remaining findings are non-blocking. The most important of them is that the reverse layout (benign disk decoy, malicious archive member) now drops the member's findings without recording that the member was withheld. Once the failure is limited to real collisions, the change should be close to ready.
Material findings
-
[Blocker]
src/skillspector/nodes/build_context.py:3181: every real path containing!/becomes a fatal FAILED artifact, even when no archive member collides with it.reserved_pathsis built from everyartifact_inventoryrow whose path contains!/. That includes user-excluded and out-of-scope rows (merged at 2678-2694), and nothing checks whether nested inspection produced the same path. Lines 3182-3194 then set FAILED/artifact_path_collisionand add a FAILED ledger event, while the bytes stay inraw_file_cacheand are still analyzed (2931-2941).- Downstream, inspection_ledger.py:914-929 turns any
failedinventory row into a fatal exception, soexecution_successfulis false and the CLI exits 2 (cli.py:858-859). inspection_ledger.py:985-992 counts the file as entirely uninspected, and_mark_unanalyzed_executables(build_context.py:1000) emits SC9 for a FAILED executable. The archive-side equivalents, ARCHIVE_UNSAFE_MEMBER_PATH and ARCHIVE_AMBIGUOUS_MEMBER_PATH, are in_PARTIAL_INVENTORY_REASONS(nested_artifacts.py:167-178) and are non-fatal. - Measured with
scan --no-llm --format jsonthrough CliRunner, main against a transcription of the PR hunks (two reviewers, independently):SKILL.md+tools!/run.sh(#!/bin/bash,echo hello, mode 755), no archive: main exit 0, complete, SAFE, score 0. PR exit 2, failed, SC9 HIGH, score 51, DO_NOT_INSTALL,entirely_uninspected=1.Notes!/readme.txt, no archive: main exit 0, SAFE. PR exit 2, failed, CAUTION.- The same with
--exclude 'Notes!/*': main exit 0 with a non-fataluser_exclusion. PR exit 2, with a fatalartifact_path_collisionadded. node_modules/pkg/wow!/a.txt, which is out of scope and never read: main exit 0, complete, SAFE. PR exit 2.- A real
bundle.zipwith memberother.txtplus a diskbundle.zip!/payload.txt(no shared key): main exit 0, complete. PR exit 2. - A malicious
tools!/run.sh(curl piped to bash plus SSH-key exfiltration): main reports E1, PE3, SC2 and TM2. The PR reports the same four findings from the same bytes and also SC9 saying the content is outside analyzer coverage (score 97 to 100). The FAILED/uninspected accounting is therefore wrong, not just strict. - A control with
tools/run.sh(no!) is identical on both.
test_reserved_disk_path_fails_even_without_an_archive(tests/nodes/test_nested_artifacts.py:1255) asserts this behaviour, and the PR body describes it, so it is deliberate policy rather than an oversight. It is still the wrong outcome: README.md:784 documents exit 2 as "Error (bad input, unreadable source, internal failure)", and here the input is benign and fully read. Renaming the directory is the only way out. Prevalence is probably low (no path in this repository contains!), but any skill that hits it gets the same signal as a scanner crash.- Expected fix:
- Treat a disk
!/path as reserved only when nested inspection produced the same path for an archive member, for example by intersecting with{i["path"] for i in nested.artifact_inventory} | set(nested.file_cache) | set(nested.raw_file_cache) | set(nested.components). Do not usenested.inventory_overridesor nested ledger events on their own for this: they can also be keyed on the outer disk container (_exception(path=container_virtual_path, ...)at nested_artifacts.py:834-836), which would flag a real ZIP under a!directory against itself. - Keep the rest of the hunk (FAILED mark, ledger event, blocking the nested merge, the override guard) for real collisions, whatever the disk row's prior disposition. Leave rows with no collision unchanged, or add at most a non-fatal note.
- A reviewer prototype of this restriction (reviewer code, not the PR's) kept exit 2 with the disk finding for both the visible and hidden attack layouts, and returned every non-colliding case above to main's results.
- Replace the no-archive test with tests asserting that
real!/notes.txtwith no archive stays analyzed and complete with exit 0, that an archive with no matching member stays complete, and that--exclude 'x!/*'stays non-fatal.
- Treat a disk
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3195: in the reverse layout (benign disk decoy, malicious archive member) the member is dropped and its findings disappear, with no record that archive content was withheld.blocked_nested_pathsincludesreserved_paths, so the colliding member is removed fromnested.metadata(3196), the nested components (3197-3199),local_file_cache(3201-3203),raw_file_cache(3204-3210) and the nested inventory (3211-3213).llm_file_cacheis built from disk files only, so the member's bytes are not analyzed anywhere. The only record is the FAILEDartifact_path_collisionrow and event on the path, and that message is about a filesystem path.- Measured (two reviewers, transcription):
payload.txtwith an injection string, DEFLATE, benign disk decoy: main reports P1 HIGH and YR4 HIGH onbundle.zip!/payload.txt, score 40, CAUTION, complete, exit 0. The PR reports no findings, score 0, CAUTION, status failed, exit 2; the raw cache holds the decoy bytes and the member bytes are in neither cache.- The same with ZIP_STORED, which is what the test helper
_write_archivewrites: main score 50. The PR keeps only YR4 on the outerbundle.zip, score 20. notes.mdwith prompt-injection and exfiltration text: main reports E1, P1 and PE3 (score 65 to 79 depending on the payload), DO_NOT_INSTALL, exit 1. The PR reports no findings, score 0, CAUTION, exit 2.
- This is not a new attacker capability. Every collision now fails closed (exit 2,
safe_to_installfalse at mcp_server.py:213-219), and main already lets an author hide a member with duplicate member names inside one archive (first wins at nested_artifacts.py:841-847; 0 findings, CAUTION, exit 0 on both main and the PR). The cost is in reporting: reports, SARIF and batch summaries (which sort by score) show score 0 and CAUTION where main showed HIGH findings and DO_NOT_INSTALL, and nothing tells the reader that an archive member was not analyzed. The PR tests cover only the malicious-disk orientation (tests/nodes/test_nested_artifacts.py:1229-1234). - Expected fix:
- When a member is withheld because of a real collision, record it, for example with a ledger event that names the member, on the path or on its container, with a member-specific reason. ARCHIVE_AMBIGUOUS_MEMBER_PATH is the existing first-wins precedent.
- Add a reverse-orientation regression test (malicious member, benign disk file at
bundle.zip!/payload.txt) that assertsexecution_successfulis False, the result is incomplete and not SAFE, and the withheld-member record is present. - Analyzing both sides under distinct identities would keep both sets of findings. That is a larger change across findings, SARIF and reference resolution and fits better as a follow-up.
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3196: nested ledger events for the withheld member are still merged, unfiltered, at line 3489 and are reported against the disk file.- Lines 3181-3212 filter metadata, components, caches and inventory, but
*nested.ledger_eventsis spread into the final ledger at 3489 as-is. Nested events carry the member's virtual path (_exceptionat nested_artifacts.py:594-630), which is the same string as the disk path, and the finalizer projects them intoledger_exceptionsby path. - Measured (transcription): disk
bundle.zip!/payload.txtis the 12-bytePlain note.; the member is 400,000 zero bytes, DEFLATE. The path'sledger_exceptionsareartifact_path_collision(fatal) andarchive_compression_ratio(non-fatal,observed_bytes=400000,limit_bytes=40400). The inventory has one row, failed,size_bytes12, and the only metadata row is text, size 12, with noouter_path. An encrypted member addsarchive_encryptedthe same way; a benign member adds nothing. - Main was worse here (two inventory and two metadata rows, status partial), so this is an unfinished separation rather than a new problem. The verdict and exit code do not change, but the report says a 12-byte text file failed an archive compression-ratio check, which is the mixed provenance this PR sets out to remove.
- Expected fix: re-attribute nested events whose path is in
reserved_pathsto the archive container that produced the member, with a note that the member was withheld (this fits finding 2). Plain filtering is weaker, because some member events are halting events (ARCHIVE_TIME_LIMIT at nested_artifacts.py:1048-1049) whose loss would hide that the rest of the archive was not inspected. Extend the collision test with a high-ratio member and assert that noarchive_*reason appears for the disk path.
- Lines 3181-3212 filter metadata, components, caches and inventory, but
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3216: no test fails if the guard that keeps inventory overrides from downgrading FAILED is removed.nested.inventory_overridesis not filtered byreserved_paths, so this guard is the only thing stopping a member override from rewriting the disk row. It only matters for reserved-path collisions: failed disk rows are never passed to nested inspection, and nested rows already carry their overrides.- Mutation (two reviewers, independently, transcription with the guard removed): on the PR test's shape (a 20-byte STORED member) the results are identical with and without the guard. With a colliding DEFLATE member that trips ARCHIVE_COMPRESSION_RATIO (400,000 zero bytes, or 200 KB of
A), the guarded copy keepsfailed/artifact_path_collisionand the mutant givespartial/archive_compression_ratio. Main's ownbuild_contextsets both rows topartial/archive_compression_ratioin that case, which confirms that the override reaches the disk path. - inspection_ledger.py:914-929 restores fatal exceptions only from
failedinventory rows when detail events are truncated, so without the guard the collision would survive truncation only through the separate prework event (reasoned from the code, not run end to end). - Expected fix: add a test whose colliding member triggers a PARTIAL-class override and assert the row stays
failed/artifact_path_collision. Build the archive with_zip_bytes({"payload.txt": b"\x00" * 400_000}, compression=zipfile.ZIP_DEFLATED), since_write_archiveuses ZIP_STORED and triggers no override. An encrypted member is not a good trigger: ARCHIVE_ENCRYPTED is FAILED-class, so the disposition would stay FAILED without the guard.
-
[Non-blocking]
tests/nodes/test_nested_artifacts.py:1245: the hidden-archive case skips the retention and P1 checks, and the!=checks at 1243-1244 also pass when the path is missing.- Line 1245 limits the
==checks (1246-1247) and the P1 check (1248-1250) tobundle.zip. With the transcription,.bundle.zipalso keeps the malicious disk bytes and reports P1 and YR4 on.bundle.zip!/payload.txt, so the skip is unnecessary. - A simulated regression that deletes the colliding path from both caches fails the
bundle.zipcase but passes every assertion of the.bundle.zipcase, becauseNone != benignis true. - The fatal status and exit 2 are already covered: the disposition check at 1241, together with inspection_ledger.py:914-929 and cli.py:858, forces
execution_successful=false. - Expected fix: remove the
startswith(".")guard, replace the two!=checks with== maliciousand== malicious.decode(), and assert the P1 finding for both parametrizations.
- Line 1245 limits the
-
[Non-blocking]
src/skillspector/inspection_ledger.py:167: the reason code says "collision" when there is none, and the message gives no remediation.- With
Notes!/readme.txtand no archive, main's terminal renderer fed the PR's code and message (transcribed strings) printsartifact_path_collision Notes!/readme.txt: A filesystem path uses the reserved archive delimiter and cannot be attributed safely.The message names the cause class, but the code name points to an archive that does not exist, and nothing says how to fix it. Renaming the directory is the only remedy: user-excluded rows are re-added to the inventory (build_context.py:2678-2694) and then escalated at 3181-3194. The archive-side equivalent is named after the property (ARCHIVE_UNSAFE_MEMBER_PATH). - Expected fix: once the reason fires only for real collisions (finding 1), the name becomes accurate. Make the message actionable: quote
!/, say that a directory name ends in!, suggest renaming it, and say that the archive member at that path was not analyzed (finding 2). If a note for non-colliding!/paths is kept, give it its own code. The code is unreleased, so naming is free now.
- With
-
[Non-blocking]
src/skillspector/inspection_ledger.py:78: the new fatal reason and the reservation of!/in real paths are not documented, and the PR changes no docs.- docs/NESTED_ARTIFACT_INSPECTION.md:8-12 documents the
outer-file!/nested.zip!/...virtual path syntax, and its "Failure and completeness behavior" section (53-64) lists member-level failures only. Nothing in docs/ or the README mentionsartifact_path_collisionor says what happens to a real path containing!/. The PR that added the archive-side!/reservation (#382) updated NESTED_ARTIFACT_INSPECTION.md, the CHANGELOG and the README. - Users and integrators who get exit 2 for this have nothing to explain why or how to avoid it.
- Expected fix: add a short paragraph to docs/NESTED_ARTIFACT_INSPECTION.md (after the syntax example or in the failure section) saying that
!/is reserved, that a real file colliding with an archive member is reported asartifact_path_collisionand the member is not merged, what that does to the result (exit 2,safe_to_install=false), and that renaming the directory fixes it. Word it to match the outcome of finding 1, and optionally add a CHANGELOG line. docs/scan-completeness.md is the design note for #563 and does not need a row.
- docs/NESTED_ARTIFACT_INSPECTION.md:8-12 documents the
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3208: one level deeper, archive members still shadow the members of a real container under a!directory (pre-existing, untested).- The unchanged nested input list (build_context.py:2963-2969) includes a reserved disk container such as
bundle.zip!/inner.zip.bundle.zipsorts first and is expanded first, so its owninner.zip!/x.txtclaimsbundle.zip!/inner.zip!/x.txt. When the disk container is expanded later, itsx.txtis skipped as ARCHIVE_AMBIGUOUS_MEMBER_PATH (nested_artifacts.py:841-847). The exact-path filters at 3195-3213 drop the archive memberbundle.zip!/inner.zipbut not its child, which passes this filter. - Measured (transcription; malicious
x.txtin the diskinner.zip, benign in the archive's): the cache holds the benign bytes,x.txtispartial/archive_ambiguous_member_path, and there is no P1 (no findings with DEFLATE; only YR4 onbundle.zip!/inner.zipwith STORED). The result is CAUTION withexecution_successful=false. Main gives the same shadowing (CAUTION, incomplete), so this is not a regression, and the verdict fails closed. Without a colliding archive, both main and the PR report P1 and YR4 at that path. - Expected fix (low priority, can be a follow-up): order the
inspect_nested_artifactsinputs at 2963-2969 so disk paths containing!/are inspected first. A reviewer prototype with a stablesorted(inputs, key=lambda p: "!/" not in p)restored P1 at depth 2 and left depth 1 and the no-collision case unchanged. Excluding reserved disk paths from nested inspection would make it worse, since it loses the P1 reported today when no archive collides. Add a depth-2 regression test, or at least one that pins today's fail-closed behaviour.
- The unchanged nested input list (build_context.py:2963-2969) includes a reserved disk container such as
PIC tradeoffs:
- A collision leaves one cache key for two sources, so one side has to yield or be renamed. Keeping the disk bytes and failing closed is the simplest choice and fixes the reported layout, but moves the lost findings to the reverse layout (finding 2). Giving both sides distinct identities keeps all evidence at the cost of changes across findings, SARIF and reference resolution.
- FAILED or PARTIAL for a real collision: FAILED (fatal, exit 2, immune to later overrides) is defensible for a genuine collision. The archive-side analogues ARCHIVE_UNSAFE_MEMBER_PATH and ARCHIVE_AMBIGUOUS_MEMBER_PATH are PARTIAL, which already blocks SAFE and
safe_to_install. Either is reasonable if chosen deliberately; neither fits a directory name with no collision (finding 1). - Reserving
!/for real paths has a case beyond collisions: other code reads!/as archive provenance (static_patterns_supply_chain.py:3472, static_runner.py:2408 and 2498). A non-fatal note on non-colliding!/paths would keep the reservation visible without turning benign skills into failed scans. - Filtering each store separately (metadata, components, caches, inventory) rather than through one provenance-keyed merge already missed
nested.ledger_events(finding 3). Any future nested store would need the same filter.
Verification and gaps:
- Code traces on head
2d6defd: the hunks at build_context.py:3179-3217 and inspection_ledger.py:78 and 167-169, and their consumers at inspection_ledger.py:914-929, 985-992 and 1046, cli.py:858-859, mcp_server.py:213-219, build_context.py:1000 and 3489, and nested_artifacts.py:167-187, 362 and 834-847. - Baseline runs of main (
graph.invokewithuse_llm=False, and CliRunnerscan --no-llm --format json) reproduced the bug for the visible and hidden archive names: twoanalyzedrows, benign bytes cached, no P1, complete, SAFE, exit 0. - PR behaviour comes from reviewers' own transcriptions of the two source hunks onto copies of main
3c8e4b9, run with main's venv. Each copy differs from the head only by the two comment lines at build_context.py:3179-3180, or not at all. Results: the reported layout is fixed (P1 and YR4 kept, failed, exit 2), an ordinary ZIP and a control skill without!are unchanged, and the numbers in findings 1-5 and 8. - No other collision route: virtual paths are always
container!/member(nested_artifacts.py:840),_safe_member_namerejects member names containing!/(nested_artifacts.py:362),inspect_nested_artifactshas a single caller (build_context.py:2964), and symlinked disk entries are never cached. The newREASON_MESSAGESentry means the message lookups cannot raise KeyError, and the broadened guard at 3216 changes behaviour only for rows that are already FAILED. - main's test suite run against a transcription: 10,533 passed; the 3 failures were batch_scan worker-timing tests that pass in isolation and are unrelated. A second reviewer ran about 880 tests in 9 related modules with the same outcome.
- Tests: both new tests would fail on main (the core test at
len(inventory) == 1, because main has two rows; the no-archive test atdisposition == FAILED). They use production code paths without mocks. None covers the reverse layout, the override guard, depth 2, or a non-colliding!/path keeping its result; the second test asserts the opposite of that last case. - CI: no checks have run on the head; the workflow run is
action_requiredand waiting for maintainer approval. - Conflicts: none with main. This PR shares build_context.py with #794 and #795 and inspection_ledger.py with #795; all merge cleanly and no semantic conflict was found. #794 renames
primary_content_eventsand adds OPAQUE_CONTENT PARTIAL events, so a reserved readable-binary path would get both events and FAILED still dominates. #795 changes OMS signature handling for the rootskill.oms.sig, which cannot contain!/. - Head update: I reviewed
2d6defd4e821fb861866b8b836e70283e08ba548. The current head201a6ec48df626d45f0643bb6a6c6b44c17c784eonly adds automated merges ofmain(#691, #577, #608) fromupdate-pr-branches.yml; those commits touch none of this PR's files, so line references below are to the current head. The PR's own diff is unchanged (same patch-id), so this review applies to the current head. - I did not run the PR's tests or code, per policy.
- Gaps: local runs used Python 3.13 on macOS (CI uses 3.12). Windows and case-insensitive filesystems were not exercised; cache keys are exact
as_posixstrings.use_llm=Truewas not tested. MCP, SARIF and Markdown output were checked by code reading and through the sharedledger_exceptionspath, not end to end, and the batch scanner and Pi/OpenCode extensions were not run. The real-world prevalence of directories ending in!is unmeasured.
Decision: Changes Requested (reviewed head 2d6defd4e821fb861866b8b836e70283e08ba548; current head 201a6ec48df626d45f0643bb6a6c6b44c17c784e only adds merges of main)
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @yashrajp22, thank you for the careful follow-up. Three parts of it stand out:
- The collision failure now applies only to paths that nested inspection actually produced, and a parametrized test covers the plain, excluded, out-of-scope, different-member and container layouts.
- While merging #579, you extended the reserved-path filters to its new
python_source_classificationsstore and added a test for both orientations. - The new tests for the override guard, the input sort and the classification filter each fail when that piece of code is removed.
Value and readiness: The earlier blocker is fixed, and the change is ready for maintainer review.
- Attack layout. The reported layout is a malicious disk file
bundle.zip!/payload.txtnext to abundle.zipwhose memberpayload.txtis benign. On main it ends complete, SAFE, exit 0. With this PR it still fails closed: onefailed/artifact_path_collisionrow, disk bytes kept, P1 and YR4 reported, exit 2. - Ordinary
!directories. Directories ending in!that have no collision give main's results in all six layouts I measured. - Earlier items. All eight earlier findings and the #579 impact item are resolved, except finding 3, which is partly resolved.
- Remaining issues. Two non-blocking provenance issues remain (findings 1 and 2 below). Both occur only in collision layouts that already fail with exit 2. Neither changes a verdict or loses a finding compared with main, so they can be fixed here or in a follow-up.
All 6 CI checks pass on this head. GitHub reports it MERGEABLE/CLEAN, and every earlier review thread is resolved.
Previous findings:
The thread replies cite 2660d977. That is a merge commit. The fixes are in 89de1080 and e17d593d, and the python_source_classifications filter arrived with the merge b9db2f0c.
- Resolved. Finding 1 [Blocker]: every real path containing
!/became a fatal FAILED artifact.-
Fix: a path is reserved only when a disk inventory path equals a path that nested inspection produced, through its
artifact_inventory,components,file_cacheorraw_file_cache(src/skillspector/nodes/build_context.py:3252-3259). The no-archive test was replaced bytest_noncolliding_disk_delimiter_paths_remain_complete(tests/nodes/test_nested_artifacts.py:1409-1437). -
Evidence (transcription): six layouts give main's status, verdict, score, findings and exceptions, with no SC9 and no
artifact_path_collision:tools!/run.sh(mode 755);Notes!/readme.txt;--exclude 'Notes!/*'(partial/CAUTION on both);node_modules/pkg/wow!/a.txt;bundle.zip{other.txt}plus a diskbundle.zip!/payload.txt;real!/bundle.zip.
Through the CLI,
tools!/run.shand the excluded case exit 0 on both main and the PR. -
Author's reply: accurate.
-
- Resolved. Finding 2: the reverse layout dropped the archive member with no record.
- Fix: for each colliding path, a PARTIAL
archive_ambiguous_member_pathevent is recorded on the member's container, with the member path appended to its message (build_context.py:3265-3277).test_withheld_member_events_keep_archive_provenance(tests/nodes/test_nested_artifacts.py:1439-1479) covers the benign-disk, malicious-member orientation. - Evidence (transcription):
- DEFLATE injection member: status failed,
execution_successfulfalse, CAUTION, exit 2, withbundle.zip archive_ambiguous_member_path(non-fatal) and a fatalartifact_path_collisionon the disk path. notes.mdmember: main gives DO_NOT_INSTALL, score 59, exit 1. The PR gives failed, CAUTION, exit 2, with the same two records.
- DEFLATE injection member: status failed,
- Author's reply: accurate, with one caveat. The member name appears only in the internal
state["inspection_ledger"]event, which is what the test asserts on. CLI JSON, SARIF, Markdown, terminal and MCP output build the message fromREASON_MESSAGES(src/skillspector/inspection_ledger.py:622, via_exception_from_eventat 643), so they show the generic text. A search of the CLI JSON for the appended text found nothing. Users can still identify the member: its path is the disk path, and that row'sartifact_path_collisionmessage says "the colliding archive member was not analyzed". No change is needed.
- Fix: for each colliding path, a PARTIAL
- Partly resolved. Finding 3: nested events for the withheld member were reported against the disk file.
- Fix: nested events whose path is reserved are moved to the member's container, with a note appended (
build_context.py:3278-3287). The newwork_iduses the same formula asledger_event(inspection_ledger.py:464-466). - Evidence (transcription): in the 400,000-zero DEFLATE case,
bundle.zipgetsarchive_compression_ratio(the internal event keepsobserved_bytes400000), and the disk path gets noarchive_*exception. - What remains:
- The loop cannot tell member events apart from events that nested inspection recorded about the disk file itself, so it moves both (finding 1 below).
- Events from the withheld member's descendants are not moved (finding 2 below).
- Part of the over-reach traces to my earlier request to assert that "no
archive_*reason appears for the disk path". The loop now enforces that for every event. The request should have been limited to events produced by the member.
- Author's reply: accurate for events at the member's own path: their reason and limit values are preserved. The note naming the member is internal only, as in item 2.
- Fix: nested events whose path is reserved are moved to the member's container, with a note appended (
- Resolved. Finding 4: no test failed if the FAILED override guard was removed.
- Fix: the provenance test is parametrized with
b"\x00" * 400_000DEFLATE and asserts the row staysfailed/artifact_path_collision. - Evidence (transcription, mutation): with the guard removed, the row becomes
partial/archive_compression_ratio, so the test fails. - Author's reply: accurate.
- Fix: the provenance test is parametrized with
- Resolved. Finding 5: the hidden-archive case skipped the retention and P1 checks.
- Fix:
tests/nodes/test_nested_artifacts.py:1382-1406asserts the exact malicious bytes inraw_file_cacheandlocal_file_cache, and P1, for bothbundle.zipand.bundle.zip, with nostartswith(".")guard. - Evidence (transcription): the
.bundle.ziplayout gives one failed row, the malicious bytes retained, P1 and YR4 on.bundle.zip!/payload.txt, and exit 2. - Author's reply: accurate.
- Fix:
- Resolved. Finding 6: the reason fired without a collision, and the message gave no remedy.
- Fix: the reason now fires only on real collisions. The message (
inspection_ledger.py:178-181) says the path collides with an archive member, tells the user to rename the directory ending in!, and says the colliding member was not analyzed. - Evidence:
artifact_path_collisionappears in none of the six non-colliding layouts in item 1. - Author's reply: accurate.
- Fix: the reason now fires only on real collisions. The message (
- Resolved. Finding 7: the new fatal reason was undocumented.
- Fix:
docs/NESTED_ARTIFACT_INSPECTION.md:66-70covers real collisions, retention of the disk bytes, exit 2 andsafe_to_install=false, the rename remedy, and that non-colliding!directories are allowed. The optional CHANGELOG line was not added; that is fine. - What remains: line 70 ("Archive exceptions for a withheld member are reported against its container") needs a small wording change once findings 1 and 2 below are settled.
- Author's reply: accurate.
- Fix:
- Resolved. Finding 8: depth-2 shadowing of a real disk container's members.
- Fix: nested inputs are stably sorted by
-path.count("!/"), so real containers under a!directory expand first (build_context.py:3035-3042).test_disk_container_children_take_precedence_over_colliding_archive(tests/nodes/test_nested_artifacts.py:1481-1497) asserts the malicious disk bytes, P1, andexecution_successfulfalse. - Evidence (transcription):
- With the sort, the disk child keeps its malicious bytes and P1, with status failed and score 50.
- Without the sort (mutation), the cache holds the archive's
A plain note., P1 is gone and the score is 20, so the test pins the sort. - The ordering argument holds: an archive can produce
P!/conly after producingP, so a disk container atPhas more!/segments and always expands first.
- What remains: finding 8 also noted that the exact-path filters do not cover the member's descendants. You did what that finding asked: the sort and a depth-2 test. That residual is finding 2 below.
- Author's reply: accurate.
- Fix: nested inputs are stably sorted by
- Resolved. Impact check: #579's
NestedInspectionResult.python_source_classificationswas not covered by the reserved-path filters.- Fix: the store is filtered by
blocked_nested_paths(build_context.py:3303-3307) before it is used at 3349 and 3407.test_collision_keeps_python_identity_bound_to_disk_bytes(tests/nodes/test_nested_artifacts.py:1357-1379) covers both orientations. - Evidence (transcription):
- Dangerous disk Python runner next to a text member: AST2, AST4, E1, PE3, TT4, TT5 and SC9, DO_NOT_INSTALL, and the file is classified
python. On main: SAFE with 0 findings. - The same layout without the filter (mutation): the file is classified
non_python, and AST2, AST4, TT4 and TT5 are lost. - Python member next to a disk text file: the disk file stays
non_python.
- Dangerous disk Python runner next to a text member: AST2, AST4, E1, PE3, TT4, TT5 and SC9, DO_NOT_INSTALL, and the file is classified
- Author's reply: not applicable. The PR body states this behaviour.
- Fix: the store is filtered by
Material findings
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3278: the event re-attribution also moves exceptions that belong to the disk file, and blames the valid outer archive for them.- Code:
- The loop at 3278-3287 moves every nested event at a reserved path to the archive container and appends "This applies to withheld archive member ..., not the disk file." It never checks where the event came from.
- In
src/skillspector/nested_artifacts.py(unchanged by this PR),archive_format_mismatchis recorded only in the top-level loop over disk inputs (1287-1291 and 1308-1312).archive_malformedis recorded only at depth 1 (735-740); deeper members getarchive_truncated. Archive members are never format-checked. - So when either reason appears on a reserved path, it can only belong to the disk file. The disk path is always a top-level input, so this happens whenever the colliding disk file is a mistyped or broken container.
- Measured (transcription,
graph.invoke,use_llm=False):- Disk
bundle.zip!/doc.docx(plain text) next tobundle.zip{doc.docx: text}:- Main:
archive_format_mismatchon the disk path. - PR:
bundle.zip archive_ambiguous_member_path,bundle.zip archive_format_mismatch, and a fatalartifact_path_collisionon the disk path. Nothing archive-related stays on the disk path. - Without the colliding member, both main and the PR keep the mismatch on the disk path.
- Main:
- Disk
bundle.zip!/inner.zip(aPKheader followed by garbage) next to a text memberinner.zip:- Main: a fatal
archive_malformedon the disk path. - PR: a non-fatal
archive_malformedonbundle.zip, which is a well-formed archive.
- Main: a fatal
- Plain-text disk
bundle.zip!/inner.zipnext to a memberinner.zipthat is a valid ZIP: the disk file'sarchive_format_mismatchmoves tobundle.zip. The internal note says it applies to the withheld member, which is the valid ZIP, so the note has the attribution backwards.
- Disk
- Consequence: the verdict does not change, because the collision is still fatal. But reports show
bundle.zipas mismatched or malformed, with the standard reason text, and the disk path loses its own container problem. This is the mixed provenance the fix for finding 3 set out to remove, now in the other direction.docs/NESTED_ARTIFACT_INSPECTION.md:70does not describe it. - Expected fix:
- Narrow fix: in the loop, leave
archive_format_mismatchandarchive_malformedevents on the disk path when that path is innested.outer_metadata. That map records only top-level inputs (nested_artifacts.py:821-828at depth 1, and 1265, 1280, 1301 and 1317). - Add a test with a non-ZIP disk
bundle.zip!/doc.docxand a colliding member. Assert thatarchive_format_mismatchstays on the disk path and does not appear onbundle.zip. The existing assertion attests/nodes/test_nested_artifacts.py:1468-1471still holds, because that disk file is a.txt. - A general fix needs each event's origin. One option is to skip the colliding member inside
_inspect_zip_bytes. A reviewer prototype of that (reviewer code, about 30 lines, not the PR's) records the ambiguity on the container and keeps these exceptions on the disk path in all three layouts. It also withholds the member's whole subtree, though, which is a policy change with a cost (see finding 2). - Align the docs sentence at line 70 with whichever behaviour you keep.
- Narrow fix: in the loop, leave
- Code:
-
[Non-blocking]
src/skillspector/nodes/build_context.py:3301: the filters and the event loop match reserved paths exactly, so the withheld member's descendants are still handled under the disk path's namespace. In the PR's own depth-2 layout, the fully analyzed disk child is labelled partial and ambiguous.- Code:
- The filters at 3301-3324 and the loop at 3278-3287 use exact membership in
reserved_paths. - When the colliding member is itself a ZIP, nested inspection has already expanded it (
nested_artifacts.py:1166-1177). - Because of the sort, the disk container expands first. The archive's copy of the same child path then hits the duplicate check (
nested_artifacts.py:856-861), which recordsarchive_ambiguous_member_pathon that path and setsinventory_overridesfor it (598). - The override loop at
build_context.py:3325-3328then applies to the disk child's row.
- The filters at 3301-3324 and the loop at 3278-3287 use exact membership in
- Measured (transcription):
- Depth-2 test layout: the disk child
bundle.zip!/inner.zip!/payload.txtkeeps its malicious bytes and P1, and its metadataouter_pathis the disk containerbundle.zip!/inner.zip. But its row ispartial/archive_ambiguous_member_path, and the public exceptions includearchive_ambiguous_member_pathon that path. The PR introduced this. On main, the row was also partial, but there the disk child really was the copy that was skipped. - Distinct child (disk
inner.zip{a.txt}, archiveinner.zip{b.txt}with an injection):bundle.zip!/inner.zip!/b.txtis analyzed, P1 and YR4 are reported on it, and itsouter_pathcorrectly saysbundle.zip. Meanwhile, the internal container note says memberbundle.zip!/inner.zip"was not analyzed". Main also analyzesb.txt(complete,execution_successfultrue), so only the contradictory note is new. - Plain-text disk
bundle.zip!/inner.zipwith a ZIP member holdingpayload.txt: P1 and YR4 are reported atbundle.zip!/inner.zip!/payload.txt(outer_pathbundle.zip). Main reports the same P1. - All three end with status failed,
execution_successfulfalse and exit 2.
- Depth-2 test layout: the disk child
- Consequence: a fully analyzed disk file is reported as partial with ambiguous provenance, and the note says the member was not analyzed when its children were.
docs/NESTED_ARTIFACT_INSPECTION.md:70says more than the code does. This is not a bypass, and no finding is lost compared with main. - Expected fix:
- At minimum, stop the archive side's duplicate-skip from marking disk rows. For a path under
<reserved>!/whose row came from the disk container (its metadataouter_pathis the reserved disk path), move thearchive_ambiguous_member_pathevent to the archive container and do not apply the override. - Extend the depth-2 test to assert that the disk child stays
analyzed, with noarchive_ambiguous_member_pathon its path. - Then choose and document a policy for the member's own descendants, and add a distinct-child test that pins it. Either option still fails closed.
- (a) Keep analyzing them, which is the current behaviour and main's. Then word the note and docs line 70 to say only the member file itself was not analyzed.
- (b) Withhold the whole subtree, as the direct reverse layout already does. The source-side skip from finding 1 gives (b), but it drops findings that both main and the PR report today. In the prototype, the distinct-child case goes from P1 and YR4 on
b.txt(score 50) to YR4 onbundle.ziponly (score 20). The plain-text case goes from DO_NOT_INSTALL (score 59) to CAUTION (score 29), and P1 is lost.
- At minimum, stop the archive side's duplicate-skip from marking disk rows. For a path under
- Code:
PIC tradeoffs:
- Post-hoc filtering or a source-side skip.
- The PR runs nested inspection unchanged, then filters each store by exact path and moves events. That keeps the change local, but it cannot tell an event's origin (finding 1) or a descendant's (finding 2). Every new nested store also needs its own filter; you added one for #579's store.
- Skipping the colliding member in
_inspect_zip_bytesremoves the sort, the filters and the event loop. It changes the descendant policy, though, and drops findings in the layouts in finding 2. - Either is acceptable if the docs match.
- FAILED for a real collision. This remains a sound choice. One side effect: a colliding executable disk file also gets SC9 HIGH and
excluded_executable_content, even though its bytes were analyzed. In the disk Python layout, AST4 and TM1 are reported alongside SC9. I accept this because it only affects genuine collisions, which already exit 2. - Recording withheld members, or analyzing both sides. The PR records them. Analyzing both sides under distinct identities would keep all evidence, but it remains a larger follow-up across findings, SARIF and reference resolution.
Verification and gaps:
- Head:
3d0d9cc642fd1f62dbcf6d68f3c9ba8106b615f8is the live head (re-checked withgh pr viewjust before writing). Its merge-base is current main5edc347c, and the diff touches only the four PR files. The author's merge commits add nothing outside them. - CI: changes, lint, test-unit, OpenCode TypeScript Tests, DCO Check and docker-smoke all completed with SUCCESS on this head. GitHub reports MERGEABLE/CLEAN, and all 8 earlier review threads are resolved.
- Conflicts: none with main;
git merge-treeis clean. Against the other open PRs:- Merge-tree is also clean with #794, #795 and #741. #796, #797 and #798 touch disjoint files.
- #794's readable-binary block and its renamed
content_interpretation_eventsrun after this hunk and only upgrade ANALYZED rows, so FAILED still dominates. - #795's OMS projection reads
local_file_cache, which keeps the disk bytes for colliding paths.
- Method:
- PR behaviour comes from my transcription of the live-head source hunks onto main
5edc347c. Checked againstgit showof the head, it differs only in local variable names, andinspection_ledger.pyis byte-identical. - I ran it with main's venv through
graph.invoke(use_llm=False) and CliRunnerscan --no-llm --format json. Main baselines and the mutation runs used the same harness. - The recomputed
work_idcannot collide with analyzer work: SYSTEM events never match analyzerplanned_work, and_merge_exception_projectionmerges public rows with the same key by design.
- PR behaviour comes from my transcription of the live-head source hunks onto main
- Tests: I ran main's tests from 9 related modules against the transcription: test_nested_artifacts, test_build_context, test_explicit_exclusions, test_finalize_inspection_ledger, test_analysis_completeness, integration/test_graph, test_primary_input_completeness, test_shared_python_ast and test_opaque_reference_reporting. 447 passed and 103 were deselected by markers. The full suite was not run.
- I did not run the PR's tests or code, per policy.
- Gaps:
- Local runs used Python 3.13.14 on macOS; CI uses 3.12. Windows, case-insensitive filesystems and
use_llm=Truewere not exercised. - SARIF, Markdown, terminal and MCP output were checked by reading code only (
report.py:910,1050and1530, andmcp_server.py:214-219). - The PR body's figures could not be checked independently: 744 tests against source and wheel, 6,243 combined regression tests, 19 sample pairs, and the live NVIDIA Build checks.
nested.recognized_zip_pathsis still unfiltered for withheld ZIP members (build_context.py:3343). The impact is limited because the colliding disk row is already FAILED; I did not measure it.- The source-side prototype mentioned in findings 1 and 2 is reviewer code. I checked it only against the layouts listed here and the same 447-test subset.
- Local runs used Python 3.13.14 on macOS; CI uses 3.12. Windows, case-insensitive filesystems and
Decision: Approved (reviewed head 3d0d9cc642fd1f62dbcf6d68f3c9ba8106b615f8)
A real file below a directory ending in
!could share a cache key with an archive member. Merging the archive could replace the disk file's bytes and hide its findings. Detect actual disk/member collisions, retain the disk bytes, and record a fatal attribution failure. Non-colliding!/paths keep their normal behavior.Withheld members and their existing exceptions are attributed to the archive container, with original reason and limit values preserved. Real disk containers expand first so nested virtual paths cannot displace their children. Later overrides cannot downgrade a collision from failed to partial.
Validation: 744 tests passed against source and a freshly installed wheel, including reversed/depth-two collisions, exclusions, ordinary delimiter paths, and Python source classification. Three focused synthetic skills also matched across source and wheel. Withheld archive metadata cannot overwrite a real file's Python classification. Verification was offline.
Combined verification across the updated PRs: 6,243 regression tests passed against source and again against the freshly installed wheel, with seven conditional skips and four expected failures per run. All 19 source/wheel sample pairs matched. The 12-skill corpus retained its findings and risk ratings; four former hangs now finish with explicit partial-analysis results. The 93 extension tests passed. Two synthetic live NVIDIA Build checks passed on the final wheel: benign-note was complete/SAFE, and the exfiltration sample retained SSD-3 with complete semantic and meta analysis and a DO_NOT_INSTALL recommendation. All seven recorded LLM analyses succeeded. Live checks used the configured model/reasoning defaults through a test-only proxy that kept the real credential outside the scanner.