Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
12d810f
chore: claim worktree for #15317
mrveiss Sep 17, 2026
1c2a977
security(infra): stop binding ChromaDB to every interface by default …
mrveiss Sep 17, 2026
dc85ec4
Merge branch 'main' into issue-15317-chromadb-bind
github-actions[bot] Sep 17, 2026
6512415
Merge branch 'main' into issue-15317-chromadb-bind
mrveiss Sep 18, 2026
a367488
fix(ci): chain formatter dispatch after the branch guard in pre-commi…
mrveiss Sep 18, 2026
f314235
fix(tests): prune SKIP dirs during the walk, not after rglob returns …
mrveiss Sep 18, 2026
4570656
test(security): a delegated subagent runs a write its parent is held …
mrveiss Sep 18, 2026
1abe893
fix(infra): gate doc-sync to main-at-origin-tip, move it off post-com…
mrveiss Sep 18, 2026
95185b0
Merge branch 'main' into issue-15317-chromadb-bind
mrveiss Sep 18, 2026
5170cca
fix(security): close the chromadb bind guard's two review gaps (#15317)
mrveiss Sep 18, 2026
6685f1f
Merge branch 'main' into issue-16923-git-hook-formatters
mrveiss Sep 18, 2026
07a9af5
Merge branch 'main' into issue-16601-shard10-hang
mrveiss Sep 18, 2026
c6790d4
Merge branch 'main' into issue-16950-delegation-laundering-test
mrveiss Sep 18, 2026
382ead4
Merge remote-tracking branch 'origin/issue-15317-chromadb-bind' into …
mrveiss Sep 18, 2026
cd87aab
Merge branch 'main' into issue-16934-worktree-doc-index-contamination
mrveiss Sep 18, 2026
4e7ddf8
fix(security): close 3 nits from the chromadb bind guard's re-review …
mrveiss Sep 18, 2026
fdbd1a4
fix(tests): add negative control that pruning happens during the walk…
mrveiss Sep 18, 2026
9410b8c
fix(security): restore empty-bind alternation and catch chromadb host…
mrveiss Sep 18, 2026
3844b77
fix(hooks): stop post-checkout reverting the #16923 pre-commit hook
mrveiss Sep 18, 2026
c19678e
fix(tests): add negative control that _python_files pruning happens d…
mrveiss Sep 18, 2026
c3d8b47
Merge remote-tracking branch 'origin/main' into issue-16958-sync-wt
mrveiss Sep 18, 2026
705a0f8
fix(hooks): back up a superseded pre-commit hook before overwriting (…
mrveiss Sep 18, 2026
e9308d9
chore(hooks): track the commit-msg hook, install it, and reject a sub…
mrveiss Sep 18, 2026
de45407
chore(hooks): take the root from the hook's working directory, not a …
mrveiss Sep 18, 2026
0114377
docs(hooks): no bypass in the hooks README — only the conforming rout…
mrveiss Sep 18, 2026
ceaaf7d
chore(vehicle): merge #16938 705a0f8fb2bc08aeea5ac4154654d4345098cdd2…
mrveiss Sep 18, 2026
b05548a
chore(vehicle): merge #17032 0114377306ee7ba14d9a15a26a21cd3066613d51…
mrveiss Sep 18, 2026
22b1f15
chore(vehicle): merge #16952 c19678eee3964cb782db4e390661c0dbb5fb1398…
mrveiss Sep 18, 2026
7f950ad
chore(vehicle): merge #16958 c3d8b4782f0d5bf5f43602c570d922c176f344d7…
mrveiss Sep 18, 2026
2a5658e
chore(vehicle): merge #16882 9410b8c3687c90d15480580a40e43986df30d334…
mrveiss Sep 18, 2026
f8716f8
chore(vehicle): merge #16959 cd87aab9411b2f9f8c65b4c1c76d62516da2dcd4…
mrveiss Sep 18, 2026
3310ac7
style(vehicle): lint-only fixes for code-quality on ci-hooks vehicle …
mrveiss Sep 18, 2026
0a0abdb
Merge branch 'main' into vehicle-v090-2026-09-18-ci-hooks
mrveiss Sep 18, 2026
27abb1c
Merge branch 'main' into vehicle-v090-2026-09-18-ci-hooks
mrveiss Sep 19, 2026
f921ee3
Merge branch 'main' into vehicle-v090-2026-09-18-ci-hooks
mrveiss Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,21 @@ repos:
stages: [pre-commit]
description: "Copies tracked post-commit hook into .git/hooks/ so it heals stash-pop conflicts"

# Bootstrap: install post-merge hook from tracked source (Issue #16934)
# Mirrors the install-post-checkout pattern above. The post-merge hook
# chains to doc-sync, moved here from post-commit so a worktree's own
# unmerged docs can no longer reach the shared, live autobot_docs collection.
- repo: local
hooks:
- id: install-post-merge
name: 'Install post-merge hook (Issue #16934)'
entry: bash -c 'SRC="scripts/hooks/post-merge"; DST="$(git rev-parse --git-common-dir)/hooks/post-merge"; [ -f "$SRC" ] && { [ ! -f "$DST" ] || ! diff -q "$SRC" "$DST" >/dev/null 2>&1; } && cp "$SRC" "$DST" && chmod +x "$DST" && echo "post-merge hook installed" || true'
language: system
pass_filenames: false
always_run: true
stages: [pre-commit]
description: "Copies tracked post-merge hook into .git/hooks/ so doc-sync runs on merge/pull, not on commit"

# AutoBot custom: canonical-check Wave 0 (#7458). The three runners discover
# rule modules from their respective registries and emit pre-commit-style
# violations on staged files only. BLOCK-severity rules fail the commit;
Expand Down
83 changes: 75 additions & 8 deletions autobot-backend/auth_rbac_admin_guard_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@

import ast
import functools
import os
import pathlib
import sys

import pytest

Expand Down Expand Up @@ -100,18 +102,30 @@ def _offenders_in(tree: ast.AST) -> list[int]:


def _python_files():
for path in BACKEND_ROOT.rglob("*.py"):
# #16601: walked with os.walk, pruning SKIP_DIR_PARTS from dirnames in
# place, rather than BACKEND_ROOT.rglob("*.py") filtered afterward.
# rglob has no pruning hook -- it descends into every directory
# unconditionally and only skips the FILES it finds there, so it still
# pays the full traversal cost of whatever sits under a denylisted
# directory. A shard-10 hang was measured frozen inside rglob's own
# scandir call for 40+ minutes with zero progress (in a sibling sweep,
# repo_tests/collected_test_model.py, same anti-pattern); this sweep
# walks the same backend tree with the same shape and was flagged
# alongside it (#16915) for the same reason.
for dirpath, dirnames, filenames in os.walk(BACKEND_ROOT):
# #14484: relative to the scan root, never the absolute path. Testing
# ``set(path.parts)`` asks whether the *checkout* sits under a directory
# named `archive`/`migrations`/`venv` as well as whether the file does,
# so the guard's reach depended on where the tree was cloned.
if SKIP_DIR_PARTS & set(path.relative_to(BACKEND_ROOT).parts):
continue
if path.name.endswith("_test.py") or path.name.startswith("test_"):
continue
if path.name in EXEMPT_FILES:
continue
yield path
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_PARTS]
for filename in filenames:
if not filename.endswith(".py"):
continue
if filename.endswith("_test.py") or filename.startswith("test_"):
continue
if filename in EXEMPT_FILES:
continue
yield pathlib.Path(dirpath, filename)


