rlgate 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlgate/__init__.py +7 -0
- rlgate/baseline/__init__.py +0 -0
- rlgate/baseline/invariant.py +30 -0
- rlgate/cli.py +75 -0
- rlgate/core/__init__.py +0 -0
- rlgate/core/context.py +47 -0
- rlgate/core/finding.py +40 -0
- rlgate/core/probe.py +44 -0
- rlgate/core/registry.py +46 -0
- rlgate/core/report.py +78 -0
- rlgate/core/task.py +102 -0
- rlgate/ingest/__init__.py +0 -0
- rlgate/ingest/presets/__init__.py +0 -0
- rlgate/ingest/schemas/__init__.py +0 -0
- rlgate/ingest/spec.py +75 -0
- rlgate/pipeline.py +64 -0
- rlgate/probes/__init__.py +0 -0
- rlgate/probes/forgery/__init__.py +0 -0
- rlgate/probes/forgery/world_readable_tests.py +44 -0
- rlgate/probes/grader_strength/__init__.py +0 -0
- rlgate/probes/grader_strength/revert_probe.py +44 -0
- rlgate/probes/hygiene/__init__.py +0 -0
- rlgate/probes/leakage/__init__.py +0 -0
- rlgate/probes/leakage/git_unreachable_objects.py +46 -0
- rlgate/probes/patterns/__init__.py +0 -0
- rlgate/probes/reward_shape/__init__.py +0 -0
- rlgate/probes/test_quality/__init__.py +0 -0
- rlgate/sandbox/__init__.py +0 -0
- rlgate/sandbox/_patch.py +30 -0
- rlgate/sandbox/base.py +63 -0
- rlgate/sandbox/docker.py +120 -0
- rlgate/sandbox/local.py +62 -0
- rlgate/sandbox/select.py +22 -0
- rlgate/variants/__init__.py +0 -0
- rlgate/variants/engine.py +76 -0
- rlgate/variants/revert.py +70 -0
- rlgate-0.0.1.dist-info/METADATA +81 -0
- rlgate-0.0.1.dist-info/RECORD +41 -0
- rlgate-0.0.1.dist-info/WHEEL +4 -0
- rlgate-0.0.1.dist-info/entry_points.txt +2 -0
- rlgate-0.0.1.dist-info/licenses/LICENSE +21 -0
rlgate/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""rlgate — a deterministic auditor for reward-hacking vulnerabilities in RL/eval environments.
|
|
2
|
+
|
|
3
|
+
Public surface is intentionally small; everything extensible lives behind the registries
|
|
4
|
+
in ``rlgate.core.registry`` (probes, loaders, sandboxes, scoring schemas).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.0.1"
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Phase B: oracle==1.0 / nop==0.0 at k=5, plus flaky detection. Fail-fast — if the baseline
|
|
2
|
+
is not sane, probes that assume a sane baseline are skipped (they'd be noise)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from rlgate.core.context import Baseline
|
|
7
|
+
from rlgate.core.task import Task
|
|
8
|
+
from rlgate.sandbox.base import Sandbox
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def establish(task: Task, sandbox: Sandbox, k: int = 5) -> Baseline:
|
|
12
|
+
oracle_runs = [_run(task, sandbox, gold=True) for _ in range(k)]
|
|
13
|
+
nop_runs = [_run(task, sandbox, gold=False) for _ in range(k)]
|
|
14
|
+
|
|
15
|
+
flaky = set()
|
|
16
|
+
if len(set(oracle_runs)) > 1:
|
|
17
|
+
flaky.add("oracle")
|
|
18
|
+
if len(set(nop_runs)) > 1:
|
|
19
|
+
flaky.add("nop")
|
|
20
|
+
|
|
21
|
+
oracle = min(oracle_runs) # worst-case; a single miss is a flaky/unfair signal
|
|
22
|
+
nop = max(nop_runs)
|
|
23
|
+
return Baseline(oracle=oracle, nop=nop, k=k, flaky_checks=frozenset(flaky))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(task: Task, sandbox: Sandbox, gold: bool) -> float:
|
|
27
|
+
patch = task.gold_patch.text if (gold and task.gold_patch) else None
|
|
28
|
+
with sandbox.overlay(patch) as ovl:
|
|
29
|
+
res = ovl.exec(task.grader.run)
|
|
30
|
+
return task.grader.pass_reward if res.exit_code == 0 else 0.0
|
rlgate/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`rlgate` CLI. Stdlib argparse only — no runtime CLI deps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from rlgate import __version__
|
|
9
|
+
from rlgate.core.registry import discover
|
|
10
|
+
from rlgate.core.report import to_json, to_markdown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="rlgate", description=__doc__)
|
|
15
|
+
parser.add_argument("--version", action="version", version=f"rlgate {__version__}")
|
|
16
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
17
|
+
|
|
18
|
+
p_list = sub.add_parser("list-probes", help="show all discovered probes")
|
|
19
|
+
p_list.add_argument("--family", help="filter by family")
|
|
20
|
+
|
|
21
|
+
p_audit = sub.add_parser("audit", help="audit an environment (path to descriptor or dir)")
|
|
22
|
+
p_audit.add_argument("target")
|
|
23
|
+
p_audit.add_argument("-k", type=int, default=5, help="baseline repetitions (default 5)")
|
|
24
|
+
p_audit.add_argument("--backend", choices=["auto", "local", "docker"], default="auto",
|
|
25
|
+
help="sandbox backend (default auto: docker if the task has an image)")
|
|
26
|
+
p_audit.add_argument("--probe", action="append", help="restrict to these probe ids")
|
|
27
|
+
p_audit.add_argument("--format", choices=["md", "json"], default="md")
|
|
28
|
+
p_audit.add_argument("--gate", action="store_true",
|
|
29
|
+
help="exit non-zero on any FIX/DROP (CI mode)")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
if args.cmd == "list-probes":
|
|
34
|
+
return _list_probes(args.family)
|
|
35
|
+
if args.cmd == "audit":
|
|
36
|
+
return _audit(args)
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _list_probes(family: str | None) -> int:
|
|
41
|
+
probes = discover()
|
|
42
|
+
for pid in sorted(probes):
|
|
43
|
+
meta = probes[pid].meta
|
|
44
|
+
if family and meta.family != family:
|
|
45
|
+
continue
|
|
46
|
+
reqs = ",".join(c.value for c in meta.requires) or "-"
|
|
47
|
+
print(f"{pid:42} {meta.kind.value:7} {meta.severity.name:8} needs[{reqs}] {meta.doc}")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _audit(args) -> int:
|
|
52
|
+
from rlgate.core.report import eqs, triage
|
|
53
|
+
from rlgate.ingest import spec
|
|
54
|
+
from rlgate.pipeline import audit
|
|
55
|
+
|
|
56
|
+
task = spec.load(args.target)
|
|
57
|
+
result = audit(task, k=args.k, only=set(args.probe) if args.probe else None,
|
|
58
|
+
backend=args.backend)
|
|
59
|
+
|
|
60
|
+
if args.format == "json":
|
|
61
|
+
print(to_json(result.task_id, result.findings, result.baseline_sane))
|
|
62
|
+
else:
|
|
63
|
+
print(to_markdown(result.task_id, result.findings, result.baseline_sane))
|
|
64
|
+
|
|
65
|
+
if args.gate:
|
|
66
|
+
t = triage(result.findings, result.baseline_sane)
|
|
67
|
+
if t.value != "KEEP":
|
|
68
|
+
print(f"\nGATE: {t.value} (eqs={eqs(result.findings, result.baseline_sane):.2f})",
|
|
69
|
+
file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
raise SystemExit(main())
|
rlgate/core/__init__.py
ADDED
|
File without changes
|
rlgate/core/context.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared, expensive state built once and handed to every probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from rlgate.sandbox.base import Sandbox
|
|
10
|
+
from rlgate.variants.engine import VariantEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Baseline:
|
|
15
|
+
"""Phase B result: oracle/nop at k runs, flaky checks, and the per-test→region map."""
|
|
16
|
+
|
|
17
|
+
oracle: float # gold reward (want 1.0)
|
|
18
|
+
nop: float # empty-submission reward (want 0.0)
|
|
19
|
+
k: int
|
|
20
|
+
flaky_checks: frozenset[str] = frozenset() # checks that disagreed across k runs
|
|
21
|
+
per_test: dict[str, bool] = field(default_factory=dict) # test id → passed under gold
|
|
22
|
+
ok: bool = False # oracle==1.0 and nop==0.0 and no flaky checks
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sane(self) -> bool:
|
|
26
|
+
return self.oracle == 1.0 and self.nop == 0.0 and not self.flaky_checks
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResultCache:
|
|
30
|
+
"""Content-addressed cache so identical variant runs are free across a session."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._store: dict[str, Any] = {}
|
|
34
|
+
|
|
35
|
+
def get_or_run(self, key: str, fn):
|
|
36
|
+
if key not in self._store:
|
|
37
|
+
self._store[key] = fn()
|
|
38
|
+
return self._store[key]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProbeContext:
|
|
43
|
+
sandbox: Sandbox
|
|
44
|
+
baseline: Baseline
|
|
45
|
+
variants: VariantEngine
|
|
46
|
+
cache: ResultCache = field(default_factory=ResultCache)
|
|
47
|
+
config: dict = field(default_factory=dict)
|
rlgate/core/finding.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""What every probe returns. A finding is a reproduced exploit or a concrete code location —
|
|
2
|
+
never a threshold on a score."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum, IntEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Severity(IntEnum):
|
|
12
|
+
LOW = 1
|
|
13
|
+
MEDIUM = 2
|
|
14
|
+
HIGH = 3
|
|
15
|
+
CRITICAL = 4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VerdictTier(Enum):
|
|
19
|
+
CONFIRMED = "confirmed" # execution-proven: a reproduced exploit or a concrete code fact
|
|
20
|
+
REVIEW = "review" # detected deterministically; fairness needs NL/human adjudication
|
|
21
|
+
DEFERRED = "deferred" # out of the deterministic core's scope; needs the LLM layer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Evidence:
|
|
26
|
+
kind: str # "reproduced_exploit"|"code_line"|"variant"|"perms"|"diff"
|
|
27
|
+
detail: str
|
|
28
|
+
replay: str | None = None # exact command to reproduce
|
|
29
|
+
artifacts: list[Path] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Finding:
|
|
34
|
+
probe_id: str
|
|
35
|
+
title: str
|
|
36
|
+
severity: Severity
|
|
37
|
+
verdict: VerdictTier
|
|
38
|
+
evidence: Evidence
|
|
39
|
+
location: str | None = None # file:line the finding anchors to
|
|
40
|
+
fix_hint: str | None = None # the actionable remediation (FIX, not just DROP)
|
rlgate/core/probe.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The one interface every check implements. A probe is a self-contained module in
|
|
2
|
+
``rlgate/probes/<family>/`` decorated with ``@register`` — discovery picks it up."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from rlgate.core.context import ProbeContext
|
|
11
|
+
from rlgate.core.finding import Finding, Severity
|
|
12
|
+
from rlgate.core.task import Capability, Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProbeKind(Enum):
|
|
16
|
+
GENERIC = "generic" # free with the engine (runs on any task with the capabilities)
|
|
17
|
+
PATTERN = "pattern" # per-grader pattern library (§3.6–§3.8); provable once written
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ProbeMeta:
|
|
22
|
+
id: str # stable, e.g. "forgery.world_readable_tests"
|
|
23
|
+
family: str
|
|
24
|
+
kind: ProbeKind
|
|
25
|
+
severity: Severity
|
|
26
|
+
needs_live: bool = False # requires a live run (e.g. race probes)
|
|
27
|
+
languages: frozenset[str] | None = None # None = language-agnostic
|
|
28
|
+
requires: frozenset[Capability] = field(default_factory=frozenset) # task must provide ALL
|
|
29
|
+
doc: str = "" # one-line summary → probe-catalog.md
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Probe(ABC):
|
|
33
|
+
meta: ProbeMeta
|
|
34
|
+
|
|
35
|
+
def applicable(self, task: Task) -> bool:
|
|
36
|
+
"""Gate on CAPABILITIES, never on task.source / benchmark identity."""
|
|
37
|
+
caps_ok = self.meta.requires <= task.capabilities
|
|
38
|
+
lang_ok = self.meta.languages is None or bool(self.meta.languages & task.languages)
|
|
39
|
+
return caps_ok and lang_ok
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def run(self, task: Task, ctx: ProbeContext) -> list[Finding]:
|
|
43
|
+
"""Deterministic generation; stable verdicts. Return zero or more findings."""
|
|
44
|
+
raise NotImplementedError
|
rlgate/core/registry.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Discovery: a contributor drops a ``@register``-decorated probe into
|
|
2
|
+
``rlgate/probes/<family>/`` and it is picked up automatically. External packages ship probes
|
|
3
|
+
via the ``rlgate.probes`` entry-point group without touching this repo."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import pkgutil
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
|
|
11
|
+
from rlgate.core.probe import Probe
|
|
12
|
+
|
|
13
|
+
_PROBES: dict[str, type[Probe]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(cls: type[Probe]) -> type[Probe]:
|
|
17
|
+
pid = cls.meta.id
|
|
18
|
+
if pid in _PROBES and _PROBES[pid] is not cls:
|
|
19
|
+
raise ValueError(f"duplicate probe id: {pid}")
|
|
20
|
+
_PROBES[pid] = cls
|
|
21
|
+
return cls
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _import_tree(package_name: str) -> None:
|
|
25
|
+
pkg = importlib.import_module(package_name)
|
|
26
|
+
for mod in pkgutil.walk_packages(pkg.__path__, prefix=package_name + "."):
|
|
27
|
+
if not mod.name.rsplit(".", 1)[-1].startswith("_"):
|
|
28
|
+
importlib.import_module(mod.name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def discover() -> dict[str, type[Probe]]:
|
|
32
|
+
"""Import every in-repo probe module, then load third-party entry points. Idempotent."""
|
|
33
|
+
_import_tree("rlgate.probes")
|
|
34
|
+
try:
|
|
35
|
+
eps = metadata.entry_points(group="rlgate.probes")
|
|
36
|
+
except Exception: # noqa: BLE001 - entry-point loading can raise many things; best-effort
|
|
37
|
+
eps = []
|
|
38
|
+
for ep in eps:
|
|
39
|
+
obj = ep.load()
|
|
40
|
+
if isinstance(obj, type) and issubclass(obj, Probe):
|
|
41
|
+
register(obj)
|
|
42
|
+
return dict(_PROBES)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def all_probes() -> dict[str, type[Probe]]:
|
|
46
|
+
return dict(_PROBES)
|
rlgate/core/report.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Turn findings into an EQS + KEEP/FIX/DROP triage + JSON/Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from rlgate.core.finding import Finding, Severity, VerdictTier
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Triage(Enum):
|
|
13
|
+
KEEP = "KEEP"
|
|
14
|
+
FIX = "FIX"
|
|
15
|
+
DROP = "DROP"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def triage(findings: list[Finding], baseline_sane: bool) -> Triage:
|
|
19
|
+
if not baseline_sane:
|
|
20
|
+
return Triage.DROP
|
|
21
|
+
if any(f.severity >= Severity.CRITICAL for f in findings):
|
|
22
|
+
return Triage.DROP
|
|
23
|
+
if findings:
|
|
24
|
+
return Triage.FIX
|
|
25
|
+
return Triage.KEEP
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def eqs(findings: list[Finding], baseline_sane: bool) -> float:
|
|
29
|
+
"""Environment Quality Score in [0,1]: 1.0 clean, down-weighted by severity."""
|
|
30
|
+
if not baseline_sane:
|
|
31
|
+
return 0.0
|
|
32
|
+
penalty = sum(0.1 * int(f.severity) for f in findings)
|
|
33
|
+
return max(0.0, 1.0 - penalty)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_json(task_id: str, findings: list[Finding], baseline_sane: bool) -> str:
|
|
37
|
+
payload = {
|
|
38
|
+
"task": task_id,
|
|
39
|
+
"eqs": eqs(findings, baseline_sane),
|
|
40
|
+
"triage": triage(findings, baseline_sane).value,
|
|
41
|
+
"baseline_sane": baseline_sane,
|
|
42
|
+
"findings": [_finding_json(f) for f in findings],
|
|
43
|
+
}
|
|
44
|
+
return json.dumps(payload, indent=2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _finding_json(f: Finding) -> dict:
|
|
48
|
+
d = asdict(f)
|
|
49
|
+
d["severity"] = f.severity.name
|
|
50
|
+
d["verdict"] = f.verdict.value
|
|
51
|
+
d["evidence"]["artifacts"] = [str(p) for p in f.evidence.artifacts]
|
|
52
|
+
return d
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_markdown(task_id: str, findings: list[Finding], baseline_sane: bool) -> str:
|
|
56
|
+
tier = {VerdictTier.CONFIRMED: "[CONFIRMED]", VerdictTier.REVIEW: "[REVIEW]",
|
|
57
|
+
VerdictTier.DEFERRED: "[DEFERRED]"}
|
|
58
|
+
lines = [
|
|
59
|
+
f"# rlgate audit — {task_id}",
|
|
60
|
+
(f"**EQS {eqs(findings, baseline_sane):.2f} · "
|
|
61
|
+
f"{triage(findings, baseline_sane).value}** "
|
|
62
|
+
f"· baseline {'OK' if baseline_sane else 'BROKEN'}"),
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
if not findings:
|
|
66
|
+
lines.append("_No findings._")
|
|
67
|
+
for f in sorted(findings, key=lambda x: x.severity, reverse=True):
|
|
68
|
+
lines += [
|
|
69
|
+
f"## {tier[f.verdict]} {f.title} ({f.severity.name})",
|
|
70
|
+
f"- probe: `{f.probe_id}`" + (f" · at `{f.location}`" if f.location else ""),
|
|
71
|
+
f"- evidence: {f.evidence.detail}",
|
|
72
|
+
]
|
|
73
|
+
if f.evidence.replay:
|
|
74
|
+
lines.append(f"- replay: `{f.evidence.replay}`")
|
|
75
|
+
if f.fix_hint:
|
|
76
|
+
lines.append(f"- fix: {f.fix_hint}")
|
|
77
|
+
lines.append("")
|
|
78
|
+
return "\n".join(lines)
|
rlgate/core/task.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The normalized environment contract. Every loader produces a ``Task``; every probe
|
|
2
|
+
consumes one. Probes gate on ``Task.capabilities`` — never on a benchmark name."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from unidiff import PatchSet
|
|
14
|
+
except ImportError: # keep import-time soft so `list-probes` works without extras
|
|
15
|
+
PatchSet = None # type: ignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Capability(Enum):
|
|
19
|
+
"""What a task *provides*. Probes require these; this is the only thing they gate on."""
|
|
20
|
+
|
|
21
|
+
GOLD_PATCH = "gold_patch" # a reference fix with addressable hunks
|
|
22
|
+
GIT_REPO = "git_repo" # workspace is a git checkout
|
|
23
|
+
CONTAINER = "container" # grader runs in a container we control
|
|
24
|
+
SCORING_SPEC = "scoring_spec" # a declared reward formula (any schema plugin)
|
|
25
|
+
PER_TEST = "per_test" # grader emits per-test results (junit/trx/go-json/…)
|
|
26
|
+
INSTRUCTION = "instruction" # an agent-facing prompt exists
|
|
27
|
+
# extend freely — adding a capability is additive.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Patch:
|
|
32
|
+
"""A unified diff. Canonicalized + hashed once so hunk decomposition is stable."""
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
|
|
36
|
+
@cached_property
|
|
37
|
+
def sha256(self) -> str:
|
|
38
|
+
return hashlib.sha256(self.text.encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
@cached_property
|
|
41
|
+
def patchset(self) -> PatchSet:
|
|
42
|
+
if PatchSet is None:
|
|
43
|
+
raise RuntimeError("unidiff is required to parse patches (pip install rlgate[dev])")
|
|
44
|
+
return PatchSet(self.text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ReportSpec:
|
|
49
|
+
format: str # "junit" | "trx" | "vitest_json" | "go_json" | "cpp_exit"
|
|
50
|
+
path: str # where the grader writes it (inside the sandbox)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class GraderSpec:
|
|
55
|
+
"""How to score a submission. Image optional (local run); report optional (per-test)."""
|
|
56
|
+
|
|
57
|
+
run: str # command that scores the current workspace
|
|
58
|
+
image: str | None = None # Dockerfile path or image ref; None → local sandbox
|
|
59
|
+
report: ReportSpec | None = None
|
|
60
|
+
pass_reward: float = 1.0 # reward when the run signals full success
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def capabilities(self) -> set[Capability]:
|
|
64
|
+
caps: set[Capability] = set()
|
|
65
|
+
if self.image:
|
|
66
|
+
caps.add(Capability.CONTAINER)
|
|
67
|
+
if self.report:
|
|
68
|
+
caps.add(Capability.PER_TEST)
|
|
69
|
+
return caps
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ScoringSpec:
|
|
74
|
+
schema: str # name of a registered ScoringSchema plugin
|
|
75
|
+
path: str # path to the scoring file(s) this schema reads
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Task:
|
|
80
|
+
id: str
|
|
81
|
+
source: str # free-form provenance; NOT a probe switch
|
|
82
|
+
repo_dir: Path # the as-shipped workspace
|
|
83
|
+
grader: GraderSpec
|
|
84
|
+
instruction: str | None = None
|
|
85
|
+
gold_patch: Patch | None = None
|
|
86
|
+
scoring_spec: ScoringSpec | None = None
|
|
87
|
+
languages: frozenset[str] = frozenset()
|
|
88
|
+
meta: dict = field(default_factory=dict) # source-specific; generic probes never read it
|
|
89
|
+
|
|
90
|
+
@cached_property
|
|
91
|
+
def capabilities(self) -> frozenset[Capability]:
|
|
92
|
+
caps: set[Capability] = set()
|
|
93
|
+
if self.gold_patch:
|
|
94
|
+
caps.add(Capability.GOLD_PATCH)
|
|
95
|
+
if self.scoring_spec:
|
|
96
|
+
caps.add(Capability.SCORING_SPEC)
|
|
97
|
+
if self.instruction:
|
|
98
|
+
caps.add(Capability.INSTRUCTION)
|
|
99
|
+
if (self.repo_dir / ".git").exists():
|
|
100
|
+
caps.add(Capability.GIT_REPO)
|
|
101
|
+
caps |= self.grader.capabilities
|
|
102
|
+
return frozenset(caps)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
rlgate/ingest/spec.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Generic, declarative ingestion: describe ANY environment in `task.rlgate.yaml` and get a
|
|
2
|
+
`Task`. Capabilities are derived from which keys are present — no benchmark-specific code.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
instruction_path: TASK.md
|
|
6
|
+
workspace: ./repo
|
|
7
|
+
gold_patch: ./solution/fix.patch # optional → GOLD_PATCH probes
|
|
8
|
+
grader:
|
|
9
|
+
run: "bash run_tests.sh"
|
|
10
|
+
image: ./Dockerfile # optional → CONTAINER
|
|
11
|
+
report: {format: junit, path: /out/results.xml} # optional → PER_TEST
|
|
12
|
+
scoring_spec: # optional → reward_shape probes
|
|
13
|
+
schema: harbor_scorepy
|
|
14
|
+
path: ./score.py
|
|
15
|
+
languages: [python]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from rlgate.core.task import GraderSpec, Patch, ReportSpec, ScoringSpec, Task
|
|
25
|
+
|
|
26
|
+
DESCRIPTOR_NAME = "task.rlgate.yaml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(descriptor_path: str | Path) -> Task:
|
|
30
|
+
path = Path(descriptor_path)
|
|
31
|
+
if path.is_dir():
|
|
32
|
+
path = path / DESCRIPTOR_NAME
|
|
33
|
+
data = yaml.safe_load(path.read_text())
|
|
34
|
+
base = path.parent
|
|
35
|
+
|
|
36
|
+
def rel(p: str) -> Path:
|
|
37
|
+
return (base / p).resolve()
|
|
38
|
+
|
|
39
|
+
g = data["grader"]
|
|
40
|
+
report = None
|
|
41
|
+
if g.get("report"):
|
|
42
|
+
report = ReportSpec(format=g["report"]["format"], path=g["report"]["path"])
|
|
43
|
+
grader = GraderSpec(
|
|
44
|
+
run=g["run"],
|
|
45
|
+
image=str(rel(g["image"])) if g.get("image") else None,
|
|
46
|
+
report=report,
|
|
47
|
+
pass_reward=float(g.get("pass_reward", 1.0)),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
instruction = None
|
|
51
|
+
if data.get("instruction_path"):
|
|
52
|
+
instruction = rel(data["instruction_path"]).read_text()
|
|
53
|
+
elif data.get("instruction"):
|
|
54
|
+
instruction = data["instruction"]
|
|
55
|
+
|
|
56
|
+
gold = None
|
|
57
|
+
if data.get("gold_patch"):
|
|
58
|
+
gold = Patch(text=rel(data["gold_patch"]).read_text())
|
|
59
|
+
|
|
60
|
+
scoring = None
|
|
61
|
+
if data.get("scoring_spec"):
|
|
62
|
+
s = data["scoring_spec"]
|
|
63
|
+
scoring = ScoringSpec(schema=s["schema"], path=str(rel(s["path"])))
|
|
64
|
+
|
|
65
|
+
return Task(
|
|
66
|
+
id=data.get("id", path.parent.name),
|
|
67
|
+
source=data.get("source", str(path)),
|
|
68
|
+
repo_dir=rel(data["workspace"]),
|
|
69
|
+
grader=grader,
|
|
70
|
+
instruction=instruction,
|
|
71
|
+
gold_patch=gold,
|
|
72
|
+
scoring_spec=scoring,
|
|
73
|
+
languages=frozenset(data.get("languages", [])),
|
|
74
|
+
meta=data.get("meta", {}),
|
|
75
|
+
)
|
rlgate/pipeline.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Orchestration: ingest → baseline (fail-fast) → applicable probes (capability-gated) →
|
|
2
|
+
report. Probes gate on capabilities only; the pipeline never branches on benchmark identity."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from rlgate.baseline.invariant import establish
|
|
9
|
+
from rlgate.core.context import ProbeContext
|
|
10
|
+
from rlgate.core.finding import Finding
|
|
11
|
+
from rlgate.core.registry import discover
|
|
12
|
+
from rlgate.core.task import Task
|
|
13
|
+
from rlgate.sandbox.base import Sandbox
|
|
14
|
+
from rlgate.sandbox.select import build_sandbox
|
|
15
|
+
from rlgate.variants.engine import VariantEngine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AuditResult:
|
|
20
|
+
task_id: str
|
|
21
|
+
baseline_sane: bool
|
|
22
|
+
findings: list[Finding]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def audit(task: Task, sandbox: Sandbox | None = None, k: int = 5,
|
|
26
|
+
only: set[str] | None = None, backend: str = "auto") -> AuditResult:
|
|
27
|
+
sandbox = sandbox or build_sandbox(task, prefer=backend)
|
|
28
|
+
sandbox.build()
|
|
29
|
+
|
|
30
|
+
baseline = establish(task, sandbox, k=k)
|
|
31
|
+
ctx = ProbeContext(
|
|
32
|
+
sandbox=sandbox,
|
|
33
|
+
baseline=baseline,
|
|
34
|
+
variants=VariantEngine(task, sandbox, baseline),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
findings: list[Finding] = []
|
|
38
|
+
for pid, cls in discover().items():
|
|
39
|
+
if only and pid not in only:
|
|
40
|
+
continue
|
|
41
|
+
probe = cls()
|
|
42
|
+
if not probe.applicable(task):
|
|
43
|
+
continue
|
|
44
|
+
# Fail-fast: when the baseline is broken, skip probes that assume it's sane.
|
|
45
|
+
if not baseline.sane and probe.meta.family in {"grader_strength", "reward_shape"}:
|
|
46
|
+
continue
|
|
47
|
+
findings.extend(probe.run(task, ctx))
|
|
48
|
+
|
|
49
|
+
if not baseline.sane:
|
|
50
|
+
findings.insert(0, _baseline_finding(baseline))
|
|
51
|
+
return AuditResult(task.id, baseline.sane, findings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _baseline_finding(baseline) -> Finding:
|
|
55
|
+
from rlgate.core.finding import Evidence, Severity, VerdictTier
|
|
56
|
+
detail = f"oracle={baseline.oracle} nop={baseline.nop} flaky={sorted(baseline.flaky_checks)}"
|
|
57
|
+
return Finding(
|
|
58
|
+
probe_id="baseline.invariant",
|
|
59
|
+
title="baseline not sane (oracle!=1.0 / nop!=0.0 / flaky)",
|
|
60
|
+
severity=Severity.CRITICAL,
|
|
61
|
+
verdict=VerdictTier.CONFIRMED,
|
|
62
|
+
evidence=Evidence(kind="reproduced_exploit", detail=detail),
|
|
63
|
+
fix_hint="the environment is broken before hacking is even discussed; fix this first.",
|
|
64
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""§3.1 — held-out sources / scorer readable by the candidate UID (not chmod 700)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rlgate.core.context import ProbeContext
|
|
6
|
+
from rlgate.core.finding import Evidence, Finding, Severity, VerdictTier
|
|
7
|
+
from rlgate.core.probe import Probe, ProbeKind, ProbeMeta
|
|
8
|
+
from rlgate.core.registry import register
|
|
9
|
+
from rlgate.core.task import Task
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register
|
|
13
|
+
class WorldReadableTests(Probe):
|
|
14
|
+
meta = ProbeMeta(
|
|
15
|
+
id="forgery.world_readable_tests",
|
|
16
|
+
family="forgery",
|
|
17
|
+
kind=ProbeKind.GENERIC,
|
|
18
|
+
severity=Severity.HIGH,
|
|
19
|
+
doc="/tests (held-out sources, scorer) is readable by the candidate UID.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# The directory holding graded sources; overridable via task.meta without special-casing.
|
|
23
|
+
def _tests_dir(self, task: Task) -> str:
|
|
24
|
+
return task.meta.get("tests_dir", "tests")
|
|
25
|
+
|
|
26
|
+
def run(self, task: Task, ctx: ProbeContext) -> list[Finding]:
|
|
27
|
+
tdir = self._tests_dir(task)
|
|
28
|
+
with ctx.sandbox.overlay(None) as ovl:
|
|
29
|
+
perms = ovl.read_perms(tdir)
|
|
30
|
+
if perms.world_readable:
|
|
31
|
+
return [Finding(
|
|
32
|
+
probe_id=self.meta.id,
|
|
33
|
+
title=f"{tdir}/ is world-readable; held-out names leak at runtime",
|
|
34
|
+
severity=Severity.HIGH,
|
|
35
|
+
verdict=VerdictTier.CONFIRMED,
|
|
36
|
+
location=tdir,
|
|
37
|
+
evidence=Evidence(
|
|
38
|
+
kind="perms",
|
|
39
|
+
detail=f"mode={perms.mode:#o}",
|
|
40
|
+
replay=f"rlgate audit <task> --probe {self.meta.id}",
|
|
41
|
+
),
|
|
42
|
+
fix_hint="chmod 700 the graded dir; delete graded sources after build.",
|
|
43
|
+
)]
|
|
44
|
+
return []
|
|
File without changes
|