mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-19 13:10:03 +00:00
fix(learn): withhold label-tracking signals from the v1 classifier; leak audit
Maintainer decision "Neutralise both for v1". 1. Phantom candidates. A pair side's candidates are now only the functions that side declares (plane/harvest.py `declares`, the excerpt builder's function_bounds); a labelled function absent from a side is listed under `absent_on_side` and never asked, and a side declaring none of its verify family's functions is no verify unit. The recorded harvest units held 58 phantom verify candidates (53 fixed, 5 vulnerable sides). build_features.py skips label rows whose side does not declare the function and drops their earlier rows (MemoryStore.drop_rows); rebuilt the pair records from the recorded signals, no model call: 126 label rows absent_on_side (117 fixed, 9 vulnerable) -- access_control 13, config_secrets 4, deserialization 20, injection 28, memory 4, output_encoding 16, path 15, untrusted_destination 26 -- so 252 feature rows dropped (1712 -> 1460); no other row changed. 2. Engine withheld. Input profiles in learn/schema.py (`v1` withholds the engine instrument, `full` withholds nothing); ProgramSpec.inputs defaults to v1 and compile.toml records it. Neither the engine's state nor its values reach the candidate, examples, demonstrations or the instruction proposer's error summary, and the Gower retrieval distance leaves the engine block out. Stored records are unchanged. design.md Risks records the decision and that the engine returns only through a pre-registered A/B on assumed-benign and benign-control negatives. 3. `ousast learn audit-leaks` (learn/leaks.py): per feature and per instrument state, separation = (pairs the signal alone puts on the right side - on the wrong side) / pairs, over all pairs and per family; flagged at >= 0.20 over >= 10 pairs. Counts under benchmarks/measurements/2026-10-01-decision-engine-leak-audit/ (before and after the rebuild). After: facts.function_lines (0.80 untrusted_destination, 0.75 config_secrets, 0.26 injection; higher on fixed), verify.turns_mean 0.67 config_secrets, eng.* 0.35-0.40 output_encoding, verify.flag_a/votes/ site_offset and agree.final 0.20-0.30 on 10-12 pair families. Before the rebuild the phantom fixed sides drove state.source/state.roles/ms.flagged to 1.0 and state.engine to 0.95 on deserialization. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4c96a59f8f
commit
9e3c83cfdc
@@ -14,8 +14,11 @@ root: the excerpts and candidates are inputs, never committed):
|
||||
Units carry no label. A pair side is a files-only Workspace holding the excerpt (and the documents that register
|
||||
it) under ``repo/`` and the candidates under ``inputs/`` -- outside the hunt's root, so the tools cannot read them;
|
||||
its name is opaque (``h-<sha>``): the pair's name says its CWE and its side. ``agree`` gets no declared sites, so
|
||||
no ``site_match`` is computed. ``units.json`` maps each unit name to its source, reference, side, repository, pin
|
||||
and candidates for the host-side feature build; it names functions and stays local.
|
||||
no ``site_match`` is computed. A side's candidates are only the functions that side declares (the excerpt builder's
|
||||
matcher, :func:`declares`): a labelled function absent from a side -- a fixed side whose fix removed or renamed it --
|
||||
is never asked there and is listed under ``absent_on_side`` instead. ``units.json`` maps each unit name to its
|
||||
source, reference, side, repository, pin and candidates for the host-side feature build; it names functions and stays
|
||||
local.
|
||||
|
||||
Every Run entry has a budget; the sum over each Run is held under ``ceiling`` (USD) by construction: verify gets
|
||||
:data:`USD_PER_HUNT` per hunt of an excerpt or a repository (scaled down when the hunts would exceed the ceiling),
|
||||
@@ -44,7 +47,7 @@ from .tasks.repo_facts import DECLARATION, GLOBAL, _declared_name
|
||||
from .tasks.roles import CHUNK_LINES
|
||||
from .tasks.verify import OPERATIONS, units_of
|
||||
|
||||
__all__ = ["Unit", "declaration_line", "pair_units", "population_units", "render_harvest", "write_harvest"]
|
||||
__all__ = ["Unit", "declaration_line", "declares", "pair_units", "population_units", "render_harvest", "write_harvest"]
|
||||
|
||||
Candidate = tuple[str, str, int]
|
||||
REPO = "repo"
|
||||
@@ -79,14 +82,18 @@ class Unit:
|
||||
files: tuple[tuple[str, str], ...] = field(default=())
|
||||
verify: tuple[Candidate, ...] = field(default=()) # the candidates of ``family`` (all of them when empty)
|
||||
sizes: tuple[tuple[str, int, int], ...] = field(default=()) # (path, characters, lines) of a pin's candidate files
|
||||
absent_on_side: tuple[str, ...] = field(default=()) # labelled ``path::function`` names this side does not declare
|
||||
|
||||
@property
|
||||
def paths(self) -> list[str]:
|
||||
return sorted({c[0] for c in self.candidates})
|
||||
|
||||
def index_row(self) -> dict[str, Any]:
|
||||
return {"source": self.source, "ref": self.ref, "side": self.side, "repo": self.repo, "pin": self.pin,
|
||||
"family": self.family, "candidates": [list(c) for c in self.candidates]} # fmt: skip
|
||||
row: dict[str, Any] = {"source": self.source, "ref": self.ref, "side": self.side, "repo": self.repo, "pin": self.pin,
|
||||
"family": self.family, "candidates": [list(c) for c in self.candidates]} # fmt: skip
|
||||
if self.absent_on_side:
|
||||
row["absent_on_side"] = list(self.absent_on_side)
|
||||
return row
|
||||
|
||||
|
||||
def _opaque(*parts: str) -> str:
|
||||
@@ -106,6 +113,14 @@ def declaration_line(lines: Sequence[str] | None, path: str, function: str) -> i
|
||||
return next((i + 1 for i, line in enumerate(lines) if _declared_name(pattern, line) == function), 1)
|
||||
|
||||
|
||||
def declares(lines: Sequence[str] | None, path: str, function: str) -> bool:
|
||||
"""Whether a side's file declares ``function``, by the excerpt builder's matcher
|
||||
(:func:`..learn.excerpt.function_bounds`): a candidate is asked only where an excerpt of it exists."""
|
||||
from ..learn.excerpt import function_bounds
|
||||
|
||||
return bool(lines) and function_bounds(lines or (), detect_language(Path(path)) or "", function) is not None
|
||||
|
||||
|
||||
def _read_labels(labels: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in labels.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
@@ -135,13 +150,18 @@ def pair_units(labels: Path, catalog: Path) -> list[Unit]:
|
||||
lines = {path: text.splitlines() for path, text in texts.items()}
|
||||
|
||||
def located(names: Iterable[str], lines: Mapping[str, list[str]] = lines) -> tuple[Candidate, ...]:
|
||||
return tuple(sorted({(p, fn, declaration_line(lines.get(p), p, fn)) for c in names for p, _, fn in [c.partition("::")]}))
|
||||
"""The candidates this side declares: a name taken from the other side is never asked here."""
|
||||
split = [(p, fn) for c in names for p, _, fn in [c.partition("::")]]
|
||||
return tuple(sorted({(p, fn, declaration_line(lines.get(p), p, fn)) for p, fn in split if declares(lines.get(p), p, fn)}))
|
||||
|
||||
labelled = set().union(*families.values())
|
||||
absent = tuple(sorted(c for c in labelled if not declares(lines.get(c.partition("::")[0]), *c.partition("::")[::2])))
|
||||
asked = located(families[verify[0]]) if verify else () # none declared here: no verify unit on this side
|
||||
role = "vulnerable" if side == "vuln" else "fixed"
|
||||
out.append(Unit(
|
||||
_opaque("pairs", name, side), "pairs", name, role, case.repo or f"pairs/{case.slice}/{name}",
|
||||
blob_sha1(texts[case.relpath]), verify[0] if verify else "", located(set().union(*families.values())),
|
||||
tuple(sorted(texts.items())), located(families[verify[0]]) if verify else (),
|
||||
blob_sha1(texts[case.relpath]), verify[0] if asked else "", located(labelled),
|
||||
tuple(sorted(texts.items())), asked, absent_on_side=absent,
|
||||
)) # fmt: skip
|
||||
return out
|
||||
|
||||
|
||||
@@ -325,6 +325,24 @@ class MemoryStore(ABC):
|
||||
ordered = [merged[i] for i in sorted(merged)]
|
||||
self._put(key, "".join(_line(r) + "\n" for r in ordered).encode("utf-8"), _labels(ordered))
|
||||
|
||||
def drop_rows(self, repo: str, pin: str, ids: Iterable[str]) -> int:
|
||||
"""Remove the rows ``ids`` from one repository + pin object (a rebuild that no longer produces them); returns
|
||||
how many were removed. The object is deleted when it ends up empty."""
|
||||
key = f"repos/{repo_dir(repo)}/{pin}.jsonl"
|
||||
found = self._get(key)
|
||||
unwanted = set(ids)
|
||||
if found is None or not unwanted:
|
||||
return 0
|
||||
rows = _parse_lines(found[0], key)
|
||||
kept = [r for r in rows if str(r["id"]) not in unwanted]
|
||||
if len(kept) == len(rows):
|
||||
return 0
|
||||
if kept:
|
||||
self._put(key, "".join(_line(r) + "\n" for r in kept).encode("utf-8"), _labels(kept))
|
||||
else:
|
||||
self._delete(key)
|
||||
return len(rows) - len(kept)
|
||||
|
||||
def ingest_rows(self, run: str, task: str, rows: Sequence[Mapping[str, Any]], facts: bytes | None = None) -> IngestResult:
|
||||
"""One (run, task)'s rows and facts; a repeat of the same rows is skipped by the index."""
|
||||
checked = [validate_row(dict(r), f"{run}/{task} row {i + 1}") for i, r in enumerate(rows)]
|
||||
|
||||
Reference in New Issue
Block a user