Skip to content

fix(security): close an arbitrary file write and two path-injection holes (#17300) - #17301

Merged
mrveiss merged 6 commits into
mainfrom
issue-17300-path-injection
Sep 23, 2026
Merged

mrveiss merged 6 commits into
mainfrom
issue-17300-path-injection

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

Triaging the 111 open CodeQL alerts blocking #16801 turned up seven genuine py/path-injection findings among 78 verified false positives. Three are fixed here; they share a shape worth naming.

Each had validation — running too late, or on the wrong value. The attachment path parsed company_id as a UUID three lines after writing the file. The work-item route ran a correct IDOR guard against ctx.org_id, then built the storage path from a query parameter nothing had tied to it. This is not "validation was missing", it is "validation was present and not load-bearing", which is why four review passes over that code never flagged it.

The attachment case also is not the bug it looks like. It is not traversal:

root / "/tmp/evil" / wi / a.txt   ->   /tmp/evil/wi/a.txt

pathlib discards everything left of an absolute segment, so an absolute company_id replaces the storage root rather than climbing out of it. No amount of ".." not in value would have caught it. That is why the test suite pins the pathlib mechanism itself and not just the outcome.

What Changed

llc/services/attachment_service.py — arbitrary file write (high)

_storage_path now parses every id to a UUID before returning, so a value that is not a UUID can never become a path segment, and asserts containment under the resolved root — deliberately redundant, so the property is checkable without first reasoning about what uuid.UUID rejects. The attacker-supplied extension is bounded rather than trusted.

llc/api/work_items.py passes ctx.org_id instead of the query-string company_id. The parameter stays in the signature for API compatibility; it is simply no longer authoritative. Before this, one tenant could write into another's attachment tree by passing their id — a tenancy break independent of the traversal.

autobot-slm-backend/services/manifest_loader.py — traversal

role_name (a role query param, a role_name body field) was joined as its own path segment unvalidated; "../../../../etc" walked out of the infra base before both exists() and open(). Allowlisted to the shape an ansible role name actually has, plus the same containment check. Returns None rather than raising, which is the caller's existing "no manifest for this role" path and the honest answer for a name that cannot be a role.

autobot-slm-backend/api/code_source.py — information disclosure

The "Did you mean: …?" typo suggestion was computed by listing an arbitrary directory and returned to the caller. These routes are gated by get_current_user only, not by an admin role, so it handed any authenticated user one real filename from anywhere on the host. Still computed and logged for whoever is debugging a failed assignment; no longer in the response.

Verification

  • 13 attachment-path tests, 16 manifest-role tests, and the 26-test manifest suite alongside them — all green locally.
  • Tests assert on the resulting path, not on an exception type: what matters is that nothing outside the root is addressable, not that bad input is refused loudly.
  • Every parametrised set carries a positive case, so a guard that rejected everything could not pass. test_a_well_formed_upload_lands_under_the_root and test_a_real_role_name_resolves_under_the_infra_base are those cases.
  • test_the_absolute_case_would_have_escaped_without_the_fix pins the pathlib behaviour directly, so a later "fix" that swaps the UUID parse for a ".." check fails on the case that actually mattered.
  • Pre-push pytest green on the changed set.
  • One pre-existing failure in autobot-slm-backend/tests/services/test_token_denylist.py needs a live Redis (Redis database 'main' not ready) and is unrelated to this change.

Not verified: whether any poisoned storage_path rows already exist in a live database. download/delete read row.storage_path back, so a row written before this fix would still be honoured. Finding and remediating those is data work, which no agent does unattended — flagged on #17300 for a human decision rather than actioned here.

What this does NOT do

Four of the seven real findings remain, all in autobot-slm-backend. They are listed on #17300. #16801 stays red until they land, which is the gate working correctly.

Model Used

Claude Opus 5 (1M context)

Refs #17300
Refs #16801

Summary by CodeRabbit

  • Bug Fixes
    • Improved protection for uploaded attachments by ensuring they are stored within the authenticated organisation’s storage area, with unsafe paths and invalid identifiers rejected before files are written.
    • Prevented invalid role names from accessing files outside the intended area.
    • Local repository path errors no longer reveal suggestions for similarly named paths.

…oles (#17300)

Found by triaging the 111 open CodeQL alerts that block #16801. 78 were verified
false positives and dismissed with written reasons; these are three of the seven
that were real.

1. ARBITRARY FILE WRITE, llc attachments. _storage_path built
   `root / company_id / ...` from an unvalidated Query parameter. This was never
   a traversal bug: pathlib DISCARDS everything left of an absolute segment, so
   company_id="/tmp/evil" replaced the root rather than climbing out of it.
   Verified by running it:
       root / "/tmp/evil" / wi / a.txt  ->  /tmp/evil/wi/a.txt
   The uuid.UUID(company_id) cast that would have caught it ran three lines
   AFTER mkdir + write_bytes + read_text. The ValueError aborted only the DB
   insert; the bytes were already on disk, in a directory the caller chose.

   Every id is now parsed to a UUID inside _storage_path, before anything
   touches the filesystem, with a redundant containment assertion so the
   property is checkable without first reasoning about what uuid.UUID rejects.
   The attacker-supplied extension is bounded rather than trusted.

   It was also a tenancy break independent of the write: the route's IDOR guard
   proves the work item belongs to ctx.org_id, then passed the QUERY-STRING
   company_id to build the path, so the storage tree could disagree with the
   check that had just passed. The route now passes ctx.org_id.

2. manifest_loader joined role_name as its own path segment with no validation,
   from a `role` query param and a `role_name` body field:
   role_name="../../../../etc" walked out of the infra base before exists() and
   open(). Allowlisted to the shape an ansible role name actually has, plus the
   same redundant containment check. Returns None rather than raising, which is
   the caller's existing "no manifest for this role" path and the honest answer
   for a name that cannot be a role.

3. code_source returned a typo suggestion computed by listing an arbitrary
   directory -- handing any authenticated user one real filename from anywhere
   on the host. These routes are gated by get_current_user only, NOT by an admin
   role, which is what made it worth fixing rather than accepting. The
   suggestion is still computed and logged for whoever is debugging; it no
   longer travels in the response.

Tests assert on the resulting PATH, not on an exception type: what matters is
that nothing outside the root is addressable, not that bad input is refused
loudly. One test pins the pathlib mechanism itself, so a later "fix" that checks
for ".." instead of parsing the UUID fails on the case that actually mattered.
29 tests, each parametrised set carrying a positive case so a guard that
rejected everything could not pass.

Both touched API files sit exactly on their size ceilings, so the explanatory
comments are one line each rather than the paragraph this deserves; the
reasoning lives in the tests and in #17300.

Refs #17300
Refs #16801
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 9fcc52b1-192d-45ee-928f-d5398facfc5a

📥 Commits

Reviewing files that changed from the base of the PR and between 6b399b1 and 78ce5df.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (12)
  • autobot-backend/llc/api/companies.py
  • autobot-backend/llc/services/attachment_path_17300_test.py
  • autobot-backend/llc/services/attachment_service.py
  • autobot-backend/llc/tests/test_attachment_upload_tenant_17300.py
  • autobot-backend/llc/tests/test_attachments.py
  • autobot-backend/security/prompt_injection_detector_strict_mode_test.py
  • autobot-slm-backend/api/code_source.py
  • autobot-slm-backend/api/code_source_test.py
  • autobot-slm-backend/services/manifest_loader.py
  • autobot-slm-backend/services/system_secrets_vault.py
  • autobot-slm-backend/tests/services/manifest_role_name_17300_test.py
  • repo_tests/hooks_path_override_15961_test.py
📝 Walkthrough

Walkthrough

The changes validate attachment and manifest filesystem paths, use the authenticated organisation ID for attachment storage, and remove local repository path suggestions from HTTP 400 details.

Changes

Attachment storage paths

Layer / File(s) Summary
Validate attachment storage paths
autobot-backend/llc/api/work_items.py, autobot-backend/llc/services/attachment_service.py, autobot-backend/llc/services/attachment_path_17300_test.py
The API passes the authenticated organisation ID to the upload service. The service validates UUID path components, limits the filename suffix to 16 characters, checks that the resolved path is within the storage root, and obtains the path before writing. Tests cover path containment and invalid inputs.

Manifest role-name paths

Layer / File(s) Summary
Validate manifest role paths
autobot-slm-backend/services/manifest_loader.py, autobot-slm-backend/tests/services/manifest_role_name_17300_test.py
The loader accepts role names that match its allowlist and rejects invalid names or paths outside the infrastructure base. Invalid paths are treated as not found. Tests cover accepted names, rejected names, and an external manifest.

Local repository path responses

Layer / File(s) Summary
Change missing-path response details
autobot-slm-backend/api/code_source.py
For a missing repository path on the local node, the API still logs a similar sibling path but no longer includes the suggestion in the HTTP 400 detail.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 6b399

The path hardening works as intended, but one existing attachment test will now fail in the standard test run until its fixture uses a UUID. Malformed uploader IDs can also still leave orphaned files on disk. Fix both before merging. The remaining remote path-suggestion leak and the role-name regex gap are small follow-ups.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarises the main security changes: prevention of arbitrary file writes and path-injection vulnerabilities. It is concise and specific.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

…17300)

The last two of the seven real CodeQL findings, appended here rather than
opened as a second PR - same scope, same review, one CI run.

companies.py:741 returned str(exc) to the client from a BARE except Exception,
over a pm_config that had just been decrypted. An HTTP-client error routinely
embeds the URL it was handed, and that URL carries the integration's
credentials. Unlike the knowledge_mcp sites - which catch narrow types and fall
back to a generic message, and which I dismissed as false positives on exactly
that basis - this one caught everything and forwarded it. Logged in full
server-side, generic to the caller.

system_secrets_vault.py:166 logged the secret NAME on a read failure, forty
lines below a sibling function that deliberately refuses to log the same value
and documents why ("CodeQL traces it from a secret source into this sink").
Not a leak of a value, but the file disagreed with itself, and the caller
already knows which key it asked for - the exception type is what this line
adds. Now matches its sibling.

companies.py sits on its size ceiling, so the line the log statement needs was
reclaimed by collapsing an adjacent four-line HTTPException that fits on one
within the line limit. Ceiling lowered 1732 -> 1731 to match. I briefly raised
it to 1734 instead, which is backwards - a ratchet only turns down - and
reverted before committing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Suppress remote path suggestions in assign_code_source. · code_source.py:270

autobot-slm-backend/api/code_source.py:270
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟡 Minor | ⚡ Quick win

Information Disclosure

Reachability: External
Exploitability: Moderate
CWE: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor

Suppress remote path suggestions in assign_code_source.

The router requires Permission.SERVICE_MANAGEMENT, so this route is not available to every authenticated user. Service-management users can still receive similar_path, which discloses a remote repository path in the HTTP 400 detail. Remove the remote suggestion.

Remove the remote path suggestion
-            similar_path = await _find_similar_paths(node, repo_path)
-
             error_detail = f"Repository path does not exist on source node: {repo_path}"
-            if similar_path:
-                error_detail += f". Did you mean: {similar_path}?"

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b792c3ab-6df7-42d3-b70b-ad75e1d25ce2

📥 Commits

Reviewing files that changed from the base of the PR and between 4bae334 and 6b399b1.

📒 Files selected for processing (6)
  • autobot-backend/llc/api/work_items.py
  • autobot-backend/llc/services/attachment_path_17300_test.py
  • autobot-backend/llc/services/attachment_service.py
  • autobot-slm-backend/api/code_source.py
  • autobot-slm-backend/services/manifest_loader.py
  • autobot-slm-backend/tests/services/manifest_role_name_17300_test.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/llc/services/attachment_service.py
Comment thread autobot-backend/llc/services/attachment_service.py Outdated
Comment thread autobot-slm-backend/services/manifest_loader.py Outdated
Comment thread autobot-slm-backend/tests/services/manifest_role_name_17300_test.py
Comment thread autobot-slm-backend/services/manifest_loader.py Fixed
Comment thread autobot-slm-backend/services/manifest_loader.py Fixed
…roved nothing (#17300)

A pre-merge review pass found four things. Three are fixed here; the fourth is
filed as #17302.

1. MY OWN TEST WAS A FALSE NEGATIVE. test_a_refused_name_never_reaches_the
   _filesystem planted `name: pwned` as the manifest to be traversed to -- which
   fails RoleManifest validation, which _load_from_disk swallows in its generic
   `except Exception` and turns into None. So the test passed whether or not the
   guard existed: it was asserting that pydantic rejects a malformed file, not
   that traversal was blocked. Confirmed by reverting the guard and watching it
   stay green.

   The fixture is now a schema-valid manifest, so None can only mean the guard
   fired. Mutation-checked the other way too: with the guard removed the
   traversal now LOADS the planted manifest and the test fails. A control test
   pins that the fixture really does validate, so it cannot drift back into
   being invalid and silently restore the false negative.

   This is the exact defect class I have been flagging all session, in my own
   work, in a security test. A test that passes with the fix reverted is worse
   than no test: it certifies the thing it never checked.

2. code_source was HALF fixed. I removed the "Did you mean: <path>?" disclosure
   from the local-node branch and left the identical line in the remote/SSH
   branch twenty lines below -- same leak, same rule, a real filename from any
   directory on the remote node. Both branches now log rather than tell.
   Ceiling lowered 720 -> 719.

3. The route fix had NO test. Reverting work_items.py's
   `company_id=str(ctx.org_id)` back to the query parameter passed the entire
   existing suite, because test_work_items_idor.py mocks upload wholesale and
   never inspects its kwargs. Three tests added in their own file (that one is
   at its size ceiling): the service receives the authenticated org and not a
   different well-formed UUID from the query string, the absolute exploit string
   never reaches the service, and cross-tenant upload is still refused -- the
   contrast, without which a route that ignored company_id entirely would pass.
   Mutation-checked: 2 of 3 fail with the fix reverted.

Filed #17302 for the finding outside this diff: WorkProduct.storage_path is an
agent-supplied free-form string opened directly by ArtifactIngestor, gated only
by an extension allowlist with no containment check -- arbitrary file read, and
the sink is an indexer, so the disclosure persists in the KB.

Refs #17300
Refs #17302
…nput (#17300)

Four more review findings, and two of them say the same thing from different
directions: my manifest fix was correct and not PROVABLY correct.

CodeQL still flagged manifest_loader.py:93 and :104 after the allowlist went in
(alerts 1152, 1153) -- it does not recognise a regex as a sanitiser, and it was
right not to, because the property depended on reasoning about what the regex
excludes rather than on anything structural.

_manifest_path now builds the path from a DIRECTORY LISTING: the argument only
selects an entry that already exists under the infra base. There is no string
concatenation left for an attacker to steer, so traversal is impossible rather
than filtered, and impossible in a way a reader (and a scanner) can see. The
allowlist and the containment check stay as two cheaper filters in front.

The allowlist itself was wrong: `match()` with a trailing `$` also matches
before a final newline, so "backend\n" was accepted. `fullmatch()` now, and the
case is in the rejected set.

The containment check had never been exercised. Every rejected name in the test
file fails the ALLOWLIST first, so `is_relative_to` could have been deleted
without a single failure -- the same false-negative shape as the fixture defect
in the previous commit. A symlinked role directory passes the name check and the
listing and is caught only by the containment check; that case is now pinned.

Also in this commit, both surfaced by touching the files:
- test_attachments.py::test_storage_path_structure passed "att-001" as the
  attachment id. Production has always passed str(uuid.uuid4()); only the
  fixture was a stand-in, and _storage_path now parses all three ids.
- A pre-existing #7444 violation (Path.write_text inside an async test) in the
  same file. Fixed rather than exempted.

Contract change worth noting for review: a role must now EXIST to be resolvable.
A well-formed name for a role with no directory returns None instead of a path
that merely happens to be empty. Pinned by its own test.
hooks-path-override 6525 -> 6527 (population 6927), prompt-injection
2955 -> 2956 (population 3256). Both measured on the tree that merges with main.

The prompt-injection number is confirmed twice -- CI's figure and a direct
git ls-files count -- because a local run of that floor only became trustworthy
after #17298 made the sweep import declarations living outside repo_tests/.
Before that it checked a smaller set and reported it identically.

Twelfth re-pin of hooks-path-override, third today. This branch contributes two
counting files, both security regression tests; main had already spent the rest
of the allowance. Recorded again on #17142: at this cadence the fixed 400-file
growth allowance -- 5.8% of a ~6900-file tree -- is what needs changing, not the
floor.
Review finding, and it is my own defect repeated inside the same function. I
moved the validation for the three ids that FORM the path and left the two that
do not -- uploaded_by_agent_id and uploaded_by_user_id were still cast after
_write_local, so a malformed one raised with the file already on disk. Orphaned
file per attempt, repeatable, which is a disk filler.

The original bug was "validation ran after the write". I fixed it for the
parameters I was looking at and reproduced it for the parameters I was not.

All four ids are parsed before anything touches the filesystem now.

Tests assert on the DIRECTORY being empty, not on the exception: ValueError was
already raised before this change, so asserting the raise would have passed
throughout. What changed is that nothing is written. Plus the contrast -- a
well-formed upload still writes its bytes -- so a change that refused every
upload could not pass. Mutation-checked: reverting the early parse fails them.

The contrast test tripped #7444 (Path.read_bytes inside an async def); wrapped
in asyncio.to_thread rather than exempted, same as the earlier occurrence.
@mrveiss
mrveiss merged commit 4df6237 into main Sep 23, 2026
80 checks passed
@mrveiss
mrveiss deleted the issue-17300-path-injection branch September 23, 2026 08:44
mrveiss added a commit that referenced this pull request Sep 23, 2026
…453)

The last two of the nine open CodeQL alerts. Folded in here per the owner rather
than opened as a third security PR.

Seven of the nine were already fixed in main by #17301 and cleared on the rescan
that has since run (9 -> 2 open). These two did not, and I should have seen that
when I wrote the first fix: I removed the "Did you mean: <path>?" DISCLOSURE and
left the sinks. repo_path still reached Path(...).is_dir() and .iterdir()
unconfined, so any authenticated user could still test whether an arbitrary path
exists on the host. The oracle was quieter, not closed.

repo_path now goes through validate_path with roots from
AUTOBOT_CODE_SOURCE_ALLOWED_ROOTS. Unset defers to path_validator's own default
rather than restating "/opt/autobot" here -- a second copy is a value that can
drift from the one enforced, which is this PR's subject one layer down. The
hardcoded-values guard caught the literal in my first attempt.

Why confinement rather than an admin role: these routes are gated by
get_current_user only, and NO admin-role dependency exists anywhere in
autobot-slm-backend/api/. Adding one is a new authorisation tier for that
service -- an architecture decision, not a security patch.

Three existing tests used /tmp paths and started failing, correctly. They now
declare their root via the env var rather than the guard being weakened for
them. test_validate_local_path_not_exists moved inside the allowed root on
purpose: "/nonexistent/path" trips confinement first and returns a different
400, so the test would have silently stopped exercising the does-not-exist
branch it is named for.

Helpers live in api/_code_source_paths.py because code_source.py is
grandfathered at 719 lines and may not grow; it is back at exactly 719.

Refs #17300
mrveiss added a commit that referenced this pull request Sep 23, 2026
…ed (#15333)

#17301 merged and CodeQL's rescan of main closed the seven alerts it fixed, so
the count went 9 -> 2 and this PR's own gate went red:

    [codeql-ceiling] 2 open alert(s), ceiling 9
    [codeql-ceiling] FAIL: 2 open alert(s) is BELOW the ceiling of 9.

Which is the behaviour it was written for, arriving faster than expected. A
ceiling left above the real count re-licenses exactly the alerts that were just
adjudicated -- the next finding lands in the slack and nothing goes red. The
gate refused its own stale number before anyone had to notice.

The two that remain are the code_source.py path-injection pair, fixed in #17304;
that PR takes this to 0.
mrveiss added a commit that referenced this pull request Sep 23, 2026
…T and confine repo_path (#17304, #17300) (#17309)

* fix(deps): move the six Dependabot packages into the constraints SSOT (#17304)

58 open Dependabot alerts were six packages. The count was duplication: one
advisory seen through several resolution paths, and 48 of the 58 attributed to
pyproject.toml, which declares no dependencies at all -- it is [tool.black],
[tool.isort] and [tool.mypy].

What was actually exposed was two manifests, and they were exposed by DRIFT:
aiohttp had been raised to the patched 3.14.3 in five requirements files and
left at 3.13.4 in two others. Nothing compares a pin against the floor its
siblings already adopted, so a security bump applied to "the requirements file"
missed two of the seven that declare it.

The fix is adoption, not invention. constraints/shared.txt has been the single
source of truth since #10524 and the constraint-drift workflow already fails any
PR that re-declares a constrained version -- it simply constrained two packages,
numpy and openvino. These six were never brought in, which is the whole reason
they could drift.

Now guarded: aiohttp, chromadb, cryptography, nltk, numpy, openvino, pypdf,
transformers. Versions removed from 15 component files; three files that
declared a constrained package without referencing the constraints at all
(requirements.txt, docs/guides/requirements-local.txt,
autobot-backend/requirements-media.txt) now reference it, as do the
requirements-ci fragments, which their own header documents as individually
installable.

Verified rather than assumed:
  * every -c target resolves from the file that declares it;
  * no floor regressed -- each package's constraint equals the highest pin that
    existed anywhere before, checked against origin/main;
  * check_constraint_drift.py passes.

chromadb stays at >=1.5.9,<2.0.0 with 11 advisories and NO patched release
upstream. Constrained anyway so the version moves in one place the day a fix
ships. Three nltk advisories are likewise unpatched. Those 14 cannot be resolved
by bumping and need a dismissal decision.

Refs #17304

* fix(security): confine the code-source repo_path (#17300, CodeQL 452/453)

The last two of the nine open CodeQL alerts. Folded in here per the owner rather
than opened as a third security PR.

Seven of the nine were already fixed in main by #17301 and cleared on the rescan
that has since run (9 -> 2 open). These two did not, and I should have seen that
when I wrote the first fix: I removed the "Did you mean: <path>?" DISCLOSURE and
left the sinks. repo_path still reached Path(...).is_dir() and .iterdir()
unconfined, so any authenticated user could still test whether an arbitrary path
exists on the host. The oracle was quieter, not closed.

repo_path now goes through validate_path with roots from
AUTOBOT_CODE_SOURCE_ALLOWED_ROOTS. Unset defers to path_validator's own default
rather than restating "/opt/autobot" here -- a second copy is a value that can
drift from the one enforced, which is this PR's subject one layer down. The
hardcoded-values guard caught the literal in my first attempt.

Why confinement rather than an admin role: these routes are gated by
get_current_user only, and NO admin-role dependency exists anywhere in
autobot-slm-backend/api/. Adding one is a new authorisation tier for that
service -- an architecture decision, not a security patch.

Three existing tests used /tmp paths and started failing, correctly. They now
declare their root via the env var rather than the guard being weakened for
them. test_validate_local_path_not_exists moved inside the allowed root on
purpose: "/nonexistent/path" trips confinement first and returns a different
400, so the test would have silently stopped exercising the does-not-exist
branch it is named for.

Helpers live in api/_code_source_paths.py because code_source.py is
grandfathered at 719 lines and may not grow; it is back at exactly 719.

Refs #17300

* fix(deps): back out the constraints consolidation; the SSOT cannot reach ansible (#17304)

Three guards failed and two of them were right about something I had not
checked. Reverting the consolidation and keeping the security fix.

WHAT THE GUARDS FOUND

windows_npu_worker_transformers_bound_test: I turned `transformers==5.16.1`
into a bare name, which is exactly the regression #15298 fixed. That file is
pinned to an EXACT version on purpose -- an unbounded floor there is "one
dependency bump away from being affected, silently", in its own words. My change
would have let a fresh resolve land on 5.17+ on the one platform that was
deliberately held back.

ansible_requirements_parity_test: five ansible sites restate these versions, and
the guard's advice is to pass `-c <checkout>/constraints/shared.txt` instead,
"as roles/npu-worker and roles/tts-worker do". That advice does not transfer.
Neither dependency_patching nor redis/tasks/chromadb.yml references
code_source_dir or /opt/autobot at ALL: the first patches whatever venv
host_venv_path names on whatever host, the second builds a standalone venv on
the redis host. Adding a `-c` pointing into a checkout that may not exist there
would turn a security-patching role into a failing one -- strictly worse than
the duplication it removes.

THE ACTUAL LIMIT, which is worth recording

constraints/shared.txt works for components installed FROM the checkout. numpy
and openvino are in it because their consumers are exactly that. It structurally
cannot serve a role that builds a standalone venv on an arbitrary host, and all
five packages I moved have such a site. "Defined in a single place" is right as
a goal and is not reachable by this mechanism for these packages.

WHAT SURVIVES

The actual vulnerability: aiohttp raised from >=3.13.4 to >=3.14.3 in
docs/guides/requirements-local.txt and the windows npu worker -- the two of
seven manifests that a previous security bump missed. All seven now agree.

Plus the repo_path confinement (CodeQL 452/453) unchanged.

hooks-path-override floor 6527 -> 6529, thirteenth re-pin, fourth in two days.
mrveiss added a commit that referenced this pull request Sep 23, 2026
) (#17303)

* feat(ci): the open CodeQL alert backlog may not grow (#15333, #17300)

Detection was never the gap. CodeQL had already flagged all three lines of the
arbitrary file write found on 2026-09-23 (#17300); the alerts sat in a backlog
of 111 because nothing required anyone to look. So this is not another scanner.
It is a ceiling on how many findings may sit unadjudicated, and it only comes
down.

Adjudicated means one of two things, both of which reduce the count: the code
changed, or the alert was dismissed WITH A WRITTEN REASON. A dismissal with no
reasoning is not adjudication -- the reason is the artifact, the state change is
its receipt.

Enforced from code-quality.yml rather than codeql.yml, deliberately:
code-quality is in branch protection's required contexts and CodeQL is NOT, so
today a red CodeQL cannot block a merge. This can.

Fails closed on every way of not getting an answer -- no token, API error,
unparseable ceiling, missing file. "Could not look" must never render as "found
nothing", which is the confusion this whole area is about. It also fails when
the count drops BELOW the ceiling, because a ceiling left above the real count
re-licenses exactly the alerts that were just adjudicated.

Mutation-checked rather than reasoned about. The script: ceiling too high, too
low, file missing, API unreachable -- four paths, four distinct failures. The
repo_test that keeps it wired: removing the invocation, dropping
security-events: read, and adding continue-on-error are each caught.

Pinned at 9, the current count -- all nine are the sites fixed in #17301 and
clear when it merges. The number comes down in that same change.

Context for whoever reads this next: two OTHER security gates in this repository
have never executed once. ai-security-review needs CLAUDE_CODE_OAUTH_TOKEN and
lint-conventions' third-party-name check needs CONVENTIONS_DENYLIST; `gh secret
list` returns nothing, so both emit a notice and skip. Those need credentials,
not code, and are recorded on #15333.

Refs #15333
Refs #17300

* fix(ci): the alert-ceiling gate could be fooled the way it exists to prevent (#15333)

Three review findings, and the middle one is the gate failing open in exactly
the manner it was written to stop.

`jq -s 'add | length'` returns a number for ANY JSON, not just a list. An API
error body with nine keys counted as nine alerts -- and at a ceiling of nine,
PASSED. Verified:

    $ echo '{"message":"Not Found", ...9 keys...}' | jq -s 'add | length'
    9

So a script whose whole argument is that "could not look" must never render as
"found nothing" was doing precisely that to itself. Every page must now BE an
array before anything is counted, and a non-array body fails closed naming the
reason.

The ceiling parser took `head -1`, so a file with a second number silently below
the first was accepted, and `tr -d '[:space:]'` turned "9 0" into "90". Both
make a malformed ceiling MORE permissive, which is the direction that matters.
Exactly one value now, and only OUTER whitespace is trimmed -- "9 0" is rejected
as not a number rather than quietly becoming ninety.

The wiring guard read only the live tree, which proves what the repository looks
like today and nothing about whether the guard would notice a change. Each check
is now a predicate with a positive and a negative fixture. I had mutation-tested
these by hand and reported it; a hand-run once is not the same as a case that
runs every time, and the difference is the entire subject of this PR.

Mutation-checked: two values, zero values, "9 0", an error body from a stubbed
gh -- four distinct failures, none of them a pass.

* fix(ci): lower the alert ceiling 9 -> 2, which the gate itself demanded (#15333)

#17301 merged and CodeQL's rescan of main closed the seven alerts it fixed, so
the count went 9 -> 2 and this PR's own gate went red:

    [codeql-ceiling] 2 open alert(s), ceiling 9
    [codeql-ceiling] FAIL: 2 open alert(s) is BELOW the ceiling of 9.

Which is the behaviour it was written for, arriving faster than expected. A
ceiling left above the real count re-licenses exactly the alerts that were just
adjudicated -- the next finding lands in the slack and nothing goes red. The
gate refused its own stale number before anyone had to notice.

The two that remain are the code_source.py path-injection pair, fixed in #17304;
that PR takes this to 0.

* fix(ci): cover the ceiling script in the python filter, and stop a fixture posing as a read (#15333)

Two guard failures, one of them mine twice over.

python_filter_covers_its_guards: codeql_alert_ceiling_is_wired_15333_test.py
reads pipeline-scripts/check_codeql_alert_ceiling.sh by literal, but the
python-suite path filter did not cover that file -- so a change confined to the
script alone would take the required-context shim's green while the guard that
watches it never ran. Filter widened.

The same guard also flagged "./pipeline-scripts/check_codeql_alert_ceiling.sh",
which is not a read at all: it is the quoted path inside my own test FIXTURE,
picked up by the detector's quoted-literal scan. Over-collecting is the
detector's documented and deliberate posture, so the fix belongs on my side --
the fixture now spells the invocation without the "./" prefix, which is also the
form the real filter entry matches. The predicates search for the basename, so
nothing about what the test proves changes.

hooks-path-override floor 6527 -> 6529, measured 6929. Notable only because
#17304 needed the identical re-pin an hour ago on its own branch: two open
branches, same floor, same number, neither responsible. That is #17142's
argument with a second data point.

* fix(ci): validate the PR title that becomes the squash subject (#15473)

A squash merge takes its commit subject from the PR title, and nothing
validated the title. `fix(deps+security): ...` passed every local hook,
merged, and put a subject on main that lint-conventions.sh then rejected
for every PR opened afterwards -- including the release promotion, which
carries 682 commits. Local commit subjects were guarded at commit-msg
time; the one subject a human never types directly was not.

The rule now has ONE source. scripts/lib/commit-subject.ere holds the
ERE; lint-conventions.sh reads it for both its commit-msg and range
checks, and validate_pr_body.py compiles the same file for the new title
gate. #15473 asked for exactly this -- a second copy would have rebuilt
the defect one layer up, two enforcers of one rule free to drift. Both
readers fail closed: a rule file that is missing, empty, or carrying more
than one pattern is fatal, never a subject that passes.

The gate runs in pr-template-check, whose triggers include `edited` --
the only event that sees a title changed after the last push, and the
title at merge time is what becomes the subject. `--title-only` treats an
empty title as fatal there, because CI always has one, so empty means the
lookup failed. In --file mode empty still means "no PR yet", which is the
honest answer before `gh pr create`.

Commit 59f4be8 cannot be amended, so it is exempted by SHA rather than by
widening the pattern: `+` is not a scope separator this repo uses -- one
occurrence in 600 commits and it is that one. The exemption is written to
seven characters because CI's %h abbreviates against a three-object
shallow store; the ten-character form read off a full clone would have
matched where it was authored and missed where it has to fire. Two tests
pin that: no exemption longer than CI's abbreviation, and every exemption
resolving to exactly one object.

CodeQL ceiling 2 -> 0. Measured, not assumed: state=open returned 0 while
state=dismissed returned 393 in the same query, so the zero is a count and
not a query that failed.

Found while fixing this and filed rather than folded in: #17310 --
check 3 runs at fetch-depth 2, so it walks one commit and reports that as
a clean scan of the whole range.

Closes #15473

* fix(ci): register the conventions linter and its rule in the path filters (#15473)

Found while adding the shared rule file: editing `scripts/lint-conventions.sh`
triggered nothing. It is not in `code-quality.yml`'s `paths:`, so the workflow
that RUNS it never started, and `code-quality-required-context.yml` then
published the required `code-quality` context green -- on a linter no job had
executed. It is not in `python-paths.yml` either, so its own 34 tests did not
run on a change confined to it.

`scripts/lib/commit-subject.ere` had the second half of the same gap. The
workflow trigger and the per-job filter both carry `scripts/lib/**`, so the
LINTER runs when the rule changes; nothing matched it in `python-paths.yml`, so
the TESTS asserting the two readers agree did not. Those are different
questions and they need separate entries.

This is the #16249 shape one level out: `**/*.py` does not match a `.sh`, the
suite computes `python != 'true'`, and the shim reports green.

check_workflow_path_filters.py agrees the inline list and the canonical set are
still in step -- 26 entries, all carried.

Refs #15473

* fix(ci): do not judge a promotion PR's title as a squash subject (#15473)

Caught by running the new gate against the open release-promotion PR
before merging it, rather than after: it went red on
`chore(release): promote main to release`, and it was the gate that was
wrong. A main -> release promotion is merged, not squashed. The last one
landed as a merge commit (b94f51a, two parents, `Merge pull request
#15326 from ...`) -- a subject GitHub composes and lint-conventions.sh
exempts by prefix. The title never becomes a subject there, so a
promotion title correctly carries no issue reference: a promotion is not
an issue fix.

Both enforcers skip it, and a test asserts they agree. The workflow job
adds `base.ref == 'main'`; pr_fields now reads baseRefName and validate()
takes `base`. If only one skipped, a promotion PR would be red in exactly
one place -- and the wrong one is whichever nobody is looking at.

The default is "main", not "skip". --file mode has no PR to read a base
from, and a default that skipped would leave every ordinary PR unguarded
while still looking enforced.

The residual case costs nothing: were a promotion squash-merged after
all, that subject lands on `release`, and the range check reads
release..main, so a subject ON release is never in a scanned range.

Verification -- each mutation killed its test:

    drop the promotion skip     -> FAILED test_a_promotion_pr_skips...
    default base -> 'release'   -> FAILED x2 (the skip is not the default)
    workflow loses base.ref     -> FAILED test_the_workflow_job_skips...

    $ python3 scripts/validate_pr_body.py --pr 16801
    PR title: not checked -- base is 'release', not 'main'. ...
    exit=0

Refs #15473

* fix(ci): full-sha exemptions, a CI-safe existence check, and no fork skip (#15473)

Review findings on the squash-subject gate, all confirmed before acting.

THE EXEMPTION IS NOW A FULL SHA. The abbreviated form was wrong twice
over. Ten characters, read off a full clone, missed in CI's shallow
checkout, which prints seven -- that one I caught. Seven fixed it and
introduced the other half: `continue` skips BOTH the format check and the
issue-reference check, so a prefix that ever collided would exempt an
unrelated commit from the whole of check 3. `git log` now carries %H
beside %h; %h is kept only for the message.

THE EXISTENCE TEST WOULD HAVE REDDENED CI. It ran
`rev-parse --disambiguate=` against the repository, and ci.yml's
python-shard (215) and coverage.yml (81) both check out with a bare
actions/checkout@v7 -- fetch-depth 1. The object is two hops back, so it
is absent, and `--disambiguate=` exits 0 with empty output for a missing
object: indistinguishable from a dead exemption. The test would have
asserted 0 == 1 and failed red for a reason unrelated to the change under
test, which is precisely the defect this branch exists to fix. It now
uses `cat-file -e`, which can tell those apart, and SKIPS with a stated
reason on a shallow store rather than asserting through it.

Both mechanisms are pinned rather than assumed: one test builds a shallow
repo by writing .git/shallow and asserts the detection fires, another
asserts `cat-file -e` really does fail on an absent object.

NO FORK SKIP. check-title had copied check-template's, and the two guard
different things. Skipping forks is right for a template convention a
drive-by contributor was never shown (#14153); it is wrong for a squash
subject that becomes permanent and unamendable, where the population least
familiar with the convention is exactly the one going unchecked. The
justification for skipping -- an owner can edit the title at merge -- is
the same unenforced manual step whose absence caused the incident.

THE PARSER CANNOT PASS VACUOUSLY. Both exemption tests iterate over what
the regex finds, so a drifted pattern turned them green. Now counted
against an independent loose scan.

BASH FAIL-CLOSED IS TESTED, not just inspected. "Both readers fail closed"
was asserted on the python side and true only by reading on the other.
Four cases: missing, empty, comment-only, two patterns.

ONE TABLE, BOTH ENGINES. Each side hard-coded its own expectations, so a
future edit to commit-subject.ere introducing an engine-divergent
construct would be caught only if someone updated both tables identically
-- which is the drift a shared rule file exists to remove.

Verification -- every mutation killed its test:

    exemption back to a 7-char glob   -> FAILED parser_finds_what_is_there
    bash guards replaced by a default -> FAILED all 4 unusable_rule_file
    `(?:` into the shared rule        -> FAILED 4 grep_and_python_agree
                                         (non-capturing group in python,
                                          a literal in ERE)

    96 passed (lint_conventions_test + validate_pr_body_test)

Verified and NOT changed: main's required contexts are the 11 listed by
`gh api .../branches/main/protection`, and pr-template-check is not among
them, so this gate shows red without blocking. Adding it is a
branch-protection change, which is the owner's, not a PR's.

Refs #15473

* docs(ci): drop the superseded exemption rationale (#15473)

Second review pass. The full-sha change spliced in a new rationale
paragraph starting at "Seven characters, not the ten..." and left the
paragraph above it, so the file carried both "Recorded by SHA" and
"Recorded by FULL sha" -- the first describing the mechanism the same
commit had just replaced. Editing debris, no functional effect, and
exactly the kind a reader trips over while trying to work out which rule
is live.

The rest of that pass was clean and independently reproduced rather than
taken on trust: the 5-field parse verified by running the script over two
real ranges, the exemption confirmed to be doing real work (the same
subject fed through the shared ERE directly gives NO_MATCH), the shallow
skip confirmed not to mask any checkable environment -- `shallow` is a
shared/common git file, so a linked worktree reads the same state as the
main checkout -- and every new test reproduced as non-vacuous.

One caveat recorded rather than resolved: fork-PR behaviour of check-title
rests on documented token semantics (public repo, read-only token can GET
pull request metadata) plus the safety net that the job is not a required
context. No fork PR existed to observe it on, so the first real one should
be eyeballed.

Refs #15473

* fix(test): derive the commit-msg fixture's dependencies, re-pin the reach floor (#15473)

Shards 11/12 and 12/12, two unrelated causes.

SHARD 11 -- repo_tests/commit_msg_hook_17029_test.py listed the files its
throwaway repo needs as a hardcoded tuple of three. Adding
lib/commit-subject.ere to what lint-conventions.sh reads made that list
stale, so the fixture built something that was not a checkout. The script
then refused to run, correctly and loudly ("FATAL cannot read the
commit-subject rule"), and six tests read that refusal as the hook being
broken.

The list is now derived from the script, which is what
scripts/lint_conventions_test.py already does for its own fixture and for
this exact reason (#15245). Two fixtures with the same shape, one of them
fixed and one not; the second one is the one that broke. Mutation-checked:
narrowing the derivation back to *.sh reproduces the CI failure
(7 failed), widening it restores 8 passed.

SHARD 12 -- reach floor 6529 -> 6531, measured 6931. FOURTEENTH re-pin,
fifth in two days. This branch adds exactly two counting files out of 401
of allowance; main had consumed the other 399 before the branch existed.
Recorded on #17142 rather than absorbed silently again: five re-pins in
48 hours, every one this same shape, is a growth allowance that does not
describe how fast this tree adds files, not a floor that keeps being set
wrong.

    64 passed (reach_declarations + hooks_path_override + commit_msg_hook)

Refs #15473, #17142

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.

2 participants