feat(prover): delivery B: handler-named operations, Symfony, Livewire, Remix and Tornado entries, enforcement witnesses, memoised path walk

Codex delivery under spec access-control-prover plus one fix of mine. Operation rows may now name a handler by
name pattern and framework (Jenkins form validation and list filling, Tornado request methods, Remix actions);
the mapper recognises Symfony, Livewire and Laravel controllers, Remix exports and Tornado handlers;
`obligations/enforcement.py` turns Laravel route middleware, Symfony attributes and security.yaml rules and
Tornado's authenticated decorator into router and decorator witnesses. The path walk looks each node's callers
up once per change and does not walk a tree with no recognised entry: one seatunnel change fell from 670 s to
35 s. One name Codex could not source (Tornado get_current_user) is left out and said so in the facts header.

Smoke on the 23 fix changes: 15 pushes caught (65 percent, Wilson 45 to 81), 55 of 93 locations, precision
15 of 15 flagged, no instrument failure, no change over 120 s. Delivery A caught 7.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-14 17:31:07 +02:00
co-authored by Claude Fable 5.1
parent 7f1b820d5a
commit 17e07b36c4
18 changed files with 1179 additions and 66 deletions
@@ -66,6 +66,7 @@ def check_obligations(
min_siblings: int,
extra_witnesses: Sequence[Discharge] = (),
assume_entries_protected: bool = False,
handlers_of: Mapping[str, Mapping[str, str]] | None = None,
) -> ObligationResult:
operations: list[Operation] = []
witnesses: list[Discharge] = list(extra_witnesses)
@@ -81,7 +82,7 @@ def check_obligations(
continue
scoped = facts.for_language(ir.language)
flow = flow_facts.for_language(ir.language)
operations.extend(find_operations(ir, scoped, text=text))
operations.extend(find_operations(ir, scoped, text=text, handlers_of=(handlers_of or {}).get(path)))
witnesses.extend(find_discharges(ir, scoped, flow, text=text, entries=entries))
sets = sibling_sets(entries, operations, witnesses)
anomalies, under = consistency_anomalies(sets, min_siblings=min_siblings)
@@ -122,11 +123,15 @@ def check_obligations(
# (a) the operation's own obligation: discharged when any accepted discharger dominates. A declared public route
# waives the path guard here too (Req 4.4), so the operation falls through to the next kind it accepts.
required = [kind for kind in operation.requires if not (kind == "path_guard" and declared_access == "public")]
if required and not (set(required) & dominated_kinds):
missing = next(
(kind for kind in required if (handler, kind) in anomaly_index),
required[0],
)
handler_fact = next((f for f in facts.operations if f.id == operation.fact_id and f.handlers), None)
missing_kinds = (
[kind for kind in required if kind not in dominated_kinds]
if handler_fact is not None and handlers_of is not None
else [next((kind for kind in required if (handler, kind) in anomaly_index), required[0])]
if required and not (set(required) & dominated_kinds)
else []
)
for missing in missing_kinds:
finding = _finding(
operation,
missing,
@@ -139,6 +144,7 @@ def check_obligations(
paths,
facts,
entry if assume_entries_protected else None,
handler_obligation=handler_fact is not None and handlers_of is not None,
)
if finding is not None:
findings.append(finding)
@@ -225,6 +231,8 @@ def _finding(
paths: Sequence[object],
facts: ObligationFacts,
assumed_entry: object | None = None,
*,
handler_obligation: bool = False,
) -> ObligationFinding | None:
wrong = next((w for w in same if w.kind == missing and not valid_witness(w)), None)
declared = policy.resources.get(operation.resource or "") if policy is not None else None
@@ -247,14 +255,25 @@ def _finding(
label = "consistency_violation"
evidence = anomaly.siblings + anomaly.evidence[len(anomaly.siblings) :]
elif (
paths and assumed_entry is not None and getattr(assumed_entry, "access_level", "public") != "public" and declared_access != "public"
paths
and assumed_entry is not None
and (
(getattr(assumed_entry, "access_level", "public") != "public" and declared_access != "public")
or (handler_obligation and missing != "path_guard")
)
):
label = "unguarded_path"
evidence = (
f"entry {getattr(assumed_entry, 'kind', '')}: {getattr(assumed_entry, 'path', '')}::"
f"{getattr(assumed_entry, 'function_name', '')}",
*(str(item) for item in getattr(assumed_entry, "access_evidence", ())),
"no declared policy clause and no sibling norm; the prover assumes a recognised entry is protected",
(
"no declared policy clause and no sibling norm; the handler fact requires "
+ missing
+ "; public access waives only path_guard"
if handler_obligation and (declared_access == "public" or getattr(assumed_entry, "access_level", "public") == "public")
else "no declared policy clause and no sibling norm; the prover assumes a recognised entry is protected"
),
)
elif paths:
return None # path-aware mode: without a clause or a sibling norm, nothing justifies the obligation (gating 3)
@@ -0,0 +1,104 @@
"""Framework enforcement outside a handler, resolved from cached source and route declarations."""
from __future__ import annotations
import ast
import re
from collections.abc import Mapping, Sequence
from pathlib import Path
import yaml
from ...mapping import EntryPointRecord, _php_methods, _php_routes, entry_framework
from .operations import Discharge
_MIDDLEWARE = re.compile(r"['\"](auth(?::[^'\"]+)?|can:[^'\"]+|role(?::[^'\"]+)?|permission(?::[^'\"]+)?)['\"]")
def witnesses(*, root: Path, texts: Mapping[str, str], entries: Sequence[EntryPointRecord], language: str) -> list[Discharge]:
"""One scoped path guard per enforcement source and covered handler; unrelated declarations discharge nothing.
Texts are supplied by the caller's cache. Root names the checkout; this function never rereads source files.
"""
del root
found: list[Discharge] = []
routes = _php_routes(texts) if language == "php" else []
security = _security_rules(texts) if language == "php" else []
methods_by_path = {path: _php_methods(text) for path, text in texts.items() if language == "php" and path.endswith(".php")}
for entry in entries:
if not entry.function_name or entry.line is None or not entry.defines_handler:
continue
framework = entry_framework(entry)
sources: list[tuple[str, str]] = []
if language == "python" and framework == "tornado":
tree = ast.parse(texts.get(entry.path, ""))
for py_method in ast.walk(tree):
if isinstance(py_method, (ast.FunctionDef, ast.AsyncFunctionDef)) and py_method.lineno == entry.line:
for decorator in py_method.decorator_list:
name = ast.unparse(decorator)
if name in {"authenticated", "tornado.web.authenticated"}:
sources.append(("decorator", f"Tornado {name} in {entry.path}"))
if language == "php" and framework in {"symfony", "laravel", "livewire"}:
methods = methods_by_path.get(entry.path, [])
method = next((m for m in methods if m.name == entry.function_name and m.line == entry.line), None)
if method is None:
continue
if framework == "symfony":
for header in (method.header, method.owner_attributes):
if re.search(r"(?:#\[|@)(?:[\w\\]+\\)?(?:IsGranted|Security)\s*\(", header):
sources.append(("decorator", f"Symfony IsGranted/Security in {entry.path}"))
# Symfony evaluates access_control in order: a public match cannot be skipped for a later role.
for source, path, roles in security:
if re.search(path, entry.name):
if roles and not any(role in {"PUBLIC_ACCESS", "IS_AUTHENTICATED_ANONYMOUSLY"} for role in roles):
sources.append(("router", f"Symfony access_control in {source}"))
break
if framework in {"laravel", "livewire"}:
for route in routes:
if (route.owner, route.method) == (method.owner, method.name):
middleware = re.search(r"->middleware\s*\((.*?)\)", route.statement, re.S)
if middleware and _MIDDLEWARE.search(middleware[1]):
sources.append(("router", f"Laravel route middleware in {route.source}"))
for constructor in methods:
if constructor.owner != method.owner or constructor.name != "__construct":
continue
for match in re.finditer(r"\$this->(?:middleware|authorizeResource)\s*\([^;]+;", constructor.body, re.S):
statement = match.group()
only = re.search(r"->only\s*\((.*?)\)", statement, re.S)
exclude = re.search(r"->except\s*\((.*?)\)", statement, re.S)
if only and not re.search(r"['\"]" + re.escape(method.name) + r"['\"]", only[1]):
continue
if exclude and re.search(r"['\"]" + re.escape(method.name) + r"['\"]", exclude[1]):
continue
if "authorizeResource" in statement or _MIDDLEWARE.search(statement):
sources.append(("router", f"Laravel constructor enforcement in {entry.path}"))
for scope, detail in dict.fromkeys(sources):
found.append(
Discharge(
entry.path, None, entry.function_name, "path_guard", f"{framework}-enforcement", "authenticated_context", scope, detail
)
)
return list(dict.fromkeys(found))
def _security_rules(texts: Mapping[str, str]) -> list[tuple[str, str, tuple[str, ...]]]:
result: list[tuple[str, str, tuple[str, ...]]] = []
for source, text in texts.items():
if not source.endswith(("config/packages/security.yaml", "config/packages/security.yml")):
continue
payload = yaml.safe_load(text)
if not isinstance(payload, dict) or not isinstance(payload.get("security"), dict):
continue
rules = payload["security"].get("access_control", [])
if not isinstance(rules, list):
continue
for rule in rules:
if not isinstance(rule, dict) or not isinstance(rule.get("path"), str):
continue
roles = rule.get("roles", ())
if isinstance(roles, str):
roles = (roles,)
if not isinstance(roles, (list, tuple)):
continue
result.append((source, rule["path"], tuple(str(role) for role in roles)))
return result
@@ -7,6 +7,7 @@ data bug and fails loud. Nothing detects an obligation that is not named here.
from __future__ import annotations
import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
@@ -20,7 +21,9 @@ PROVENANCE_KINDS = ("authenticated_context", "request_input", "constant", "unkno
SENSITIVITIES = ("low", "medium", "high")
DEFAULT_OBLIGATION_FACTS_DIR = Path(__file__).resolve().parents[2] / "ruleset" / "obligations"
_OPERATION_FIELDS = frozenset({"id", "kind", "calls", "resource_arg", "requires", "sensitivity", "query_document_arg"})
_OPERATION_FIELDS = frozenset(
{"id", "kind", "calls", "resource_arg", "requires", "sensitivity", "query_document_arg", "handlers", "framework"}
)
_DISCHARGER_FIELDS = frozenset(
{"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"}
)
@@ -45,6 +48,8 @@ class OperationFact:
# calls were a selector or a predicate. The position is 1-based over the real arguments; the arbiter that
# can see argument shapes decides it, and a language whose calls are unambiguous never sets this.
query_document_arg: int | None = None
handlers: tuple[str, ...] = ()
framework: str | None = None
@dataclass(frozen=True)
@@ -109,6 +114,17 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
document_arg = row.get("query_document_arg")
if document_arg is not None and (not isinstance(document_arg, int) or document_arg < 1):
raise ObligationFactsError(f"{path}: operation {row.get('id')!r} query_document_arg must be a positive integer")
handlers = _strings(row.get("handlers"), "handlers", path)
framework = row.get("framework")
if framework is not None and (not isinstance(framework, str) or not framework):
raise ObligationFactsError("invalid operation framework")
if handlers and framework is None:
raise ObligationFactsError("handler operation needs a framework")
for pattern in handlers:
try:
re.compile(pattern)
except re.error as exc:
raise ObligationFactsError(type(exc).__name__) from exc
operations.append(
OperationFact(
id=str(row["id"]),
@@ -119,6 +135,8 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
requires=requires,
sensitivity=sensitivity,
query_document_arg=document_arg,
handlers=handlers,
framework=framework,
)
)
for row in _rows(payload.get("discharger"), path):
@@ -10,7 +10,7 @@ the authenticated context (a constraint bound from the request or a constant dis
from __future__ import annotations
import re
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from ..facts import SemanticFacts
@@ -44,7 +44,9 @@ class Discharge:
detail: str = "" # identifier only: the decorator or call name that witnessed the discharge
def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[Operation, ...]:
def find_operations(
ir: FileIR, facts: ObligationFacts, *, text: str, handlers_of: Mapping[str, str] | None = None
) -> tuple[Operation, ...]:
"""Every obligated operation in the file's named functions, in source order."""
if not ir.parse_ok:
return ()
@@ -54,6 +56,14 @@ def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[O
for function in ir.functions:
if function.name == "<module>":
continue
for fact in facts.operations:
if (
fact.handlers
and not fact.calls
and (handlers_of or {}).get(function.name) == fact.framework
and any(re.search(pattern, function.name) for pattern in fact.handlers)
):
found.append(Operation(ir.path, function.start_line, function.name, fact.kind, fact.id, None, fact.requires))
for site in _operations(function, facts, lines, kind=None):
key = (function.name, site.call.line, site.fact.id)
if key in seen:
@@ -30,6 +30,19 @@ def compose_paths(
raise ValueError("invalid path bounds")
found: dict[tuple[tuple[str, int | None], ...], PathRecord] = {}
unreached: list[str] = []
if not entries:
# Nothing can complete a chain; one ordinary change of a 3000-file tree spent eleven minutes walking
# callers towards entries that did not exist.
return [], [f"{path}::{function}" for path, function in dict.fromkeys(targets)]
# Each node's callers are looked up once per change: the walk revisits nodes across chains and targets.
known: dict[str, list[tuple[str, int]]] = {}
def callers_of_node(node: str) -> list[tuple[str, int]]:
if node not in known:
node_path, _, node_function = node.rpartition("::")
known[node] = list(dict.fromkeys(callers(node_path, node_function)))[:fan_out]
return known[node]
for path, function in dict.fromkeys(targets):
target = f"{path}::{function}"
reached = False
@@ -43,9 +56,8 @@ def compose_paths(
continue
if len(hops) - 1 >= max_depth:
continue
node_path, _, node_function = node.rpartition("::")
seen = {hop[0] for hop in hops}
for caller, line in list(dict.fromkeys(callers(node_path, node_function)))[:fan_out]:
for caller, line in callers_of_node(node):
if caller not in seen:
pending.append(((caller, line), *hops))
if not reached: