This commit is contained in:
slxng1758 2026-08-27 05:17:38 -04:00 committed by GitHub
commit 081db92cbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 795 additions and 14 deletions

View file

@ -13,15 +13,19 @@ from __future__ import annotations
import json
import logging
import os
import re
import shutil
import stat
import subprocess
import tempfile
from enum import Enum
from pathlib import Path
from typing import Any
from headroom import binaries
from headroom._subprocess import run
from headroom.proxy import runtime_env
from headroom.proxy.project_context import get_current_cwd, is_current_request_trusted
from . import base
@ -88,6 +92,177 @@ _PATTERNS: dict[str, list[str]] = {
OUTLINE_MARKER = " # ... (body elided by Headroom; Read a specific line range to see it)\n"
# Per-client banner signatures -- add an entry only once a client's exact
# banner text is confirmed, never a generic keyword match.
_TRUNCATION_SIGNATURES: tuple[re.Pattern[str], ...] = (
# Claude Code: "[Truncated: PARTIAL view -- <path>: showing lines A-B of
# T total (...). Call Read with offset=N to see more.]"
re.compile(
r"\[\s*truncated\s*:\s*partial\s+view\b"
r"[^\[\]]*?"
r"showing\s+lines?\s+(?P<start_line>\d+)\s*[-]\s*(?P<end_line>\d+)"
r"\s+of\s+(?P<total_lines>\d+)\s+total"
r"[^\[\]]*\]",
re.IGNORECASE,
),
)
def _is_plausible_truncation_range(
start_line: int, end_line: int, total_lines: int, source_line_count: int
) -> bool:
# end_line == total_lines means the whole file was shown, not truncated.
if start_line < 1 or end_line < start_line or end_line >= total_lines:
return False
return end_line <= source_line_count
def _detect_truncation(source: str) -> tuple[int, int] | None:
"""Return (end_line, total_lines) if `source` carries a recognized,
internally-consistent upstream truncation banner, else None."""
source_line_count = len(source.splitlines())
for pattern in _TRUNCATION_SIGNATURES:
for m in pattern.finditer(source):
start_line = int(m.group("start_line"))
end_line = int(m.group("end_line"))
total_lines = int(m.group("total_lines"))
if _is_plausible_truncation_range(start_line, end_line, total_lines, source_line_count):
return end_line, total_lines
return None
class ReadVerificationResult(Enum):
"""Client-independent fallback for `_detect_truncation`'s banner regex:
compares tool_output against the real file on disk instead of parsing
client-specific prose. Only used when the banner regex finds nothing."""
COMPLETE = "complete"
TRUNCATED = "truncated"
# Unresolvable path, unreadable file, or mismatched content — never guess.
UNKNOWN = "unknown"
def _verify_truncation_on_disk_enabled() -> bool:
# Live read (not a module constant), matching _min_chars_to_rewrite()'s
# hot-reload behavior.
return runtime_env.getenv("HEADROOM_VERIFY_TRUNCATION_ON_DISK", "").lower() in (
"1",
"true",
"yes",
)
def _max_disk_verify_bytes() -> int:
# Live read, same hot-reload pattern as _min_chars_to_rewrite(). 5 MB
# comfortably covers real source files while bounding worst-case read
# time/memory for the disk-verify fallback.
try:
return int(runtime_env.getenv("HEADROOM_VERIFY_TRUNCATION_MAX_BYTES", "5000000"))
except (TypeError, ValueError):
return 5_000_000
def _resolve_read_path_in_workspace(file_path: str, resolved_root: Path) -> Path | None:
"""Confine `file_path` (relative or absolute) strictly under `resolved_root`.
Mirrors memory_handler._resolve_native_path's join/resolve/relative_to
pattern. `resolved_root` must already be canonicalized by the caller (a
single source of truth for "inside the workspace" across a call) --
this does not re-resolve it. `.resolve()` collapses symlinks, including
in intermediate path components, before the containment check runs, so
a symlink that points outside the workspace is rejected the same way a
`..` traversal is -- this is a path-segment containment check via
`relative_to()`, not a string-prefix check, so a sibling directory that
merely shares a string prefix with the root is correctly rejected too.
"""
candidate = Path(file_path) if Path(file_path).is_absolute() else resolved_root / file_path
try:
resolved = candidate.resolve(strict=True)
except OSError:
return None
try:
resolved.relative_to(resolved_root)
except ValueError:
return None
return resolved
def _read_disk_content_bounded(resolved: Path, max_bytes: int) -> str | None:
"""Read `resolved` iff it's a regular file no larger than `max_bytes`.
Opens with O_NONBLOCK so a FIFO/special file with no writer returns
immediately instead of blocking the calling thread indefinitely --
O_NONBLOCK has no effect on reads once fstat confirms a regular file.
fstat runs on the already-open fd (not the path) so the type/size
check and the read happen on the same underlying file object, closing
the TOCTOU gap a separate path-based stat-then-open would leave.
"""
try:
fd = os.open(resolved, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
return None
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > max_bytes:
return None
with os.fdopen(fd, "r", encoding="utf-8") as f:
fd = -1 # ownership transferred to the file object
return f.read()
except (OSError, UnicodeDecodeError):
return None
finally:
if fd >= 0:
os.close(fd)
def _verify_read_against_disk(
file_path: str | None,
received_content: str,
cwd: str | None,
*,
trusted: bool,
) -> tuple[ReadVerificationResult, tuple[int, int] | None]:
"""Compare `received_content` against the real file at `file_path`.
`cwd` (the `x-headroom-cwd` header) is never authority on its own --
`trusted` (whether the request's peer is loopback, a server-observed
fact, not a header) must hold before `cwd` or `file_path` are even
inspected, let alone touch disk. Once trusted, every read must be a
regular file whose fully resolved path is beneath the canonically
resolved `cwd`; anything else is UNKNOWN, never a guess.
TRUNCATED requires an exact-prefix match with strictly more on disk
a weaker match means the file diverged since the client read it, not
a provable truncation, so it's UNKNOWN. Returns `(visible_lines,
total_lines)` alongside TRUNCATED so the header can cite real numbers
without a second, potentially racy, read.
"""
if not trusted:
return ReadVerificationResult.UNKNOWN, None
if not file_path:
return ReadVerificationResult.UNKNOWN, None
if not cwd or not os.path.isabs(cwd):
return ReadVerificationResult.UNKNOWN, None
try:
resolved_root = Path(cwd).resolve(strict=True)
except OSError:
return ReadVerificationResult.UNKNOWN, None
if not resolved_root.is_dir():
return ReadVerificationResult.UNKNOWN, None
resolved = _resolve_read_path_in_workspace(file_path, resolved_root)
if resolved is None:
return ReadVerificationResult.UNKNOWN, None
disk_content = _read_disk_content_bounded(resolved, _max_disk_verify_bytes())
if disk_content is None:
return ReadVerificationResult.UNKNOWN, None
if disk_content == received_content:
return ReadVerificationResult.COMPLETE, None
if disk_content.startswith(received_content) and len(disk_content) > len(received_content):
visible_lines = len(received_content.splitlines())
total_lines = len(disk_content.splitlines())
return ReadVerificationResult.TRUNCATED, (visible_lines, total_lines)
return ReadVerificationResult.UNKNOWN, None
class AstGrepReadOutline:
"""Interceptor that outlines verbose code-file Read outputs."""
@ -131,7 +306,18 @@ class AstGrepReadOutline:
if not matches:
return None
outline = _build_outline(matches, tool_output)
# Banner (cheap, no I/O) wins; disk verification is the opt-in fallback.
truncation = _detect_truncation(tool_output)
if truncation is None and _verify_truncation_on_disk_enabled():
verdict, disk_truncation = _verify_read_against_disk(
_path_from_input(tool_input),
tool_output,
get_current_cwd(),
trusted=is_current_request_trusted(),
)
if verdict is ReadVerificationResult.TRUNCATED:
truncation = disk_truncation
outline = _build_outline(matches, tool_output, truncation)
return outline if outline else None
def progressive_disclosure_key(
@ -258,12 +444,21 @@ def _run_ast_grep(
return all_matches
def _build_outline(matches: list[dict[str, Any]], source: str) -> str | None:
def _build_outline(
matches: list[dict[str, Any]],
source: str,
truncation: tuple[int, int] | None = None,
) -> str | None:
"""Build a compact outline from ast-grep matches.
Emits each definition's signature line + docstring (if next line is a
string literal) + an elision marker. Matches are sorted by byte offset
so the outline tracks the original file order.
`truncation`, if given, is (end_line, total_lines) from an upstream
truncation banner already present in `source` (e.g. a client's own Read
token-cap notice). When set, the header states that the input was a
partial view instead of implying `source` is the whole file.
"""
lines = source.splitlines(keepends=True)
outline_chunks: list[str] = []
@ -292,11 +487,21 @@ def _build_outline(matches: list[dict[str, Any]], source: str) -> str | None:
if not outline_chunks:
return None
header = (
"[headroom: outlined by ast-grep — "
f"{len(seen_starts)} definition(s); "
"bodies elided. Re-read the file with a line range to see a specific body.]\n"
)
if truncation:
end_line, total_lines = truncation
header = (
"[headroom: outlined by ast-grep — "
f"{len(seen_starts)} definition(s) in the visible portion; "
f"input was truncated upstream (showing through line {end_line} of {total_lines} total). "
"Bodies elided. Re-read remaining lines to see more.]\n"
)
else:
header = (
"[headroom: outlined by ast-grep — "
f"{len(seen_starts)} definition(s); "
"bodies elided. Re-read the file with a line range to see a specific body.]\n"
)
return header + "".join(outline_chunks)

View file

@ -11,6 +11,22 @@ savings to a project without threading a parameter through every handler.
The value is sanitized (printable characters only, length-capped) before it
is stored; an absent or unusable header simply leaves attribution off for
that request, matching pre-feature behavior.
The HTTP middleware also binds the raw ``x-headroom-cwd`` header into a
second, unsanitized contextvar for consumers that need the literal
filesystem path (e.g. verifying a Read tool_result against disk). Not
(yet) bound at the WebSocket accept paths an absent cwd there is already
treated as "can't resolve, don't guess."
A third contextvar, ``_current_request_trusted``, records whether the
active request's peer is loopback — a fact the HTTP middleware observes
from the TCP connection itself, not something a caller can assert via a
header. Consumers that turn ``_current_cwd`` into a filesystem read (again,
disk verification) must gate on this first: the cwd header alone is never
sufficient authority for touching disk, only a signal to interpret once the
peer is already known to be trusted. Defaults to ``False`` so any request
path that never calls the setter (e.g. the WebSocket accept path) is safe
by construction.
"""
from __future__ import annotations
@ -31,6 +47,15 @@ from headroom.proxy.savings_tracker import sanitize_project_name
_current_project: ContextVar[str | None] = ContextVar("headroom_current_project", default=None)
# Unsanitized, unlike _current_project — consumers join this against a
# tool's file_path and read from disk, so it must stay the literal path.
_current_cwd: ContextVar[str | None] = ContextVar("headroom_current_cwd", default=None)
# Server-observed (not header-derived) loopback signal — see module docstring.
_current_request_trusted: ContextVar[bool] = ContextVar(
"headroom_current_request_trusted", default=False
)
def set_current_project(project: str | None) -> None:
"""Bind the active request's project for downstream outcome recording."""
@ -42,6 +67,26 @@ def get_current_project() -> str | None:
return _current_project.get()
def set_current_cwd(cwd: str | None) -> None:
"""Bind the active request's ``x-headroom-cwd`` header value, unmodified."""
_current_cwd.set(cwd.strip() if isinstance(cwd, str) and cwd.strip() else None)
def get_current_cwd() -> str | None:
"""Raw cwd header bound to the current request context, or ``None``."""
return _current_cwd.get()
def set_current_request_trusted(trusted: bool) -> None:
"""Bind whether the active request's peer is loopback."""
_current_request_trusted.set(trusted)
def is_current_request_trusted() -> bool:
"""Whether the active request's peer is loopback, or ``False`` if unset."""
return _current_request_trusted.get()
def strip_project_path_prefix(scope: MutableMapping[str, Any]) -> str | None:
"""Strip a ``/p/<name>`` prefix from an ASGI scope, returning the name.
@ -58,8 +103,12 @@ __all__ = [
"PROJECT_HEADER",
"PROJECT_PATH_PREFIX",
"classify_project",
"get_current_cwd",
"get_current_project",
"is_current_request_trusted",
"set_current_cwd",
"set_current_project",
"set_current_request_trusted",
"split_project_path",
"strip_project_path_prefix",
"with_project_prefix",

View file

@ -75,6 +75,16 @@ RUNTIME_ENV_KNOBS: tuple[Knob, ...] = (
"int",
"Min tool-output chars before the ast-grep read rewrite.",
),
Knob(
"HEADROOM_VERIFY_TRUNCATION_ON_DISK",
"bool",
"Verify ast-grep Read truncation against disk when no client banner is found.",
),
Knob(
"HEADROOM_VERIFY_TRUNCATION_MAX_BYTES",
"int",
"Max on-disk file size (bytes) the disk-verify fallback will read.",
),
)
_KNOBS_BY_ENV: dict[str, Knob] = {k.env: k for k in RUNTIME_ENV_KNOBS}

View file

@ -163,7 +163,9 @@ from headroom.proxy.modes import (
from headroom.proxy.probe_recorder import probe_recorder_from_env
from headroom.proxy.project_context import (
classify_project,
set_current_cwd,
set_current_project,
set_current_request_trusted,
strip_project_path_prefix,
)
from headroom.proxy.prometheus_metrics import PrometheusMetrics # noqa: F401
@ -2618,6 +2620,7 @@ class WebSocketProjectPrefixMiddleware:
name.decode("latin-1"): value.decode("latin-1") for name, value in scope["headers"]
}
set_current_project(classify_project(headers) or prefix_project)
# No set_current_cwd() here -- HTTP-only for now, see project_context.py.
await self.app(scope, receive, send)
@ -3360,7 +3363,19 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
method = request.method
query = request.url.query
headers = dict(request.headers.items())
client = getattr(request, "client", None)
client_addr = ""
client_host = None
if client is not None:
client_host = getattr(client, "host", None)
client_port = getattr(client, "port", None)
client_addr = f"{client_host}:{client_port}" if client_port else str(client_host)
set_current_project(classify_project(headers) or prefix_project)
set_current_cwd(headers.get("x-headroom-cwd"))
# Server-observed (not header-derived) trust signal for consumers that
# turn x-headroom-cwd into a filesystem read (e.g. astgrep disk
# verification) -- the header alone is never sufficient authority.
set_current_request_trusted(is_loopback_host(client_host))
# Path-based Codex identification: stamp X-Client: codex on the
# Responses endpoint for callers that don't otherwise classify (e.g.
# Codex Desktop, whose User-Agent isn't a known codex UA). Without it
@ -3370,12 +3385,6 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
# makes every downstream classify_client(headers) read "codex".
if should_stamp_codex_client(path, headers):
request.scope["headers"].append((b"x-client", b"codex"))
client = getattr(request, "client", None)
client_addr = ""
if client is not None:
client_host = getattr(client, "host", None)
client_port = getattr(client, "port", None)
client_addr = f"{client_host}:{client_port}" if client_port else str(client_host)
try:
proxy.metrics.record_inbound_request(method=method, path=path)
except Exception:

View file

@ -745,3 +745,44 @@ def test_dashboard_client_cidr_does_not_expand_other_management_endpoints(
assert client.get("/admin/upstream").status_code == 404
assert client.get("/debug/tasks").status_code == 404
assert client.post("/stats/reset").status_code == 404
def _app_with_trust_probe() -> FastAPI:
"""`_record_headroom_stack` binds `is_current_request_trusted()` from the
peer's loopback-ness before any route runs; this test-only route reads
it back so the binding itself (not just a downstream consumer like
astgrep's disk-verify fallback) can be asserted directly."""
from headroom.proxy.project_context import is_current_request_trusted
app = _make_app()
@app.get("/__test/trusted")
def _trusted_probe() -> dict[str, bool]:
return {"trusted": is_current_request_trusted()}
# A catch-all passthrough route registered by create_app() would
# otherwise shadow this path -- Starlette matches routes in
# registration order, not by specificity.
app.router.routes.insert(0, app.router.routes.pop())
return app
def test_record_headroom_stack_binds_trusted_true_for_loopback_client() -> None:
client = TestClient(
_app_with_trust_probe(), base_url="http://127.0.0.1", client=("127.0.0.1", 12345)
)
resp = client.get("/__test/trusted", headers={"x-headroom-cwd": "/some/spoofed/path"})
assert resp.json() == {"trusted": True}
def test_record_headroom_stack_binds_trusted_false_for_non_loopback_client() -> None:
"""A spoofed x-headroom-cwd header from a non-loopback peer must not
make the request trusted -- the header carries no authority on its
own. Direct regression test for the disk-verify oracle finding."""
client = TestClient(
_app_with_trust_probe(),
base_url="http://100.82.0.2:8787",
client=("100.90.0.5", 12345),
)
resp = client.get("/__test/trusted", headers={"x-headroom-cwd": "/some/spoofed/path"})
assert resp.json() == {"trusted": False}

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import os
import sys
import textwrap
import pytest
@ -14,8 +16,17 @@ from headroom.proxy.interceptors import (
interceptor_failure_counts,
register,
)
from headroom.proxy.interceptors.astgrep import AstGrepReadOutline
from headroom.proxy.interceptors.astgrep import (
AstGrepReadOutline,
ReadVerificationResult,
_verify_read_against_disk,
)
from headroom.proxy.interceptors.base import reset_interceptor_failure_counts
from headroom.proxy.project_context import (
get_current_cwd,
set_current_cwd,
set_current_request_trusted,
)
from headroom.tokenizer import Tokenizer
@ -211,6 +222,462 @@ def test_astgrep_outlines_large_python_read(tokenizer):
assert "def apply_promo" in new_content
# Bodies should NOT leak through unchanged.
assert "total += item.price * item.qty" not in new_content
# Complete-file control: no truncation banner in the input -> no truncation marker.
assert "truncated upstream" not in new_content
def test_astgrep_flags_truncated_read(tokenizer):
truncated_source = (
_PY_FIXTURE + "\n\n[Truncated: PARTIAL view — /repo/payments.py: "
"showing lines 1-42 of 90 total (26031 tokens, cap 25000). "
"Call Read with offset=43 to see more.]\n"
)
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": "/repo/payments.py"},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": truncated_source}],
},
]
result = apply_to_messages(messages, tokenizer)
assert len(result.spans) == 1
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" in new_content
assert "showing through line 42 of 90 total" in new_content
# Still lists the definitions actually present in the visible portion.
assert "def process_payment" in new_content
assert "def apply_promo" in new_content
def _read_result_messages(content: str, file_path: str = "/repo/payments.py"):
return [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": file_path},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": content}],
},
]
def test_astgrep_flags_truncated_read_wording_variants(tokenizer):
"""The signature tolerates wording/casing/dash variation, but only inside
the recognized envelope not as a synonym match over arbitrary prose."""
truncated_source = (
_PY_FIXTURE + "\n\n[TRUNCATED: PARTIAL VIEW — /repo/payments.py: "
"SHOWING LINES 142 of 90 TOTAL. Call Read with offset=43 to see more.]\n"
)
messages = _read_result_messages(truncated_source)
result = apply_to_messages(messages, tokenizer)
assert len(result.spans) == 1
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" in new_content
assert "showing through line 42 of 90 total" in new_content
def test_astgrep_ignores_truncation_phrase_in_comment(tokenizer):
"""A count-shaped phrase in a plain comment, with no bracketed envelope,
must not be read as an upstream truncation claim."""
source_with_comment = (
_PY_FIXTURE + "\n\n# API pagination showing lines 10-20 of 30 total records\n"
)
messages = _read_result_messages(source_with_comment)
result = apply_to_messages(messages, tokenizer)
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" not in new_content
def test_astgrep_ignores_bracketed_phrase_without_signature(tokenizer):
"""Brackets plus a count-shaped phrase aren't enough on their own — the
exact recognized signature phrase must also be present."""
source_with_bracket = (
_PY_FIXTURE + "\n\n[Truncation happened; showing lines 1-42 of 90 total]\n"
)
messages = _read_result_messages(source_with_bracket)
result = apply_to_messages(messages, tokenizer)
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" not in new_content
@pytest.mark.parametrize(
"banner_numbers",
[
pytest.param("50-90 of 90", id="end_equals_total"),
pytest.param("42-10 of 90", id="end_less_than_start"),
pytest.param("0-42 of 90", id="start_is_zero"),
pytest.param("1-5000 of 9000", id="end_exceeds_visible_payload"),
],
)
def test_astgrep_ignores_malformed_truncation_counts(tokenizer, banner_numbers):
truncated_source = (
_PY_FIXTURE + "\n\n[Truncated: PARTIAL view — /repo/payments.py: "
f"showing lines {banner_numbers} total (26031 tokens, cap 25000). "
"Call Read with offset=43 to see more.]\n"
)
messages = _read_result_messages(truncated_source)
result = apply_to_messages(messages, tokenizer)
new_content = result.messages[1]["content"][0]["content"]
assert "outlined by ast-grep" in new_content
assert "truncated upstream" not in new_content
def test_astgrep_accepts_truncation_at_start_equals_end(tokenizer):
"""`end >= start` is inclusive — a single-line visible window is valid."""
truncated_source = (
_PY_FIXTURE + "\n\n[Truncated: PARTIAL view — /repo/payments.py: "
"showing lines 42-42 of 90 total (26031 tokens, cap 25000). "
"Call Read with offset=43 to see more.]\n"
)
messages = _read_result_messages(truncated_source)
result = apply_to_messages(messages, tokenizer)
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" in new_content
# -------- Disk verification: client-independent truncation fallback ----- #
def test_set_get_current_cwd_round_trips():
set_current_cwd(" /repo/project ")
try:
assert get_current_cwd() == "/repo/project"
finally:
set_current_cwd(None)
def test_set_current_cwd_none_and_blank_both_clear():
set_current_cwd("/repo")
try:
assert get_current_cwd() == "/repo"
set_current_cwd(" ")
assert get_current_cwd() is None
finally:
set_current_cwd(None)
class TestVerifyReadAgainstDisk:
def test_missing_file_path_is_unknown(self):
verdict, info = _verify_read_against_disk(None, "abc", "/repo", trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_relative_path_without_cwd_is_unknown(self):
verdict, info = _verify_read_against_disk("payments.py", "abc", None, trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_untrusted_request_never_touches_disk(self, tmp_path, monkeypatch):
"""A fully valid cwd/file_path/content combo that would resolve
COMPLETE if trusted must short-circuit to UNKNOWN -- and never call
os.open -- when the request isn't trusted. The regression guarantee
is "untrusted -> UNKNOWN without any filesystem operation," not just
"untrusted -> UNKNOWN.\""""
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
opened: list[object] = []
real_open = os.open
def _tracking_open(*args, **kwargs):
opened.append(args)
return real_open(*args, **kwargs)
monkeypatch.setattr(os, "open", _tracking_open)
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(tmp_path), trusted=False)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
assert opened == []
def test_missing_file_under_workspace_root_is_unknown(self, tmp_path):
verdict, info = _verify_read_against_disk(
str(tmp_path / "missing.py"), "abc", str(tmp_path), trusted=True
)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_exact_match_is_complete(self, tmp_path):
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(tmp_path), trusted=True)
assert verdict is ReadVerificationResult.COMPLETE
assert info is None
def test_strict_prefix_is_truncated(self, tmp_path):
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
partial = _PY_FIXTURE[:200]
verdict, info = _verify_read_against_disk(str(f), partial, str(tmp_path), trusted=True)
assert verdict is ReadVerificationResult.TRUNCATED
assert info == (len(partial.splitlines()), len(_PY_FIXTURE.splitlines()))
def test_relative_path_resolves_against_cwd(self, tmp_path):
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
partial = _PY_FIXTURE[:200]
verdict, info = _verify_read_against_disk(
"payments.py", partial, str(tmp_path), trusted=True
)
assert verdict is ReadVerificationResult.TRUNCATED
assert info is not None
def test_content_mismatch_is_unknown_not_truncated(self, tmp_path):
# File diverged since the client read it -- not a clean prefix.
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE.replace("compute_subtotal", "compute_total"), encoding="utf-8")
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(tmp_path), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_absolute_path_outside_workspace_root_is_unknown(self, tmp_path):
root = tmp_path / "project"
root.mkdir()
sibling = tmp_path / "other"
sibling.mkdir()
f = sibling / "secret.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(root), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_relative_traversal_escapes_workspace_is_unknown(self, tmp_path):
root = tmp_path / "project"
root.mkdir()
f = tmp_path / "secret.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
verdict, info = _verify_read_against_disk(
"../secret.py", _PY_FIXTURE, str(root), trusted=True
)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_sibling_directory_sharing_string_prefix_is_not_inside_workspace(self, tmp_path):
"""`relative_to()` is a path-segment containment check, not a string
prefix check -- a target that merely starts with the root's string
must not be treated as inside it."""
root = tmp_path / "project"
root.mkdir()
other = tmp_path / "project-other"
other.mkdir()
f = other / "secret.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(root), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
@pytest.mark.skipif(
sys.platform == "win32", reason="symlinks need elevated privilege on Windows"
)
def test_symlinked_file_escapes_workspace_is_unknown(self, tmp_path):
root = tmp_path / "project"
root.mkdir()
outside = tmp_path / "outside.py"
outside.write_text(_PY_FIXTURE, encoding="utf-8")
link = root / "link.py"
link.symlink_to(outside)
verdict, info = _verify_read_against_disk("link.py", _PY_FIXTURE, str(root), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_directory_passed_as_file_path_is_unknown(self, tmp_path):
# Also proves O_NONBLOCK doesn't need a FIFO fixture to matter: this
# exercises the same os.open(..., O_NONBLOCK) path, just rejected by
# the S_ISREG check rather than by not blocking on a missing writer.
root = tmp_path / "project"
subdir = root / "subdir"
subdir.mkdir(parents=True)
verdict, info = _verify_read_against_disk("subdir", "abc", str(root), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_oversized_file_exceeding_byte_cap_is_unknown(self, tmp_path, monkeypatch):
monkeypatch.setenv("HEADROOM_VERIFY_TRUNCATION_MAX_BYTES", "10")
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
assert len(_PY_FIXTURE.encode("utf-8")) > 10
# Content matches exactly -- would be COMPLETE without the cap.
verdict, info = _verify_read_against_disk(str(f), _PY_FIXTURE, str(tmp_path), trusted=True)
assert verdict is ReadVerificationResult.UNKNOWN
assert info is None
def test_astgrep_disk_verification_flags_truncation_when_no_banner(
tokenizer, tmp_path, monkeypatch
):
"""Opted in, cwd bound, file on disk is strictly longer than the tool_result,
and no banner is present -- disk verification alone should qualify the header."""
monkeypatch.setenv("HEADROOM_VERIFY_TRUNCATION_ON_DISK", "1")
monkeypatch.setenv("HEADROOM_INTERCEPT_READ_MIN_CHARS", "50")
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
# Split after most functions -- a too-short partial's elision markers
# can outweigh tiny bodies and trip the "refuse to enlarge" guard.
marker = "\n\ndef format_receipt"
partial = _PY_FIXTURE[: _PY_FIXTURE.index(marker)] # no banner text anywhere
assert "truncated" not in partial.lower()
set_current_cwd(str(tmp_path))
set_current_request_trusted(True)
try:
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": "payments.py"},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": partial}],
},
]
result = apply_to_messages(messages, tokenizer)
finally:
set_current_cwd(None)
set_current_request_trusted(False)
assert len(result.spans) == 1
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" in new_content
visible_lines = len(partial.splitlines())
total_lines = len(_PY_FIXTURE.splitlines())
assert f"showing through line {visible_lines} of {total_lines} total" in new_content
assert "def compute_subtotal" in new_content
assert "def apply_promo" in new_content
# Beyond the truncation point -- never reached ast-grep, can't appear.
assert "def format_receipt" not in new_content
def test_astgrep_disk_verification_disabled_by_default(tokenizer, tmp_path, monkeypatch):
"""Same truncated-on-disk scenario as above, but without the opt-in env
var -- must behave exactly like the no-signal case (no header change)."""
monkeypatch.delenv("HEADROOM_VERIFY_TRUNCATION_ON_DISK", raising=False)
monkeypatch.setenv("HEADROOM_INTERCEPT_READ_MIN_CHARS", "50")
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
marker = "\n\ndef format_receipt"
partial = _PY_FIXTURE[: _PY_FIXTURE.index(marker)]
set_current_cwd(str(tmp_path))
set_current_request_trusted(True)
try:
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": "payments.py"},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": partial}],
},
]
result = apply_to_messages(messages, tokenizer)
finally:
set_current_cwd(None)
set_current_request_trusted(False)
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" not in new_content
def test_astgrep_disk_verification_skips_when_request_untrusted(tokenizer, tmp_path, monkeypatch):
"""Same truncated-on-disk scenario, opted in and cwd bound, but the
request's peer was never marked loopback-trusted (the contextvar's
False default) -- must behave exactly like the no-signal case."""
monkeypatch.setenv("HEADROOM_VERIFY_TRUNCATION_ON_DISK", "1")
monkeypatch.setenv("HEADROOM_INTERCEPT_READ_MIN_CHARS", "50")
f = tmp_path / "payments.py"
f.write_text(_PY_FIXTURE, encoding="utf-8")
marker = "\n\ndef format_receipt"
partial = _PY_FIXTURE[: _PY_FIXTURE.index(marker)]
set_current_cwd(str(tmp_path))
# Deliberately not calling set_current_request_trusted(True).
try:
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": "payments.py"},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": partial}],
},
]
result = apply_to_messages(messages, tokenizer)
finally:
set_current_cwd(None)
new_content = result.messages[1]["content"][0]["content"]
assert "truncated upstream" not in new_content
def test_astgrep_banner_detection_takes_priority_over_disk_verification(tokenizer, monkeypatch):
"""When a banner is already present, disk verification must not run at
all (no cwd bound here -- if it ran, resolution would fail anyway), and
the banner's own numbers must be what the header reports."""
monkeypatch.setenv("HEADROOM_VERIFY_TRUNCATION_ON_DISK", "1")
truncated_source = (
_PY_FIXTURE + "\n\n[Truncated: PARTIAL view — /repo/payments.py: "
"showing lines 1-42 of 90 total (26031 tokens, cap 25000). "
"Call Read with offset=43 to see more.]\n"
)
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "abc",
"name": "Read",
"input": {"file_path": "/repo/payments.py"},
}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": truncated_source}],
},
]
result = apply_to_messages(messages, tokenizer)
new_content = result.messages[1]["content"][0]["content"]
assert "showing through line 42 of 90 total" in new_content
def test_astgrep_skips_small_files(tokenizer):