def _may_contain_offender(source: str) -> bool:
Expand Down Expand Up @@ -235,3 +249,56 @@ def test_detects_offending_shapes(self, source):
)
def test_ignores_acceptable_shapes(self, source):
assert _offenders_in(ast.parse(source)) == []


# ---------------------------------------------------------------------------
# Does the walk actually PRUNE a SKIP_DIR_PARTS directory, or merely filter it
# out of the result afterward? (#16601)
#
# ``BACKEND_ROOT.rglob("*.py")`` filtered post-hoc and ``os.walk()`` with
# ``dirnames[:] = [...]`` pruning produce the identical final file list --
# that identity is exactly why no assertion on ``_python_files()``'s RETURN
# VALUE can tell them apart, and why the shard-10 hang (rglob descending into
# a huge excluded directory regardless) shipped without any test noticing.
# This test instead watches which directories ``os.walk`` is fed to next: a
# SKIP_DIR_PARTS directory holding a sentinel file must never be YIELDED by
# the walk at all, which is true only when ``dirnames`` is mutated in place
# before the walk continues past it.
# ---------------------------------------------------------------------------


def test_python_files_prunes_skip_dirs_during_the_walk_not_after(tmp_path, monkeypatch) -> None:
skip_name = next(iter(SKIP_DIR_PARTS))
skip_dir = tmp_path / skip_name
skip_dir.mkdir()
# A file that would be collected if this directory were ever descended
# into -- its mere presence in the returned files, or its directory being
# visited at all, is the tell.
(skip_dir / "sentinel.py").write_text("role = 'admin'\n", encoding="utf-8")
kept_dir = tmp_path / "kept"
kept_dir.mkdir()
(kept_dir / "kept.py").write_text("role = 'admin'\n", encoding="utf-8")

