mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +00:00
chore(p0): remove the improve lever and the plane improvement loop
Off every active line (PATHFINDER-2026-10-13/03-unified-proposal.md, cuts). Deleted: src/openultrasast/improve/ (1,175 lines), the ousast improve subcommand, plane/tasks/loop.py and loop.yaml with the --loop generation, the memory-snapshot step in memory.seed, pairs.build_pair_signals, and the four improve tests plus the loop cases in other tests. Docs no longer describe the lever. Net: 3,073 lines deleted, 83 added. Targeted tests: 154 passed; 3,436 tests collect. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d2010d0693
commit
94e96c6885
@@ -20,13 +20,6 @@ under ``--plane`` (default ``plane/``), everything a Run over the set needs besi
|
||||
with budgets, annotated with the population and split (``openultrasast.io/population``/``split``: the files'
|
||||
stems) the memory rows carry.
|
||||
|
||||
``--loop`` (harnessx-removal design section 6) appends the improvement loop: per case ``alerts`` (quick-mode rules
|
||||
on the vulnerable and the fixed pin, which gets its Workspace ``<case>-fixed``; ``case.json`` then also carries the
|
||||
fix's new-side ranges) feeding ``remember``, and once ``memory-snapshot`` (the store's rows after the train-on-test
|
||||
guard, written on the host by ``memory.seed``) -> ``loop-measure`` -> ``loop-propose`` -> ``loop-improve``, the last
|
||||
two bound to this repository at the commit under test. All of them model-free with budget ``{usd: 0, calls: 0}``;
|
||||
the gate's verdict is ``<run>/loop-improve/gate.json``, and adopting an accepted ledger stays a maintainer commit.
|
||||
|
||||
Each ``repo-facts`` Task carries ``openultrasast.io/memory-key`` (repository, pin, candidates digest, runner image
|
||||
digest): the key under which stored facts are reused instead of recomputed (``plane/memory.py``, ``seed``).
|
||||
|
||||
@@ -67,12 +60,12 @@ import yaml
|
||||
from ..model.endpoint import price_of
|
||||
from .budget import cost_of
|
||||
from .manifests import GIT_COMMITS_ANNOTATION, RUN_API_VERSION, Task, load_manifests
|
||||
from .memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SNAPSHOT_ANNOTATION, SPLIT_ANNOTATION, memory_key
|
||||
from .memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SPLIT_ANNOTATION, memory_key
|
||||
from .reconciler import _ax_name, _doc
|
||||
from .tasks.repo_facts import _digest
|
||||
from .tasks.verify import MAX_STEPS, units_of
|
||||
|
||||
__all__ = ["CaseInputs", "Loop", "fix_ranges", "increment", "read_cases", "render", "repin_templates"]
|
||||
__all__ = ["CaseInputs", "fix_ranges", "increment", "read_cases", "render", "repin_templates"]
|
||||
|
||||
REFERENCE_USD_PER_CANDIDATE = 0.022 # two passes: verifier-batched-check-2026-09-29.json cost.usd_per_candidate_two_passes
|
||||
REFERENCE_MODEL = "deepseek-flash" # verify_batched.py's default (DEFAULT_DETECTOR_MODEL) in the batched check
|
||||
@@ -86,12 +79,7 @@ CALLS_PER_HUNT = MAX_STEPS + 2 # every tool step, the answer, one empty-content
|
||||
ATESPACE = "default" # the default when no profile is given; ``increment`` takes ``profile.atespace``
|
||||
CASE_PATH = "/workspace/case"
|
||||
INPUTS_PATH = "/workspace/inputs"
|
||||
FIXED_PATH = "/workspace/fixed"
|
||||
PROJECT_PATH = "/workspace/project"
|
||||
TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "loop", "features", "roles")
|
||||
FIXED_RANGES_BASIS = "git diff -U0 <vulnerable> <fixed>, new side: the fix's lines at the fixed pin"
|
||||
LOOP_MANIFEST = "benchmarks/manifests/java-spring-boot-vulnerable.toml" # the equality baseline's `improve` manifest
|
||||
LOOP_CATALOG = "benchmarks/pairs/catalog.toml"
|
||||
TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "features", "roles")
|
||||
|
||||
Candidate = tuple[str, str, int]
|
||||
|
||||
@@ -105,20 +93,6 @@ class CaseInputs:
|
||||
recorded_usd: float
|
||||
triage_usd: float = 0.0
|
||||
ranges: Mapping[str, Sequence[tuple[int, int]]] | None = None
|
||||
fixed_ranges: Mapping[str, Sequence[tuple[int, int]]] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Loop:
|
||||
"""The improvement loop appended to a Run (``--loop``, design section 6): ``repo`` at ``commit`` is this
|
||||
repository at the commit under test, where ``manifest`` and ``catalog`` (paths inside it) gate the proposals;
|
||||
rows of ``populations`` never propose (the train-on-test guard)."""
|
||||
|
||||
repo: str
|
||||
commit: str
|
||||
manifest: str = LOOP_MANIFEST
|
||||
catalog: str = LOOP_CATALOG
|
||||
populations: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def fix_ranges(repo: Path, vulnerable: str, fixed: str, side: str = "old") -> dict[str, list[tuple[int, int]]]:
|
||||
@@ -165,11 +139,10 @@ def _scan_rows(triage_dir: Path, case_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def read_cases(
|
||||
validation_set: Path, candidates_dir: Path, triage_dir: Path, population: Path, repos: Path | None = None, *, fixed_side: bool = False
|
||||
validation_set: Path, candidates_dir: Path, triage_dir: Path, population: Path, repos: Path | None = None
|
||||
) -> list[CaseInputs]:
|
||||
"""The set's cases in id order, each with its candidates, the triage-kept subset, the recorded cost and its
|
||||
triage share, and -- with ``repos`` (one clone per case id) -- the fix's old-side ranges (and, with
|
||||
``fixed_side``, its new-side ranges, which the loop's `alerts` task marks fixed-pin alerts by)."""
|
||||
triage share, and -- with ``repos`` (one clone per case id) -- the fix's old-side ranges."""
|
||||
subset: dict[str, list[list[str]]] = json.loads(validation_set.read_text(encoding="utf-8"))
|
||||
records = {c["id"]: c for c in tomllib.loads(population.read_text(encoding="utf-8"))["case"]}
|
||||
cases: list[CaseInputs] = []
|
||||
@@ -199,11 +172,6 @@ def read_cases(
|
||||
recorded_usd=round(sum(float(r.get("usd") or 0.0) for r in rows), 6),
|
||||
triage_usd=_triage_usd(rows),
|
||||
ranges=None if repos is None else fix_ranges(repos / case_id, records[case_id]["vulnerable"], records[case_id]["fixed"]),
|
||||
fixed_ranges=(
|
||||
fix_ranges(repos / case_id, records[case_id]["vulnerable"], records[case_id]["fixed"], side="new")
|
||||
if repos is not None and fixed_side
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return cases
|
||||
@@ -226,9 +194,6 @@ def _case_record(item: CaseInputs) -> dict[str, Any]:
|
||||
if item.ranges is not None:
|
||||
record["ranges"] = {path: [list(span) for span in spans] for path, spans in item.ranges.items()}
|
||||
record["ranges_basis"] = RANGES_BASIS
|
||||
if item.fixed_ranges is not None:
|
||||
record["fixed_ranges"] = {path: [list(span) for span in spans] for path, spans in item.fixed_ranges.items()}
|
||||
record["fixed_ranges_basis"] = FIXED_RANGES_BASIS
|
||||
record["sites_in_set"] = [s for s in sites if s in in_set]
|
||||
record["cost"] = {
|
||||
"candidates_before_triage": len(item.candidates), "recorded_usd": item.recorded_usd,
|
||||
@@ -304,95 +269,38 @@ def _free() -> dict[str, int]:
|
||||
return {"usd": 0, "calls": 0} # model-free: budget.py refuses any call, so a stray one fails loudly
|
||||
|
||||
|
||||
def _chain_inputs(case_id: str, alerts: bool) -> dict[str, str]:
|
||||
"""Every artifact of a case's chain -- facts, the three passes with their summaries, the final agree -- and, in a
|
||||
loop, its alerts with their coverage."""
|
||||
def _chain_inputs(case_id: str) -> dict[str, str]:
|
||||
"""Every artifact of a case's chain: facts, the three passes with their summaries, and the final agreement."""
|
||||
inputs = {"facts": f"{case_id}-facts/facts.json", "facts_summary": f"{case_id}-facts/summary.json"}
|
||||
for label in ("a", "b", "c"):
|
||||
inputs.update({f"pass_{label}": f"{case_id}-v{label}/units.jsonl", f"summary_{label}": f"{case_id}-v{label}/summary.json"})
|
||||
inputs["agreed"] = f"{case_id}-final/agreed.json"
|
||||
if alerts:
|
||||
inputs["alerts"] = f"{case_id}-alerts/alerts.jsonl"
|
||||
inputs["alerts_summary"] = f"{case_id}-alerts/summary.json" # the coverage: whether a zero could mean clean
|
||||
return inputs
|
||||
|
||||
|
||||
def _features(item: CaseInputs, templates: Mapping[str, Task], alerts: bool) -> tuple[dict, dict]:
|
||||
def _features(item: CaseInputs, templates: Mapping[str, Task]) -> tuple[dict, dict]:
|
||||
"""(Task, Run entry) of the case's `features` (learned-decision-engine design section 1): model-free, bound to the
|
||||
vulnerable Workspace for the functions' spans and entry-point distance; the chain's artifacts as input."""
|
||||
case_id = item.case["id"]
|
||||
env = {"OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo"}
|
||||
task = _task(templates["features"], f"features-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)])
|
||||
inputs = _chain_inputs(case_id, alerts)
|
||||
inputs = _chain_inputs(case_id)
|
||||
entry = {"name": f"{case_id}-features", "task": f"features-{case_id}", "inputs": inputs, "outputs": ["features.jsonl", "summary.json"]}
|
||||
return task, {**entry, "budget": _free()}
|
||||
|
||||
|
||||
def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str, alerts: bool) -> tuple[dict, dict]:
|
||||
def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str) -> tuple[dict, dict]:
|
||||
"""(Task, Run entry) of the case's `remember` (design section 4): bound to the vulnerable Workspace for the
|
||||
repository and pin its rows carry; every artifact of the case's chain (and, in a loop, its alerts) and the case's
|
||||
repository and pin its rows carry; every artifact of the case's chain and the case's
|
||||
feature records as input."""
|
||||
case_id = item.case["id"]
|
||||
env = {"OUSAST_POPULATION": population or "unknown", "OUSAST_SPLIT": split or "unknown"}
|
||||
task = _task(templates["remember"], f"remember-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)])
|
||||
inputs = {**_chain_inputs(case_id, alerts), "features": f"{case_id}-features/features.jsonl"}
|
||||
inputs = {**_chain_inputs(case_id), "features": f"{case_id}-features/features.jsonl"}
|
||||
entry = {"name": f"{case_id}-remember", "task": f"remember-{case_id}", "inputs": inputs, "outputs": ["memory.jsonl"], "budget": _free()}
|
||||
return task, entry
|
||||
|
||||
|
||||
def _alerts(item: CaseInputs, templates: Mapping[str, Task], atespace: str = ATESPACE) -> tuple[dict, dict, dict]:
|
||||
"""(fixed-pin Workspace, Task, Run entry) of the case's `alerts`: quick-mode rules on both pins."""
|
||||
case = item.case
|
||||
git = [{"name": "repo", "repo": case["repo"], "dir": "repo", "depth": 1}]
|
||||
fixed = _doc("Workspace", _ax_name(case["id"], "fixed"), {"git": git}, atespace)
|
||||
fixed["metadata"]["annotations"] = {GIT_COMMITS_ANNOTATION: f"repo={case['fixed']}"}
|
||||
bindings = [(_ax_name(case["id"], "vulnerable"), CASE_PATH), (fixed["metadata"]["name"], FIXED_PATH)]
|
||||
bindings.append((_ax_name(case["id"], "inputs"), INPUTS_PATH))
|
||||
env = {
|
||||
"OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo", "OUSAST_FIXED_DIR": f"{FIXED_PATH}/repo",
|
||||
"OUSAST_INPUT_CASE": f"{INPUTS_PATH}/case.json", "OUSAST_VULNERABLE_PIN": str(case["vulnerable"]),
|
||||
"OUSAST_FIXED_PIN": str(case["fixed"]),
|
||||
} # fmt: skip
|
||||
task = _task(templates["alerts"], f"alerts-{case['id']}", env, bindings)
|
||||
entry = {"name": f"{case['id']}-alerts", "task": f"alerts-{case['id']}", "outputs": ["alerts.jsonl", "summary.json"], "budget": _free()}
|
||||
entry["serialize"] = "engine"
|
||||
return fixed, task, entry
|
||||
|
||||
|
||||
def _loop(
|
||||
cases: Sequence[CaseInputs], templates: Mapping[str, Task], loop: Loop, atespace: str = ATESPACE
|
||||
) -> tuple[dict, list[dict], list[dict]]:
|
||||
"""(project Workspace, Tasks, Run entries) of the loop's singletons: snapshot -> measure -> propose -> improve."""
|
||||
git = [{"name": "repo", "repo": loop.repo, "dir": "repo", "depth": 1}]
|
||||
project = _doc("Workspace", _ax_name("openultrasast", loop.commit[:12]), {"git": git}, atespace)
|
||||
project["metadata"]["annotations"] = {GIT_COMMITS_ANNOTATION: f"repo={loop.commit}"}
|
||||
bound = [(project["metadata"]["name"], PROJECT_PATH)]
|
||||
env = {
|
||||
"OUSAST_PROJECT_DIR": f"{PROJECT_PATH}/repo", "OUSAST_LOOP_MANIFEST": loop.manifest, "OUSAST_LOOP_CATALOG": loop.catalog,
|
||||
"OUSAST_QUALIFY_POPULATIONS": json.dumps(sorted(loop.populations)),
|
||||
} # fmt: skip
|
||||
snapshot = _task(templates["loop"], "memory-snapshot", {}, [], ["snapshot"])
|
||||
guard = {"catalog": loop.catalog, "manifest": loop.manifest, "populations": sorted(loop.populations)}
|
||||
snapshot["metadata"].setdefault("annotations", {})[SNAPSHOT_ANNOTATION] = json.dumps(guard, sort_keys=True, separators=(",", ":"))
|
||||
tasks = [
|
||||
snapshot, _task(templates["loop"], "loop-measure", {}, [], ["measure"]),
|
||||
_task(templates["loop"], "loop-propose", env, bound, ["propose"]),
|
||||
_task(templates["loop"], "loop-improve", env, bound, ["improve"]),
|
||||
] # fmt: skip
|
||||
remembered = {f"remember_{c.case['id']}": f"{c.case['id']}-remember/memory.jsonl" for c in cases}
|
||||
entries = [
|
||||
{"name": "memory-snapshot", "task": "memory-snapshot", "outputs": ["rows.jsonl", "index.json"], "budget": _free()},
|
||||
{"name": "loop-measure", "task": "loop-measure", "inputs": {**remembered, "snapshot": "memory-snapshot/rows.jsonl"},
|
||||
"outputs": ["measure.json", "rows.jsonl"], "budget": _free()},
|
||||
{"name": "loop-propose", "task": "loop-propose",
|
||||
"inputs": {"measure": "loop-measure/measure.json", "rows": "loop-measure/rows.jsonl", "index": "memory-snapshot/index.json"},
|
||||
"outputs": ["proposals.jsonl", "memory_proposals.jsonl", "signals.json"], "budget": _free()},
|
||||
{"name": "loop-improve", "task": "loop-improve", "inputs": {"proposals": "loop-propose/proposals.jsonl"},
|
||||
"outputs": ["gate.json", "journal.json"], "budget": _free(), "serialize": "engine"},
|
||||
] # fmt: skip
|
||||
return project, tasks, entries
|
||||
|
||||
|
||||
def render(
|
||||
cases: Sequence[CaseInputs],
|
||||
templates: Mapping[str, Task],
|
||||
@@ -401,13 +309,11 @@ def render(
|
||||
*,
|
||||
population: str = "",
|
||||
split: str = "",
|
||||
loop: Loop | None = None,
|
||||
atespace: str = ATESPACE,
|
||||
) -> dict[str, str]:
|
||||
"""Relative path under the plane root -> file text, for every generated manifest. ``population`` and ``split``
|
||||
(the population file's and the set's stems) become the Run's annotations, which the memory rows carry. Each
|
||||
case ends in `features` -> `remember`; ``loop`` adds per case `alerts` (and the fixed-pin Workspace), and once the
|
||||
snapshot, `loop-measure`, `loop-propose` and `loop-improve` (design section 6), all model-free."""
|
||||
case ends in `features` -> `remember`."""
|
||||
header = f"# generated by `{command}`; do not edit, regenerate.\n"
|
||||
out: dict[str, str] = {}
|
||||
tasks: list[dict[str, Any]] = []
|
||||
@@ -454,22 +360,12 @@ def render(
|
||||
tasks.sort(key=lambda doc: 1 if doc["metadata"]["name"].startswith("verify-c-") else 0) # stable: the old Tasks first
|
||||
entries.extend(tiebreak) # appended: the first Run's entries stay byte-identical, so a rerun keeps their state
|
||||
for item in cases: # appended after the tie-break for the same reason
|
||||
if loop is not None:
|
||||
fixed, alerts_task, alerts_entry = _alerts(item, templates, atespace)
|
||||
out[f"workspaces/{fixed['metadata']['name']}.yaml"] = _dump(header, [fixed])
|
||||
tasks.append(alerts_task)
|
||||
entries.append(alerts_entry)
|
||||
features_task, features_entry = _features(item, templates, loop is not None)
|
||||
features_task, features_entry = _features(item, templates)
|
||||
tasks.append(features_task)
|
||||
entries.append(features_entry)
|
||||
remember_task, remember_entry = _remember(item, templates, population, split, loop is not None)
|
||||
remember_task, remember_entry = _remember(item, templates, population, split)
|
||||
tasks.append(remember_task)
|
||||
entries.append(remember_entry)
|
||||
if loop is not None:
|
||||
project, loop_tasks, loop_entries = _loop(cases, templates, loop, atespace)
|
||||
out[f"workspaces/{project['metadata']['name']}.yaml"] = _dump(header, [project])
|
||||
tasks.extend(loop_tasks)
|
||||
entries.extend(loop_entries)
|
||||
out[f"tasks/{run_name}.yaml"] = _dump(header, tasks)
|
||||
annotations = {k: v for k, v in ((POPULATION_ANNOTATION, population), (SPLIT_ANNOTATION, split)) if v}
|
||||
metadata: dict[str, Any] = {"name": run_name, **({"annotations": annotations} if annotations else {})}
|
||||
@@ -519,24 +415,21 @@ def increment(
|
||||
command: str,
|
||||
repos: Path | None = None,
|
||||
runner_image: Path | None = None,
|
||||
loop: Loop | None = None,
|
||||
atespace: str | None = None,
|
||||
) -> list[Path]:
|
||||
"""Write the generated manifests under ``plane`` from the templates in ``plane/tasks`` (re-pinned first when
|
||||
``runner_image`` is given), with the improvement loop when ``loop`` is given; returns the paths. Workspaces go
|
||||
``runner_image`` is given); returns the paths. Workspaces go
|
||||
to ``atespace`` (default: the profile's)."""
|
||||
if atespace is None:
|
||||
from .profile import load_profile
|
||||
|
||||
atespace = load_profile().atespace
|
||||
if loop is not None and repos is None:
|
||||
raise ValueError("--loop needs --repos: the alerts task marks fixed-pin alerts by the fix's new-side ranges")
|
||||
if runner_image is not None:
|
||||
repin_templates(plane, runner_image)
|
||||
loaded = load_manifests([plane / "tasks" / f"{name}.yaml" for name in TEMPLATES])
|
||||
cases = read_cases(validation_set, candidates_dir, triage_dir, population, repos, fixed_side=loop is not None)
|
||||
cases = read_cases(validation_set, candidates_dir, triage_dir, population, repos)
|
||||
files = render(
|
||||
cases, loaded.tasks, run_name, command, population=population.stem, split=validation_set.stem, loop=loop, atespace=atespace
|
||||
cases, loaded.tasks, run_name, command, population=population.stem, split=validation_set.stem, atespace=atespace
|
||||
)
|
||||
written: list[Path] = []
|
||||
for relative, text in files.items():
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows
|
||||
of the kinds in ``KINDS`` (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``features``, ...) that
|
||||
``remember`` (:mod:`.tasks.remember`) derives from a run's delivered artifacts, plus the loop's and the decision
|
||||
``remember`` (:mod:`.tasks.remember`) derives from a run's delivered artifacts, plus the decision
|
||||
engine's own. Layout, identical for both backends::
|
||||
|
||||
index.jsonl one row per ingested (run, task): sha256 of its rows, row count
|
||||
@@ -34,10 +34,6 @@ Fact reuse: a facts entry is valid for (repo, pin, candidates digest, runner ima
|
||||
writes as the Task annotation ``openultrasast.io/memory-key``. :func:`seed` runs before a Run: for every entry whose
|
||||
Task carries a key with stored facts and no state yet, it writes ``<run>/<task>/facts.json`` and a ``done``
|
||||
summary and marks the task done under the run lock (``reconciler.mark_done``), so the reconciler skips it.
|
||||
|
||||
The loop's ``memory-snapshot`` (a Task annotated ``openultrasast.io/memory-snapshot`` with the train-on-test guard's
|
||||
parameters) is seeded the same way: a sandboxed task cannot read this store, so :func:`seed` writes the store's rows
|
||||
after the guard into ``<run>/memory-snapshot/`` (:func:`.tasks.loop.write_snapshot`) and marks it done.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -62,7 +58,6 @@ log = logging.getLogger(__name__)
|
||||
MEMORY_KEY_ANNOTATION = "openultrasast.io/memory-key"
|
||||
POPULATION_ANNOTATION = "openultrasast.io/population"
|
||||
SPLIT_ANNOTATION = "openultrasast.io/split"
|
||||
SNAPSHOT_ANNOTATION = "openultrasast.io/memory-snapshot" # the loop's guard: {"catalog", "manifest", "populations"}
|
||||
# The decision engine's kinds (learned-decision-engine, Data Models): `features` from the `features` task through
|
||||
# `remember`; `label`, `decision`, `experiment`, `arm_outcome` and `experiment_result` for the later tasks; `example`
|
||||
# (a labelled case of the local memory: label + feature record + excerpt sha) from `ousast learn memory build`.
|
||||
@@ -984,8 +979,7 @@ def ingest(run_dir: Path, store: MemoryStore | None = None) -> list[IngestResult
|
||||
def seed(run_manifest: Path, store: MemoryStore | None = None, *, results_root: Path | None = None) -> list[str]:
|
||||
"""Reuse stored facts for a Run's tasks whose ``openultrasast.io/memory-key`` matches (Req 6.1): each such task
|
||||
without a directory or a state gets ``facts.json`` and a ``done`` summary and is marked done under the run
|
||||
lock. Returns the seeded task names. A changed pin, candidate set or runner image finds nothing and recomputes.
|
||||
A ``memory-snapshot`` Task gets the store's rows after the guard its annotation names, marked done likewise."""
|
||||
lock. Returns the seeded task names. A changed pin, candidate set or runner image finds nothing and recomputes."""
|
||||
from . import reconciler
|
||||
|
||||
run_spec, manifests = reconciler.load_run(Path(run_manifest))
|
||||
@@ -995,16 +989,8 @@ def seed(run_manifest: Path, store: MemoryStore | None = None, *, results_root:
|
||||
seeded: list[str] = []
|
||||
for entry in run_spec.tasks:
|
||||
annotations = manifests.tasks[entry.task].metadata.annotations
|
||||
text, snapshot = annotations.get(MEMORY_KEY_ANNOTATION), annotations.get(SNAPSHOT_ANNOTATION)
|
||||
if not (text or snapshot) or (base / entry.name).exists() or state.get(entry.name, {}).get("status", "pending") != "pending":
|
||||
continue
|
||||
if snapshot:
|
||||
from .tasks.loop import write_snapshot # lazy: the guard reads the pair catalog and the benchmark manifest
|
||||
|
||||
info = write_snapshot(base / entry.name, store, json.loads(snapshot))
|
||||
reused = {"snapshot": store.describe(), **{k: info[k] for k in ("rows", "kept", "index_digest")}}
|
||||
reconciler.mark_done(run_spec.metadata.name, entry.name, reused=reused, results_root=results_root)
|
||||
seeded.append(entry.name)
|
||||
text = annotations.get(MEMORY_KEY_ANNOTATION)
|
||||
if not text or (base / entry.name).exists() or state.get(entry.name, {}).get("status", "pending") != "pending":
|
||||
continue
|
||||
key = parse_memory_key(str(text))
|
||||
where = {"candidates_digest": key["candidates"], "image": key["image"]}
|
||||
@@ -1037,7 +1023,6 @@ __all__ = [
|
||||
"POPULATION_ANNOTATION",
|
||||
"QUERYABLE",
|
||||
"QUERY_FIELDS",
|
||||
"SNAPSHOT_ANNOTATION",
|
||||
"SPLIT_ANNOTATION",
|
||||
"FileStore",
|
||||
"IngestResult",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""`alerts` (harnessx-removal Requirement 6.3, design section 6): what the deterministic rules say about a case.
|
||||
|
||||
No model, budget ``{usd: 0, calls: 0}``, one per case in a ``--loop`` Run, serialized like ``repo-facts`` (a full-tree
|
||||
No model, budget ``{usd: 0, calls: 0}``, serialized like ``repo-facts`` (a full-tree
|
||||
scan per pin). It runs the quick-mode scan -- ``preprocess_repository``, the entry-point reachability hints,
|
||||
``rank_targets`` and ``quick_scan_findings`` over the image's bundled ruleset, enabled and shadow rules alike, exactly
|
||||
what ``ousast scan --mode quick`` runs -- on the case's vulnerable pin and on its fixed pin, and writes one row per
|
||||
@@ -12,8 +12,7 @@ what ``ousast scan --mode quick`` runs -- on the case's vulnerable pin and on it
|
||||
candidates' ``path::function``; ``<global>`` outside any function); a language those patterns do not cover keeps the
|
||||
scan's own reachability attribution. ``in_fix_range`` says whether the line lies inside the fix: the old-side ranges
|
||||
of ``case.json`` on the vulnerable pin, the new-side ``fixed_ranges`` on the fixed pin (null when ``case.json`` has
|
||||
none). A fixed-pin alert inside the fix is a false alert on code the fix wrote: rule M1 of ``improve/memory.py``
|
||||
counts it.
|
||||
none). A fixed-pin alert inside the fix is a false alert on code the fix wrote.
|
||||
|
||||
**Coverage** (an honest zero): quick mode has rules only for the languages its ruleset declares
|
||||
(:func:`quick_languages`, derived from the bundled ruleset directory, never listed by hand). ``summary.json`` records
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
"""The improvement loop as plane tasks (harnessx-removal Requirement 6.3, design section 6).
|
||||
|
||||
``command: ["loop", <step>]``; every step is model-free with budget ``{usd: 0, calls: 0}`` (``budget.py`` refuses any
|
||||
call, so a stray one fails loudly), and each writes ``summary.json`` under the task contract. Records travel as
|
||||
``{"key", "version", "row"}`` lines: the store object a row came from and its version (the bucket's object version id, a file's
|
||||
content hash), which a proposal's provenance cites (Req 6.4).
|
||||
|
||||
- ``snapshot``: never runs in the sandbox, which cannot read the host's store. ``ousast plane run`` seeds it before
|
||||
the Run (``memory.seed`` -> :func:`write_snapshot`): the store's rows after the train-on-test guard to
|
||||
``rows.jsonl``, and ``index.json`` (store, index digest, row counts, what the guard excluded). Run in a sandbox it
|
||||
fails, naming the seed that did not happen.
|
||||
- ``measure``: every case's ``remember`` rows (inputs ``remember_<case>``) plus the snapshot -> ``measure.json``,
|
||||
the increment's metrics for this run, the store and both (declared sites agreed, usd per candidate, dispute rates
|
||||
per family, alerts per rule and pin) -- and ``rows.jsonl``, the union by row id that ``propose`` reads.
|
||||
- ``propose``: ``improve/memory.py``'s rules M1/M2 over those rows after the guard (``OUSAST_QUALIFY_POPULATIONS``, the
|
||||
gated manifest's cases, the pair catalog's holdout pairs), against the benchmark target's ledger and journal in
|
||||
this repository at the commit under test (``OUSAST_PROJECT_DIR``) -> ``proposals.jsonl`` (edit + provenance),
|
||||
``memory_proposals.jsonl`` (the sidecar) and ``signals.json`` (M3, advisory).
|
||||
- ``improve``: one ``evolve.run_round`` with those proposals -- the unchanged validator and gate, the pair catalog's
|
||||
holdout clause included -- on copies of the target's ledger and journal -> ``gate.json`` (the ``RoundOutcome``
|
||||
and which edits came from memory), ``journal.json``, the sidecar, and ``rule_policy.json`` only when the round
|
||||
was accepted; ``memory.jsonl`` holds the ``proposal_outcome`` rows the host ingests after the Run. Adopting an
|
||||
accepted ledger stays a maintainer commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..memory import REMEMBER_OUTPUT, MemoryStore, Record, validate_row
|
||||
|
||||
INPUT_PREFIX = "OUSAST_INPUT_REMEMBER_"
|
||||
CALIBRATION = Path(".openultrasast") / "calibration" # cli.CALIBRATION_DIR: where `improve` keeps a target's ledger
|
||||
ADOPTION = "adoption is a maintainer commit: copy rule_policy.json to {ledger} (README, Rule-level loop)"
|
||||
|
||||
|
||||
# --- records -------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _record_line(record: Record) -> str:
|
||||
return json.dumps({"key": record.key, "version": record.version, "row": record.row}, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def read_records(path: Path) -> list[Record]:
|
||||
records: list[Record] = []
|
||||
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if raw.strip():
|
||||
item = json.loads(raw)
|
||||
records.append(Record(validate_row(item["row"], f"{path}:{number}"), str(item["key"]), item.get("version")))
|
||||
return records
|
||||
|
||||
|
||||
def write_records(path: Path, records: Iterable[Record]) -> None:
|
||||
path.write_text("".join(_record_line(r) for r in sorted(records, key=lambda r: str(r.row["id"]))), encoding="utf-8")
|
||||
|
||||
|
||||
def _guard(params: Mapping[str, Any], root: Path) -> Any:
|
||||
from ...benchmark import load_benchmark_manifest
|
||||
from ...improve.memory import Guard, benchmark_cases
|
||||
from ...pairs import load_pair_catalog, select_split
|
||||
|
||||
manifest = load_benchmark_manifest(root / str(params["manifest"]))
|
||||
holdout = select_split(load_pair_catalog(Path(str(params["catalog"]))), "holdout")
|
||||
return Guard.build([str(p) for p in params.get("populations") or []], benchmark_cases(manifest), holdout)
|
||||
|
||||
|
||||
def write_snapshot(directory: Path, store: MemoryStore, params: Mapping[str, Any], root: Path | None = None) -> dict[str, Any]:
|
||||
"""The store's rows after the guard ``params`` names (``catalog``, ``manifest``, ``populations``; paths relative to
|
||||
``root``, the working directory) into ``directory``: ``rows.jsonl``, ``index.json`` and a ``done`` summary."""
|
||||
from ...improve.memory import index_digest
|
||||
|
||||
here = Path.cwd()
|
||||
try:
|
||||
os.chdir(root or here) # the pair catalog names its files relative to the repository root
|
||||
guard = _guard(params, Path.cwd())
|
||||
finally:
|
||||
os.chdir(here)
|
||||
directory.mkdir(parents=True, exist_ok=False) # after the guard loaded: a failed load leaves no half-seeded task
|
||||
records = store.rows()
|
||||
kept, excluded = guard.apply(records)
|
||||
write_records(directory / "rows.jsonl", kept)
|
||||
info = {
|
||||
"store": store.describe(), "index_entries": len(store.index()), "index_digest": index_digest(store), "rows": len(records),
|
||||
"kept": len(kept), "excluded": excluded, "guard": dict(params),
|
||||
} # fmt: skip
|
||||
(directory / "index.json").write_text(json.dumps(info, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
summary = {"status": "done", "units_done": 1, "units_total": 1, "usd": None, "calls": 0, "usage": {}, "model": None, "reused": info}
|
||||
(directory / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return info
|
||||
|
||||
|
||||
# --- measure -------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def metrics(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
"""The increment's metrics over verdict, unit-cost and alert rows (a disputed final is neither agreed nor not)."""
|
||||
verdicts = [r for r in rows if r["kind"] == "verdict"]
|
||||
finals = {f: sum(1 for v in verdicts if v.get("final") == f) for f in ("agreed", "rejected", "disputed")}
|
||||
costs = [r.get("usd") for r in rows if r["kind"] == "unit_cost"]
|
||||
usd = None if any(c is None for c in costs) else round(sum(float(c or 0) for c in costs), 6)
|
||||
families: dict[str, list[int]] = {}
|
||||
for v in verdicts:
|
||||
tally = families.setdefault(str(v.get("family")), [0, 0])
|
||||
tally[0] += 1
|
||||
tally[1] += v.get("final") == "disputed"
|
||||
alerts: dict[str, dict[str, int]] = {}
|
||||
for a in (r for r in rows if r["kind"] == "alert"):
|
||||
role = "fixed" if a.get("pin_role") == "fixed" else "vulnerable"
|
||||
tally_a = alerts.setdefault(str(a.get("rule_id")), {"vulnerable": 0, "fixed": 0, "fixed_in_fix_range": 0})
|
||||
tally_a[role] += 1
|
||||
tally_a["fixed_in_fix_range"] += role == "fixed" and a.get("in_fix_range") is True
|
||||
coverage: dict[str, dict[str, int]] = {} # language -> pins per coverage kind: a `none` pin's zero alerts mean nothing
|
||||
for c in (r for r in rows if r["kind"] == "coverage"):
|
||||
tally_c = coverage.setdefault(str(c.get("language")), {"quick": 0, "engine": 0, "none": 0})
|
||||
tally_c[str(c.get("coverage"))] = tally_c.get(str(c.get("coverage")), 0) + 1
|
||||
return {
|
||||
"cases": len({(v["repo"], v["pin"]) for v in verdicts}),
|
||||
"candidates": len(verdicts),
|
||||
**finals,
|
||||
"tiebreaks": sum(1 for v in verdicts if v.get("tiebreak")),
|
||||
"declared_sites_matched": sum(1 for v in verdicts if v.get("site_match") is True),
|
||||
"declared_sites_agreed": sum(1 for v in verdicts if v.get("site_match") is True and v.get("final") == "agreed"),
|
||||
"usd": usd,
|
||||
"usd_per_candidate": None if usd is None or not verdicts else round(usd / len(verdicts), 6),
|
||||
"dispute_rate": {f: {"candidates": n, "disputed": d, "rate": round(d / n, 4)} for f, (n, d) in sorted(families.items())},
|
||||
"alerts": dict(sorted(alerts.items())),
|
||||
"coverage": dict(sorted(coverage.items())),
|
||||
}
|
||||
|
||||
|
||||
def measure(env: Mapping[str, str], output: Path) -> dict[str, Any]:
|
||||
run = env.get("OUSAST_RUN") or "unknown"
|
||||
current: list[Record] = []
|
||||
read: dict[str, int] = {}
|
||||
for name in sorted(k for k in env if k.startswith(INPUT_PREFIX)):
|
||||
path = Path(env[name])
|
||||
data = path.read_bytes()
|
||||
read[name.removeprefix(INPUT_PREFIX).lower()] = len(data)
|
||||
version = hashlib.sha256(data).hexdigest()
|
||||
for number, raw in enumerate(data.decode("utf-8").splitlines(), 1):
|
||||
if raw.strip():
|
||||
row = validate_row(json.loads(raw), f"{path}:{number}")
|
||||
current.append(Record(row, f"runs/{run}/{row['task']}/{REMEMBER_OUTPUT}", version))
|
||||
if not current:
|
||||
raise ValueError(f"read no remember rows from {len(read)} inputs ({read}): an unread run is not an empty one")
|
||||
snapshot = read_records(Path(env["OUSAST_INPUT_SNAPSHOT"])) if env.get("OUSAST_INPUT_SNAPSHOT") else []
|
||||
merged = {str(r.row["id"]): r for r in snapshot}
|
||||
merged.update({str(r.row["id"]): r for r in current}) # this run's row wins over its earlier ingest
|
||||
write_records(output / "rows.jsonl", merged.values())
|
||||
report = {
|
||||
"run": run, "inputs_bytes": read, "rows": {"run": len(current), "snapshot": len(snapshot), "union": len(merged)},
|
||||
"run_metrics": metrics([r.row for r in current]), "store_metrics": metrics([r.row for r in snapshot]),
|
||||
"union_metrics": metrics([r.row for r in merged.values()]),
|
||||
} # fmt: skip
|
||||
(output / "measure.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return {"rows": report["rows"]}
|
||||
|
||||
|
||||
# --- propose and improve -------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _project(env: Mapping[str, str]) -> tuple[Any, Path, Path, Path]:
|
||||
"""(manifest, target, ledger, journal) in the repository at the commit under test; the working directory
|
||||
becomes the repository root, where the pair catalog's relative paths resolve."""
|
||||
from ...benchmark import load_benchmark_manifest, resolve_benchmark_source
|
||||
|
||||
project = Path(env.get("OUSAST_PROJECT_DIR") or "")
|
||||
if not env.get("OUSAST_PROJECT_DIR") or not (project / str(env.get("OUSAST_LOOP_MANIFEST"))).is_file():
|
||||
raise ValueError(f"OUSAST_PROJECT_DIR {project} holds no benchmark manifest {env.get('OUSAST_LOOP_MANIFEST')}")
|
||||
os.chdir(project)
|
||||
manifest_path = Path(str(env["OUSAST_LOOP_MANIFEST"]))
|
||||
manifest = load_benchmark_manifest(manifest_path)
|
||||
target = resolve_benchmark_source(manifest_path, manifest)
|
||||
ledger = target / CALIBRATION / "rule_policy.json"
|
||||
return manifest, target, ledger, ledger.with_name("improve_journal.json")
|
||||
|
||||
|
||||
def _params(env: Mapping[str, str]) -> dict[str, Any]:
|
||||
populations = json.loads(env.get("OUSAST_QUALIFY_POPULATIONS") or "[]")
|
||||
return {"catalog": env.get("OUSAST_LOOP_CATALOG"), "manifest": env.get("OUSAST_LOOP_MANIFEST"), "populations": populations}
|
||||
|
||||
|
||||
def propose(env: Mapping[str, str], output: Path) -> dict[str, Any]:
|
||||
from ...improve.journal import load_journal, next_round_index
|
||||
from ...improve.memory import disputed_families, propose_from_memory, write_sidecar, write_signals
|
||||
from ...ruleset import DEFAULT_RULESET_DIR, load_ruleset, read_rule_ledger
|
||||
|
||||
records = read_records(Path(env["OUSAST_INPUT_ROWS"]))
|
||||
index = json.loads(Path(env["OUSAST_INPUT_INDEX"]).read_text(encoding="utf-8"))
|
||||
_, _, ledger, journal = _project(env)
|
||||
guard = _guard(_params(env), Path.cwd())
|
||||
rules = load_ruleset(DEFAULT_RULESET_DIR, ledger if ledger.is_file() else None)
|
||||
rounds = load_journal(journal)
|
||||
found = propose_from_memory(
|
||||
records, {r.rule_id: r for r in rules}, read_rule_ledger(ledger), rounds, excluded=guard, index_digest=str(index["index_digest"])
|
||||
)
|
||||
lines = [json.dumps({"edit": asdict(p.edit), "provenance": p.provenance}, sort_keys=True) + "\n" for p in found]
|
||||
(output / "proposals.jsonl").write_text("".join(lines), encoding="utf-8")
|
||||
sidecar = output / "journal.json"
|
||||
write_sidecar(sidecar, next_round_index(rounds), found)
|
||||
sidecar.with_name("memory_proposals.jsonl").touch() # present when nothing was proposed, as a declared output
|
||||
write_signals(output, disputed_families(records, guard))
|
||||
kept, excluded = guard.apply(records)
|
||||
return {"proposals": len(found), "rows": len(records), "kept": len(kept), "excluded": excluded["rows"]}
|
||||
|
||||
|
||||
def improve(env: Mapping[str, str], output: Path) -> dict[str, Any]:
|
||||
from ...gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR
|
||||
from ...improve.evolve import run_round
|
||||
from ...improve.memory import MemoryProposal, outcome_of, outcome_rows, proposal_id_of
|
||||
from ...improve.validator import RuleStatusEdit
|
||||
from ...pairs import load_pair_catalog, select_split
|
||||
from ...policy import load_policy
|
||||
from ...ruleset import DEFAULT_RULESET_DIR
|
||||
|
||||
path = Path(env["OUSAST_INPUT_PROPOSALS"])
|
||||
items = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
proposals = [MemoryProposal(RuleStatusEdit(**item["edit"]), item["provenance"]) for item in items]
|
||||
manifest, target, ledger, journal = _project(env)
|
||||
pairs = select_split(load_pair_catalog(Path(str(env["OUSAST_LOOP_CATALOG"]))), "holdout")
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-loop-") as scratch:
|
||||
work_ledger = Path(scratch) / "rule_policy.json"
|
||||
if ledger.is_file():
|
||||
shutil.copyfile(ledger, work_ledger)
|
||||
if journal.is_file():
|
||||
shutil.copyfile(journal, output / "journal.json")
|
||||
outcome = run_round(
|
||||
target, manifest, ledger_path=work_ledger, journal_path=output / "journal.json", ruleset_dir=DEFAULT_RULESET_DIR,
|
||||
policy=load_policy(), recall_floor=RECALL_FLOOR, fp_ceiling=FALSE_POSITIVE_CEILING, pair_cases=pairs, proposals=proposals,
|
||||
) # fmt: skip
|
||||
if outcome.accepted and work_ledger.is_file():
|
||||
shutil.copyfile(work_ledger, output / "rule_policy.json")
|
||||
(output / "journal.json").touch()
|
||||
(output / "memory_proposals.jsonl").touch()
|
||||
memory_edits = [e.key() for e in outcome.edits if proposal_id_of(e)]
|
||||
verdict = {
|
||||
"accepted": outcome.accepted, "reason": outcome.reason, "round": outcome.round, "proposals": len(proposals),
|
||||
"memory_edits": memory_edits, "benchmark_edits": [e.key() for e in outcome.edits if not proposal_id_of(e)],
|
||||
"manifest": env.get("OUSAST_LOOP_MANIFEST"), "outcome": asdict(outcome),
|
||||
"adoption": ADOPTION.format(ledger=ledger.as_posix()) if outcome.accepted else None,
|
||||
} # fmt: skip
|
||||
(output / "gate.json").write_text(json.dumps(verdict, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
||||
status = outcome_of(outcome.reason, outcome.accepted)
|
||||
by_id = {p.proposal_id: p for p in proposals}
|
||||
gate_run = f"plane:{env.get('OUSAST_RUN') or 'unknown'}"
|
||||
rows = outcome_rows(outcome.edits, status, outcome.round, outcome.reason, by_id, gate_run) if status else []
|
||||
(output / REMEMBER_OUTPUT).write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
|
||||
return {"accepted": outcome.accepted, "reason": outcome.reason, "edits": len(outcome.edits), "memory_edits": len(memory_edits)}
|
||||
|
||||
|
||||
def snapshot(env: Mapping[str, str], output: Path) -> dict[str, Any]:
|
||||
raise RuntimeError(
|
||||
"memory-snapshot ran in the sandbox: `ousast plane run` writes it on the host from the memory store before the Run "
|
||||
"starts (memory.seed), so the seed did not happen; rerun through `ousast plane run`"
|
||||
)
|
||||
|
||||
|
||||
STEPS: dict[str, Callable[[Mapping[str, str], Path], dict[str, Any]]] = {
|
||||
"snapshot": snapshot, "measure": measure, "propose": propose, "improve": improve,
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, environ: Mapping[str, str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
env = environ if environ is not None else os.environ
|
||||
output = Path(env["OUSAST_OUTPUT_DIR"]) if env.get("OUSAST_OUTPUT_DIR") else None
|
||||
summary: dict[str, Any] = {"status": "failed", "units_done": 0, "units_total": 1, "usd": 0, "calls": 0, "usage": {}, "model": None}
|
||||
here = Path.cwd()
|
||||
try:
|
||||
if output is None:
|
||||
raise ValueError("OUSAST_OUTPUT_DIR is not set")
|
||||
if len(args) != 1 or args[0] not in STEPS:
|
||||
raise ValueError(f"expected one step of {sorted(STEPS)}, got {args}")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
summary.update(step=args[0], **STEPS[args[0]](env, output))
|
||||
summary.update(status="done", units_done=1)
|
||||
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback
|
||||
summary["reason"] = traceback.format_exc()[-2000:]
|
||||
finally:
|
||||
os.chdir(here)
|
||||
if output is not None:
|
||||
(output / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
||||
shown = {k: summary.get(k) for k in ("status", "step", "proposals", "accepted", "reason", "rows")}
|
||||
print(json.dumps(shown, default=str), file=sys.stderr)
|
||||
return 0 if summary["status"] == "done" else 2
|
||||
|
||||
|
||||
__all__ = ["main", "measure", "metrics", "propose", "improve", "read_records", "write_records", "write_snapshot"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user