Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6c7ca23
server: fixes from the main-track review of 9edcd4b
SamGu-NRX Sep 27, 2026
14dd051
server: find drift-dependent passing intervals by bracketing
SamGu-NRX Sep 27, 2026
903d86f
server: fixes from the Codex review of 6c7ca23
SamGu-NRX Sep 27, 2026
7133283
server: finish the height, segment and gap fixes (caretaker on 903d86f)
SamGu-NRX Sep 27, 2026
6200098
server: safety fixes from the Codex review of 7133283
SamGu-NRX Sep 27, 2026
9176125
server: document and pin the wall out_ft equality rule
SamGu-NRX Sep 27, 2026
4a9fa44
server: two false passes from the Codex review of 9176125
SamGu-NRX Sep 27, 2026
5682a67
server: every request settles in one capture (S4 on 4a9fa44)
SamGu-NRX Sep 27, 2026
5a4b4f4
server: the caretaker's three findings at 4a9fa44
SamGu-NRX Sep 27, 2026
86b2b4d
server: a conservative house, and the coverage tolerance counted once
SamGu-NRX Sep 27, 2026
22292a8
Test that more space never makes a check worse
SamGu-NRX Sep 27, 2026
a726199
Count ground seen past a limit end inside an unexplored end's reach
SamGu-NRX Sep 27, 2026
da6d308
Hold every PASS wherever the declared errors put things
SamGu-NRX Sep 27, 2026
0ac96a3
Judge spans with the battery's error along the walls, not the meter's
SamGu-NRX Sep 27, 2026
3baa338
Bound the route's meter error exactly, and hold a PASS strictly
SamGu-NRX Sep 27, 2026
6c7bea1
Fix the final review's side passage, drive, fixed-point and size find…
SamGu-NRX Sep 27, 2026
1d1e7e1
Move a hazard on another wall's line with that wall
SamGu-NRX Sep 27, 2026
3fb7303
Add review-hardening witness regressions (all failing on t3/server)
SamGu-NRX Oct 10, 2026
a8182aa
Implement the evidence, budget and reason/provenance repairs
SamGu-NRX Oct 10, 2026
468cb86
Add independent corner, ground, uncertainty and schema-compat witnesses
SamGu-NRX Oct 10, 2026
62bd963
Add scene-witness contract module (checkpoint; rebuild in progress af…
SamGu-NRX Oct 11, 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
27 changes: 14 additions & 13 deletions server/README.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,31 @@ def _limit_bytes(env: str, default_mb: float) -> int:
return int(mb * _MB)


def _limit_int(env: str, default: int) -> int:
raw = os.environ.get(env)
if raw is None:
return default
try:
value = int(raw)
except ValueError:
value = -1
if value <= 0:
raise ValueError(f"{env}={raw!r} must be a positive whole number")
return value


# Read once at import so a bad value stops the server at startup. Tests monkeypatch these names.
MAX_UPLOAD_BYTES = _limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256)
MAX_UNZIPPED_BYTES = _limit_bytes("HOUSESCAN_MAX_UNZIPPED_MB", 512)
# Parsing JSON takes many times its size in memory (10.5 MB took 255 MB), and a real scene.json
# is well under 1 MB, so scene.json itself is capped far below the bundle.
MAX_SCENE_BYTES = _limit_bytes("HOUSESCAN_MAX_SCENE_MB", 10)
# A bundle's central directory is parsed in full the moment ZipFile is built, so the directory's
# entry count and size are read from its end record BEFORE the bundle is opened. 20,000 entries
# is far above any real walk's file count, and a directory holding them is far under this byte
# cap; together they keep a crafted bundle from spending the request on metadata alone.
MAX_ZIP_ENTRIES = _limit_int("HOUSESCAN_MAX_ZIP_ENTRIES", 20_000)
MAX_ZIP_DIRECTORY_KB = _limit_int("HOUSESCAN_MAX_ZIP_DIRECTORY_KB", 8_192)

# Loaded at import: invalid rules must stop the server before it answers anything.
LOADED: LoadedRules = load_rules()
Expand Down Expand Up @@ -181,6 +200,8 @@ def _policy() -> dict[str, Any]:
"version": p.version,
# Same rule as solver.solve: no policy id means nothing is approved automatically.
"auto_approve": p.auto_approve and p.id is not None,
# Same rule as solver.solve: reported so a client can ask why nothing was rejected.
"allow_reject": p.allow_reject,
"sources": list(LOADED.sources),
"rules_sha256": LOADED.sha256,
"notice": p.notice,
Expand Down Expand Up @@ -298,8 +319,39 @@ def _check_scene_size(size: int) -> None:
)


def _zip_directory(data: bytes) -> tuple[int, int]:
"""The central directory's entry count and size, read from the zip's end record before any
metadata is parsed. `zipfile.ZipFile` reads the whole directory and builds every entry while
it is constructed, so a later count comes too late. `zipfile._EndRecData` is the function
ZipFile itself uses (ZIP64 included), so the sizes checked here are the ones it will read."""
try:
with io.BytesIO(data) as f:
end = zipfile._EndRecData(f)
except OSError:
end = None
Comment on lines +327 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Bad ZIP returns server error