visited_dirpaths: list[str] = []
real_walk = os.walk

def spying_walk(root, *args, **kwargs):
for dirpath, dirnames, filenames in real_walk(root, *args, **kwargs):
visited_dirpaths.append(dirpath)
yield dirpath, dirnames, filenames

monkeypatch.setattr(sys.modules[__name__], "BACKEND_ROOT", tmp_path)
monkeypatch.setattr(os, "walk", spying_walk)

files = list(_python_files())

assert str(skip_dir) not in visited_dirpaths, (
f"os.walk descended into {skip_dir}, a directory in SKIP_DIR_PARTS -- "
"pruning must mutate `dirnames` in place BEFORE the walk continues past "
"it, not filter the results afterward (#16601). A post-hoc filter would "
"leave this directory out of the returned files just the same, which is "
"exactly the negative-control gap this test closes."
)
assert files == [kept_dir / "kept.py"], (
"the sentinel file under the SKIP_DIR_PARTS directory must never reach " "the returned generator either"
)
100 changes: 100 additions & 0 deletions autobot-backend/chat_workflow/delegation_laundering_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""A delegated subagent must not do what its parent is held from doing (#16950).

The parent here runs under a work item that declares ``writing files`` as
approval-gated, so its own ``write_file`` is held for a human. It then calls the
``delegate`` tool. The child is built from the child profile alone
(``build_governed_identity({"agent_id": agent_type}, ...)``), which drops the
parent's approval categories and work item. So the child's ``write_file`` runs
unapproved: the parent's refusal is laundered through the delegation.

The assertion is on the outcome, not a field. The real ``enforce_work_item_approval``
is asked about the context the child actually runs with. Any fix that carries the
originator's authority across the hop makes it pass, however it is represented.

``test_the_child_is_held_as_its_parent_is`` is a STRICT xfail on purpose, not a
skipped test to tidy away. It reports XFAIL on ``main`` because the gap is real,
and ``strict=True`` turns the fix's XPASS into a hard failure. The marker must
therefore be removed in the same commit that closes the hole. ``raises=AssertionError``
confines the expected failure to that one claim: broken setup, or a delegation that
never ran, fails outright rather than counting as proof.
"""

from types import SimpleNamespace
from unittest.mock import patch

import pytest

import chat_workflow
from chat_workflow import delegation
from chat_workflow.models import AgentContext
from chat_workflow.tool_dispatch_guards import enforce_work_item_approval

#: A file write, which the ``writing files`` category gates.
_WRITE = {"name": "write_file", "params": {"path": "notes.md", "content": "x"}}


def _held_parent() -> SimpleNamespace:
"""A governed parent whose work item gates file writes on human approval."""
return SimpleNamespace(
agent_context=AgentContext(agent_id="research_agent", session_id="s-parent"),
requires_approval_before=["writing files"],
work_item_id="wi-16950",
context={},
auth_role="user",
)


async def _delegate_write(parent) -> tuple[list, dict]:
"""Run the real ``delegate`` handler from *parent*; return its results and the child ctx it built."""
from chat_workflow.tool_handler import ToolHandlerMixin

captured: dict = {}

async def _fake_loop(ctx):
captured["child"] = ctx
yield (["written"], [], None)

fake_manager = SimpleNamespace(_execute_llm_continuation_loop=_fake_loop)
call = {"params": {"task": "write notes.md", "agent_type": "documentation_agent", "engine": "internal"}}
results: list = []
mixin = ToolHandlerMixin.__new__(ToolHandlerMixin)
with (
patch.object(delegation, "DELEGATION_ENABLED", True),
patch.object(chat_workflow, "get_chat_workflow_manager", return_value=fake_manager),
):
_ = [m async for m in mixin._handle_delegate_tool(call, results, parent)]
return results, captured


@pytest.mark.asyncio
async def test_the_parent_is_held_and_the_delegation_runs():
"""The preconditions, asserted where a failure is a real failure and never an expected one."""
parent = _held_parent()

assert enforce_work_item_approval(dict(_WRITE), parent, []) is not None, "the parent must be held"

results, captured = await _delegate_write(parent)

assert results and results[0]["status"] == "completed", results
assert captured["child"].agent_context.agent_id == "documentation_agent"


@pytest.mark.asyncio
@pytest.mark.xfail(
strict=True,
raises=AssertionError,
reason="#16950: delegation drops the parent's approval gates, so the child's write_file is not held. "
"Remove this marker in the commit that closes the hole.",
)
async def test_the_child_is_held_as_its_parent_is():
_, captured = await _delegate_write(_held_parent())
if "child" not in captured:
pytest.fail("the delegation did not run, so the check below would pass or fail for the wrong reason")

held = enforce_work_item_approval(dict(_WRITE), captured["child"], [])

assert held is not None, "a parent held for approval delegated the write, and the child ran it unapproved"
50 changes: 25 additions & 25 deletions autobot-backend/services/knowledge/doc_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
Documentation Indexer Service

Issue #1385: Consolidated documentation indexing with ChromaDB as single source of truth.
Extracted from autobot-infrastructure/shared/tools/index_documentation.py and adapted
for service use (SSOT config, proper path constants, thread-safe singleton).
Extracted from autobot-infrastructure/shared/tools/index_documentation.py and adapted for
service use (SSOT config, proper path constants, thread-safe singleton).

Replaces the dual Redis KB + ChromaDB CLI approach with a single ChromaDB-based system.
"""
Expand All @@ -29,6 +29,7 @@
from autobot_shared.doc_chunking import estimate_tokens as _estimate_tokens
from autobot_shared.doc_chunking import process_h2_sections as _process_h2_sections
from autobot_shared.logging_manager import get_logger
from autobot_shared.paths import shared_cache_path

if TYPE_CHECKING:
from services.knowledge.sync_queue import SyncQueueEntry, SyncReason # noqa: F401
Expand Down Expand Up @@ -378,7 +379,9 @@ def _discover_files(root_dir: Path, tier: int | None = None) -> List[Tuple[str,
# HASH CACHE for incremental indexing
# ============================================================================

HASH_CACHE_FILE = PATH.DATA_DIR / ".doc_index_hashes.json"
# #16934: shared git-common-dir, not per-checkout PATH.DATA_DIR -- one shared collection.
_HASH_CACHE_FALLBACK = PATH.DATA_DIR / ".doc_index_hashes.json"
HASH_CACHE_FILE = shared_cache_path("autobot-doc-index", ".doc_index_hashes.json", fallback=_HASH_CACHE_FALLBACK)


def _compute_file_hash(file_path: str) -> str:
Expand Down Expand Up @@ -909,10 +912,7 @@ def _check_hash_and_update_cache(self, file_str: str, force: bool) -> bool:
return False

async def _index_file_chunks(self, file_str: str, content: str, rel_path: str, tier: int) -> tuple[int, int]:
"""Chunk content and index all chunks; return (success_count, chunk_count). Issue #2735.

Extracted from index_file to keep parent under 65 lines.
"""
"""Chunk content and index all chunks; return (success_count, chunk_count). Issue #2735."""
import asyncio

body, fm_tags, fm_aliases = _parse_frontmatter(content)
Expand All @@ -932,16 +932,7 @@ async def _index_file_chunks(self, file_str: str, content: str, rel_path: str, t
return indexed, len(chunks)

async def index_file(self, file_path: Path, tier: int = 3, force: bool = False) -> IndexResult:
"""Index a single documentation file into ChromaDB.

Args:
file_path: Path to the markdown file.
tier: Priority tier (1=critical, 2=high, 3=medium).
force: If True, skip hash check.

Returns:
IndexResult with counts.
"""
"""Index a single markdown file at the given priority tier; force skips the hash check."""
if not self._initialized:
await self.initialize()

Expand Down Expand Up @@ -1088,14 +1079,7 @@ async def _run_kb_synthesis(self, indexed_paths: List[str]) -> None:
logger.exception("DocIndexerService: KB synthesis failed (non-fatal)")

async def index_all(self, force: bool = False) -> IndexResult:
"""Index all documentation files.

Args:
force: If True, re-index all files regardless of hash cache.

Returns:
IndexResult with aggregate counts.
"""
"""Index every documentation file; force ignores the hash cache and re-indexes all of them."""
start_time = time.time()

if not self._initialized:
Expand Down Expand Up @@ -1161,6 +1145,22 @@ async def index_all(self, force: bool = False) -> IndexResult:

return total_result

async def rebuild_from_scratch(self) -> IndexResult:
"""Delete the collection and re-index only what the current tree contains (#16934).

Even ``index_all(force=True)`` never removes a chunk: discovery walks files
that exist now, so a chunk from a file absent here (e.g. one indexed from a
branch before Issue #16934 gated indexing to main-at-origin-tip) survives
indefinitely. Deleting the collection first is the only way to guarantee that.
"""
if not self._initialized:
if not await self.initialize():
return IndexResult(errors=["Failed to initialize"])
self._client.delete_collection(self.COLLECTION_NAME)
self._initialized = False
self._collection = None
return await self.index_all(force=True)

async def enqueue_reindex(
self,
document_path: str,
Expand Down
Loading
Loading