fix(pre-push-safety-net): take the operation symbol from the call (M1b 12.1)

Admission extracted everything before the first parenthesis, so the operation for
`const preTax = eval(req.body.preTax)` was `const preTax = eval`. No real
declaration can enumerate every assignment a codebase might write, so admission
would have rejected genuine findings on operation_semantics_mismatch. The M1b
demonstration only passed because its declaration listed the assignment spellings.

The symbol is now the trailing qualified callee. Receiver spelling stays part of
it, so `$wpdb->prepare` and `prepare` remain different operations and a declaration
cannot widen from one to the other. A spelling that is not a qualified name, such
as a dynamic `handlers[name](x)`, yields the statement text, matches no declaration
and stays ineligible exactly as before.

Controls cover the assigned call, the receiver distinction, the dynamic spelling,
and that a declaration for a different operation still does not cover it. The
recorded-veto report reads the same helper instead of repeating the extraction.

1,403 tests passed, nine skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-17 09:56:59 +02:00
co-authored by Claude Opus 5
parent 6653b6aca8
commit 4ee31142ab
4 changed files with 62 additions and 3 deletions
+2 -1
View File
@@ -397,12 +397,13 @@ Preserve all completed task evidence below.
- [ ] 12. Security capability work found during M1a and M1b - [ ] 12. Security capability work found during M1a and M1b
- Group 11 established that M2 needs graph reuse rather than more runtime tuning, and that decision is recorded there. This group returns to what the tool can actually establish about a codebase, which is where the remaining release risk sits: two of the three shipped families cannot complete their change context at all, and admission rejects genuine findings on a mis-extracted operation symbol. - Group 11 established that M2 needs graph reuse rather than more runtime tuning, and that decision is recorded there. This group returns to what the tool can actually establish about a codebase, which is where the remaining release risk sits: two of the three shipped families cannot complete their change context at all, and admission rejects genuine findings on a mis-extracted operation symbol.
- [ ] 12.1 Extract the operation symbol from the operation, not from the statement - [x] 12.1 Extract the operation symbol from the operation, not from the statement
- **Found 2026-09-16 in the M1b explanation run.** Admission takes the text before the first parenthesis as the operation symbol, so `const preTax = eval(req.body.preTax)` yields `const preTax = eval`. The demonstration declaration had to enumerate each assignment spelling for its coverage check to pass. - **Found 2026-09-16 in the M1b explanation run.** Admission takes the text before the first parenthesis as the operation symbol, so `const preTax = eval(req.body.preTax)` yields `const preTax = eval`. The demonstration declaration had to enumerate each assignment spelling for its coverage check to pass.
- A qualified declaration cannot enumerate every assignment a codebase might write, so admission would reject genuine findings on `operation_semantics_mismatch`. Take the symbol from the called operation the engine reported, preserving receiver spelling, and leave an unresolved or dynamic spelling ineligible as it is today. - A qualified declaration cannot enumerate every assignment a codebase might write, so admission would reject genuine findings on `operation_semantics_mismatch`. Take the symbol from the called operation the engine reported, preserving receiver spelling, and leave an unresolved or dynamic spelling ineligible as it is today.
- Done when the symbol for an assigned call is the call's own name, a receiver-qualified call keeps its receiver, a dynamic spelling stays ineligible, and a declaration naming the plain symbol covers the assigned form. - Done when the symbol for an assigned call is the call's own name, a receiver-qualified call keeps its receiver, a dynamic spelling stays ineligible, and a declaration naming the plain symbol covers the assigned form.
- _Boundary: Delta and Actionability Policy_ - _Boundary: Delta and Actionability Policy_
- _Requirements: 3.1, 3.2, 3.4_ - _Requirements: 3.1, 3.2, 3.4_
- _Verified 2026-09-17:_ 1,403 tests passed, nine skipped; Ruff/format/mypy passed. The symbol is now the trailing qualified callee rather than everything before the first parenthesis, so `const preTax = eval(...)` yields `eval` and a declaration naming the call covers the assigned form. Receiver spelling is preserved, so `$wpdb->prepare` and `prepare` remain different operations. A spelling that is not a qualified name, such as a dynamic `handlers[name](x)`, yields the statement text, matches no declaration and stays ineligible exactly as before. The recorded-veto report reads the same helper rather than repeating the extraction.
- [ ] 12.2 Supply per-family context producers for dominance and configuration - [ ] 12.2 Supply per-family context producers for dominance and configuration
- The M1b exit run left 231 of 436 head questions reporting `change_context_incomplete` from `context_projection_unavailable` and `context_scope_empty`, because those families' queries export no context. They did not block the Node injection case, so M1b correctly left them alone, but their families stay experimental until they can answer. - The M1b exit run left 231 of 436 head questions reporting `change_context_incomplete` from `context_projection_unavailable` and `context_scope_empty`, because those families' queries export no context. They did not block the Node injection case, so M1b correctly left them alone, but their families stay experimental until they can answer.
- Give dominance and configuration their own context producers describing the actual scope of the query they accompany. Do not copy taint context into them: dominance evaluates sibling operations across a file and configuration has different scope and obligations. - Give dominance and configuration their own context producers describing the actual scope of the query they accompany. Do not copy taint context into them: dominance evaluates sibling operations across a file and configuration has different scope and obligations.
+26 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import time import time
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import asdict, dataclass, replace from dataclasses import asdict, dataclass, replace
@@ -363,6 +364,30 @@ def _blocking_degradations(scan: ModelScanResult) -> tuple[str, ...]:
return tuple(str(d.get("reason")) for d in scan.degradations if str(d.get("reason")) not in DECLARED_EXCLUSION_GAPS) return tuple(str(d.get("reason")) for d in scan.degradations if str(d.get("reason")) not in DECLARED_EXCLUSION_GAPS)
# A qualified callee: an identifier, optionally reached through `.`, `->` or `::`. Receiver
# spelling is part of the symbol, so `$wpdb->prepare` and `prepare` stay different operations.
_CALLEE = re.compile(r"[A-Za-z_$\\][\w$\\]*(?:\s*(?:\.|->|::)\s*[A-Za-z_$\\][\w$\\]*)*\s*$")
def operation_symbol(operation: str) -> str:
"""The operation a declaration can name, taken from the call rather than the statement.
Everything before the first parenthesis is the statement, not the operation: for
`const preTax = eval(req.body.preTax)` that is `const preTax = eval`, and a declaration
naming `eval` would not match it. No real declaration can enumerate every assignment a
codebase might write, so admission rejected genuine findings on `operation_semantics_mismatch`.
Only the trailing qualified callee is the symbol. A spelling that is not a qualified name,
such as a dynamic `handlers[name](x)`, yields the statement text unchanged, which matches no
declaration and so stays ineligible exactly as it did before.
"""
prefix = operation.partition("(")[0].strip()
found = _CALLEE.search(prefix)
if found is None:
return prefix
return re.sub(r"\s*(\.|->|::)\s*", r"\1", found.group(0).strip())
def _owning_question(gap: str) -> str | None: def _owning_question(gap: str) -> str | None:
"""The question id a gap is attributed to, or None when nothing attributed it.""" """The question id a gap is attributed to, or None when nothing attributed it."""
tail = gap.rsplit(":", 1)[-1] tail = gap.rsplit(":", 1)[-1]
@@ -902,7 +927,7 @@ def admit_candidates(candidates: Sequence[AdmissionCandidate], *, capabilities:
if cap is not None: if cap is not None:
# These are existing graph operation spellings, not source scanning. # These are existing graph operation spellings, not source scanning.
# A declaration may narrow an entailed claim; it cannot create one. # A declaration may narrow an entailed claim; it cannot create one.
symbol = op.operation.partition("(")[0].strip() symbol = operation_symbol(op.operation)
if symbol not in cap.operation_symbols: if symbol not in cap.operation_symbols:
reasons.append("operation_semantics_mismatch") reasons.append("operation_semantics_mismatch")
consequence = _grounded_template(cap.consequence_template, op, key.context) consequence = _grounded_template(cap.consequence_template, op, key.context)
+2 -1
View File
@@ -37,6 +37,7 @@ from openultrasast.push.policy import (
_grounded_template, _grounded_template,
admit_candidates, admit_candidates,
operation_provenance, operation_provenance,
operation_symbol,
) )
FLAG = "--experimental-record-vetoes" FLAG = "--experimental-record-vetoes"
@@ -369,7 +370,7 @@ def _evaluation(delta: CandidateDelta, declarations: Sequence[CapabilityAdmissio
return None return None
key = CapabilityKey(op.question.language, "unspecified", "unspecified", delta.family, op.mechanism, "unreviewed", semantics) key = CapabilityKey(op.question.language, "unspecified", "unspecified", delta.family, op.mechanism, "unreviewed", semantics)
matching = [declaration for declaration in declarations if declaration.key == key] matching = [declaration for declaration in declarations if declaration.key == key]
symbol = op.operation.partition("(")[0].strip() symbol = operation_symbol(op.operation)
unavailable: list[str] = [] unavailable: list[str] = []
consequence = repair = None consequence = repair = None
declaration = matching[0] if len(matching) == 1 else None declaration = matching[0] if len(matching) == 1 else None
+32
View File
@@ -231,3 +231,35 @@ def test_an_operation_the_change_introduced_is_a_supported_change_basis():
invented = replace(candidate, delta=replace(candidate.delta, reason="operation_moved_within_change")) invented = replace(candidate, delta=replace(candidate.delta, reason="operation_moved_within_change"))
disposition = admit_candidates((invented,), capabilities=(capability,)).dispositions[0] disposition = admit_candidates((invented,), capabilities=(capability,)).dispositions[0]
assert not disposition.admitted and "change_unsupported" in disposition.reasons assert not disposition.admitted and "change_unsupported" in disposition.reasons
def test_the_operation_symbol_comes_from_the_call_not_the_statement():
"""M1b 12.1: a declaration cannot enumerate every assignment a codebase might write."""
from openultrasast.push.policy import operation_symbol
assert operation_symbol("eval(req.body.preTax)") == "eval"
assert operation_symbol("const preTax = eval(req.body.preTax)") == "eval"
assert operation_symbol("return os.system(full)") == "os.system"
# Receiver spelling is part of the symbol: these are different operations.
assert operation_symbol("$result = $wpdb->query($sql)") == "$wpdb->query"
assert operation_symbol("$wpdb->prepare($sql)") != operation_symbol("prepare($sql)")
# A spelling that is not a qualified name matches no declaration and stays ineligible.
assert operation_symbol("handlers[name](x)") == "handlers[name]"
def test_a_declaration_naming_the_call_now_covers_an_assigned_call():
candidate, capability, _ = control()
assigned = replace(
candidate,
delta=replace(
candidate.delta,
head_operation=replace(candidate.delta.head_operation, operation="const out = exec(command)"),
site="handler.src:4:handler",
),
)
covered = replace(capability, operation_symbols=("exec",))
disposition = admit_candidates((assigned,), capabilities=(covered,)).dispositions[0]
assert "operation_semantics_mismatch" not in disposition.reasons
# A declaration for a different operation still does not cover it.
other = replace(capability, operation_symbols=("query",))
assert "operation_semantics_mismatch" in admit_candidates((assigned,), capabilities=(other,)).dispositions[0].reasons