_EndRecData() can raise BadZipFile when a ZIP64 locator says the archive spans multiple disks. The new call happens before the existing BadZipFile handler, and _zip_directory() catches only OSError. Such an upload now returns 500 internal_error instead of 400 unreadable_zip.

Catch BadZipFile here and preserve the existing refusal.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/api.py
Line: 327-331

Comment:
**Bad ZIP returns server error**

`_EndRecData()` can raise `BadZipFile` when a ZIP64 locator says the archive spans multiple disks. The new call happens before the existing `BadZipFile` handler, and `_zip_directory()` catches only `OSError`. Such an upload now returns 500 `internal_error` instead of 400 `unreadable_zip`.

Catch `BadZipFile` here and preserve the existing refusal.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

Comment on lines +327 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Catch malformed ZIP64 metadata during the precheck

For a ZIP carrying a ZIP64 locator with a corrupt offset or unsupported multi-disk metadata, zipfile._EndRecData raises BadZipFile, not OSError. Because this precheck catches only OSError and runs before the later guarded ZipFile construction, such malformed uploads escape as a generic 500 instead of the documented 400 unreadable_zip response.

Useful? React with 👍 / 👎.

if not end:
raise ApiError(
400, "unreadable_zip", "The bundle has no zip end-of-central-directory record."
)
return end[zipfile._ECD_ENTRIES_TOTAL], end[zipfile._ECD_SIZE]


def _open_bundle(data: bytes) -> tuple[bytes, set[str], str]:
"""scene.json's bytes, every file name in the bundle, and the folder scene.json sits in."""
entries, directory_bytes = _zip_directory(data)
if entries > MAX_ZIP_ENTRIES:
Comment on lines +341 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security ZIP entry cap is bypassed

_zip_directory() trusts the entry count claimed by the uploaded ZIP. Python's ZipFile ignores that count and reads entries until it reaches the declared directory byte size. A ZIP containing over 20,000 short entries can claim zero entries and remain below the 8 MB directory cap, bypassing MAX_ZIP_ENTRIES.

Count the directory records within the byte cap before constructing ZipFile, and add a test with a false claimed count. The directory byte cap still limits the overall size.

How this was verified: The upload supplies the claimed count, while ZipFile reads directory records using the byte size rather than that count.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/api.py
Line: 341-342

Comment:
**ZIP entry cap is bypassed**

`_zip_directory()` trusts the entry count claimed by the uploaded ZIP. Python's `ZipFile` ignores that count and reads entries until it reaches the declared directory byte size. A ZIP containing over 20,000 short entries can claim zero entries and remain below the 8 MB directory cap, bypassing `MAX_ZIP_ENTRIES`.

Count the directory records within the byte cap before constructing `ZipFile`, and add a test with a false claimed count. The directory byte cap still limits the overall size.

**How this was verified:** The upload supplies the claimed count, while `ZipFile` reads directory records using the byte size rather than that count.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

raise ApiError(
413,
"bundle_too_many_entries",
f"The bundle lists {entries} entries, over the {MAX_ZIP_ENTRIES} limit.",
)
Comment on lines +341 to +347

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify the parsed ZIP entry count

A crafted archive can underreport the entry count in its end-of-central-directory record while retaining a much larger central directory. Python's ZipFile iterates entries until it consumes the declared directory byte size rather than stopping at that count, so this precheck can accept entries <= MAX_ZIP_ENTRIES and then allocate metadata for far more than 20,000 entries. Check len(zf.infolist()) after parsing as well, or derive a trustworthy bound from the directory itself.

Useful? React with 👍 / 👎.

if directory_bytes > MAX_ZIP_DIRECTORY_KB * 1024:
raise ApiError(
413,
"bundle_directory_too_large",
f"The bundle's zip directory is {directory_bytes} bytes, over the "
f"{MAX_ZIP_DIRECTORY_KB} KB limit.",
)
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, zlib.error, ValueError, EOFError) as exc:
Expand Down
4 changes: 3 additions & 1 deletion server/examples/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def redirect_request(self, *args: object) -> None:
return None


_KEYED = urllib.request.build_opener(_NoRedirect)
# An explicit empty proxy table: with http_proxy set, a keyed request would otherwise go through
# the proxy, key included.
_KEYED = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect)


def post(url: str, body: bytes, key: str | None) -> tuple[int, dict]:
Expand Down
108 changes: 102 additions & 6 deletions server/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import Any, Literal

import yaml
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

SERVER_DIR = Path(__file__).resolve().parent
PUBLIC_RULES = SERVER_DIR / "rules.yaml"
Expand Down Expand Up @@ -90,6 +90,13 @@ class Sweep(_Strict):
wall_join_ft: Value
meter_to_wall_max_ft: Value

@model_validator(mode="after")
def _positive_step(self) -> "Sweep":
# Every solve steps the battery along the wall by this much; zero or less can't advance.
if self.step_ft.value <= 0:
raise ValueError(f"sweep.step_ft must be positive, got {self.step_ft.value}")
return self


class Clearances(_Strict):
gas_ft: Value
Expand Down Expand Up @@ -124,6 +131,16 @@ class MeterWorkingSpace(_Strict):
width_ft: Value
depth_ft: Value

@model_validator(mode="after")
def _positive_workspace(self) -> "MeterWorkingSpace":
# A zero width or depth collapses the working space to a line or a point; the solve
# would compare against a degenerate polygon and let a battery stand in the meter.
for name in ("width_ft", "depth_ft"):
value = getattr(self, name).value
if value <= 0:
raise ValueError(f"meter_working_space.{name} must be positive, got {value}")
return self


class Ground(_Strict):
allowed: list[GroundType]
Expand All @@ -139,6 +156,17 @@ class Route(_Strict):
corner_allowance_ft: Value
crossing: dict[ObjectType, Effect]

@model_validator(mode="after")
def _confident_within_max(self) -> "Route":
# Past the confident reach a run goes to review, past the maximum it fails; a confident
# reach beyond the maximum would let a run over the maximum pass.
if self.confident_reach_ft.value > self.max_ft.value:
raise ValueError(
f"route.confident_reach_ft ({self.confident_reach_ft.value}) must not exceed "
f"route.max_ft ({self.max_ft.value})"
)
return self


class Rules(_Strict):
policy: Policy
Expand All @@ -154,6 +182,18 @@ class Rules(_Strict):
ground: Ground
route: Route

@model_validator(mode="after")
def _exemption_within_the_opening_checks_height(self) -> "Rules":
# The opening check needs the wall seen only up to headroom height; a window above that
# would still count under a higher exemption but could be missed, unseen, above the view.
exempt = self.openings.exempt_bottom_above_ft
if exempt is not None and exempt > self.headroom.min_ft.value:
raise ValueError(
f"openings.exempt_bottom_above_ft ({exempt}) must not exceed headroom.min_ft "
f"({self.headroom.min_ft.value}), the wall height the opening check requires seen"
)
return self


@dataclass(frozen=True)
class LoadedRules:
Expand Down Expand Up @@ -251,11 +291,67 @@ def load_rules(private_path: Path | None = None) -> LoadedRules:
if private is None:
return rules_from_dict(data)
private_keys = frozenset(overridden_keys(data, private))
if "notice" not in private.get("policy", {}):
# The public notice says the answers are not under Base's rules; merged under private
# rules it would be false.
data["policy"]["notice"] = None
return rules_from_dict(deep_merge(data, private), ("public", "private"), private_keys)
merged = deep_merge(data, private)
# The public notice says the answers are not under Base's rules, which private rules make
# untrue; but any public placeholder they leave in place still decides answers, so the
# notice names those checks instead of going quiet.
own = private.get("policy", {}).get("notice")
merged["policy"]["notice"] = _mixed_notice(own, _placeholders_left(merged, private_keys))
return rules_from_dict(merged, ("public", "private"), private_keys)


# The check each placeholder rule decides; placeholders not tied to one check are named by key.
PLACEHOLDER_CHECKS = {
"clearances.drive_ft": "drive_clearance",
"clearances.pool_ft": "pool_clearance",
"clearances.wall_equipment_ft": "wall_equipment_above",
"headroom.min_ft": "headroom",
"ground": "ground_surface",
"route.confident_reach_ft": "route_length",
"route.corner_allowance_ft": "route_length",
"route.height_ft": "route_path",
}


def _placeholders_left(merged: dict[str, Any], private_keys: frozenset[str]) -> list[str]:
"""Dotted keys still marked placeholder that the private file did not set."""
left: list[str] = []

def walk(node: dict[str, Any], path: str) -> None:
if node.get("placeholder") is True:
# A cited value is replaced when the private file sets it; a group (such as ground)
# only when it sets every child that decides something, not just one of them.
if "value" in node:
replaced = path in private_keys
else:
children = [k for k in node if k not in ("source", "placeholder")]
replaced = all(
any(p == f"{path}.{k}" or p.startswith(f"{path}.{k}.") for p in private_keys)
for k in children
)
if not replaced:
left.append(path)
return
for key, value in node.items():
if isinstance(value, dict):
walk(value, f"{path}.{key}" if path else key)

walk(merged, "")
return left


def _mixed_notice(own: str | None, left: list[str]) -> str | None:
if not left:
return own
checks = sorted({PLACEHOLDER_CHECKS[k] for k in left if k in PLACEHOLDER_CHECKS})
other = sorted(k for k in left if k not in PLACEHOLDER_CHECKS)
parts = []
if checks:
parts.append("these checks still use public placeholder values: " + ", ".join(checks))
if other:
parts.append("placeholder settings still apply: " + ", ".join(other))
mixed = "Private rules, but " + "; ".join(parts) + "."
return f"{own} {mixed}" if own else mixed


def _public_policy(data: dict[str, Any]) -> dict[str, Any]:
Expand Down
Loading