feat(1e): HC2 repo-local approval allowlist (default-deny) + discovery gate
- tests/repo-local-approved.txt (empty ⇒ default-deny); CCCI_REPO_LOCAL_APPROVED_FILE override. - discovery: repo_local_approved()/_gated() centralize the gate; resolve_overlay_op + generic_op (HC3 additive split); custom_tests/install_steps/pre_op_hook all honor the gate. - unit tests rewritten for approved-vs-not + the generic floor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,19 +1,25 @@
|
||||
"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d, plan §2.5, DG4/DG5).
|
||||
"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d/1e, DG4/DG5 + HC2/HC3).
|
||||
|
||||
The generic is the default for each lifecycle op; a recipe's `test_<op>.py` OVERRIDES it. Sources,
|
||||
in precedence order (machine-docs/DECISIONS.md):
|
||||
The generic is the default floor for each lifecycle op and, per Phase 1e HC3, runs ADDITIVELY
|
||||
alongside a recipe overlay by default (the orchestrator owns the op; both assertion sets evaluate the
|
||||
shared post-op state). Discovery here only locates the candidate assertion files + the install-steps
|
||||
hook; the orchestrator decides additive-vs-skip. Sources, in precedence order
|
||||
(machine-docs/DECISIONS.md):
|
||||
|
||||
lifecycle op (install/upgrade/backup/restore) — exactly ONE assertion file runs:
|
||||
lifecycle op (install/upgrade/backup/restore) — the OVERLAY assertion file, if any:
|
||||
repo-local tests/test_<op>.py (upstream-authoritative, wins same-name collisions)
|
||||
> cc-ci tests/<recipe>/test_<op>.py
|
||||
> generic tests/_generic/test_<op>.py <- always present; the floor
|
||||
(the generic tests/_generic/test_<op>.py is the always-present floor, run separately by default)
|
||||
|
||||
custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in).
|
||||
|
||||
install-steps hook — install_steps.sh: repo-local > cc-ci, or none.
|
||||
|
||||
Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra
|
||||
re-checking-out the recipe to a version tag — see the run orchestrator).
|
||||
re-checking-out the recipe to a version tag — see the run orchestrator). It is PR-author-controlled
|
||||
code that runs on the CI host with /run/secrets/* present, so per Phase 1e HC2 it is **default-deny**:
|
||||
the repo-local source is consulted ONLY when the recipe is on the cc-ci approval allowlist
|
||||
(`tests/repo-local-approved.txt`). Otherwise precedence is cc-ci > generic only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -25,32 +31,81 @@ LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
GENERIC_DIR = os.path.join(ROOT, "tests", "_generic")
|
||||
APPROVED_FILE = os.path.join(ROOT, "tests", "repo-local-approved.txt")
|
||||
|
||||
|
||||
def cc_ci_dir(recipe: str) -> str:
|
||||
return os.path.join(ROOT, "tests", recipe)
|
||||
|
||||
|
||||
def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]:
|
||||
"""Return (source, path) for the single assertion file to run for `op`:
|
||||
source in {"repo-local","cc-ci","generic"}. The generic file is the floor and always exists."""
|
||||
def approved_file_path() -> str:
|
||||
"""Location of the repo-local approval allowlist. Overridable via CCCI_REPO_LOCAL_APPROVED_FILE
|
||||
(used by tests + by the Adversary to demonstrate approved-vs-not without editing the checked-in
|
||||
file). Default: the git-tracked tests/repo-local-approved.txt."""
|
||||
return os.environ.get("CCCI_REPO_LOCAL_APPROVED_FILE", APPROVED_FILE)
|
||||
|
||||
|
||||
def approved_recipes() -> set[str]:
|
||||
"""Recipes whose repo-local (PR-authored) code is trusted to execute (HC2). One name per line in
|
||||
the allowlist; `#` comments + blank lines ignored. Missing file ⇒ empty set ⇒ default-deny."""
|
||||
names: set[str] = set()
|
||||
try:
|
||||
with open(approved_file_path()) as fh:
|
||||
for raw in fh:
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if line:
|
||||
names.add(line)
|
||||
except OSError:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def repo_local_approved(recipe: str) -> bool:
|
||||
"""True iff `recipe` is on the cc-ci repo-local approval allowlist (default-deny, HC2)."""
|
||||
return recipe in approved_recipes()
|
||||
|
||||
|
||||
def _gated(recipe: str, repo_local_dir: str | None) -> str | None:
|
||||
"""The repo-local dir to actually consult: the given dir if the recipe is approved, else None
|
||||
(default-deny). Centralizes the HC2 gate so every discovery function honors it identically."""
|
||||
return repo_local_dir if (repo_local_dir and repo_local_approved(recipe)) else None
|
||||
|
||||
|
||||
def resolve_overlay_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
|
||||
"""Return (source, path) for the OVERLAY assertion file for `op` (repo-local > cc-ci), or None if
|
||||
the recipe ships no overlay for it. The generic floor is handled separately by the orchestrator
|
||||
(HC3 additive). Repo-local is consulted only for allowlist-approved recipes (HC2)."""
|
||||
fname = f"test_{op}.py"
|
||||
if repo_local_dir:
|
||||
p = os.path.join(repo_local_dir, fname)
|
||||
rl = _gated(recipe, repo_local_dir)
|
||||
if rl:
|
||||
p = os.path.join(rl, fname)
|
||||
if os.path.isfile(p):
|
||||
return ("repo-local", p)
|
||||
p = os.path.join(cc_ci_dir(recipe), fname)
|
||||
if os.path.isfile(p):
|
||||
return ("cc-ci", p)
|
||||
return ("generic", os.path.join(GENERIC_DIR, fname))
|
||||
return None
|
||||
|
||||
|
||||
def generic_op(op: str) -> tuple[str, str]:
|
||||
"""The always-present generic assertion file for `op` (the floor, HC3)."""
|
||||
return ("generic", os.path.join(GENERIC_DIR, f"test_{op}.py"))
|
||||
|
||||
|
||||
def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]:
|
||||
"""Back-compat single-file resolver (override semantics): overlay if present, else generic.
|
||||
Phase-1e orchestration uses resolve_overlay_op + generic_op (additive); this remains for unit
|
||||
tests and any caller wanting the legacy "one file wins" view. HC2 gate still applies."""
|
||||
return resolve_overlay_op(recipe, op, repo_local_dir) or generic_op(op)
|
||||
|
||||
|
||||
def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
|
||||
"""All non-lifecycle test_*.py from cc-ci's tests/<recipe>/ and the recipe's repo-local tests/.
|
||||
These have no generic equivalent and run only when present (opt-in), additively from both."""
|
||||
"""All non-lifecycle test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
|
||||
repo-local tests/. These have no generic equivalent and run only when present (opt-in), additively
|
||||
from both. Repo-local is consulted only for allowlist-approved recipes (HC2)."""
|
||||
lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
|
||||
found: list[tuple[str, str]] = []
|
||||
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", repo_local_dir)):
|
||||
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
|
||||
if not d or not os.path.isdir(d):
|
||||
continue
|
||||
for p in sorted(glob.glob(os.path.join(d, "test_*.py"))):
|
||||
@ -60,12 +115,40 @@ def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str
|
||||
|
||||
|
||||
def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None:
|
||||
"""The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci."""
|
||||
if repo_local_dir:
|
||||
p = os.path.join(repo_local_dir, "install_steps.sh")
|
||||
"""The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci.
|
||||
Repo-local is consulted only for allowlist-approved recipes (HC2)."""
|
||||
rl = _gated(recipe, repo_local_dir)
|
||||
if rl:
|
||||
p = os.path.join(rl, "install_steps.sh")
|
||||
if os.path.isfile(p):
|
||||
return ("repo-local", p)
|
||||
p = os.path.join(cc_ci_dir(recipe), "install_steps.sh")
|
||||
if os.path.isfile(p):
|
||||
return ("cc-ci", p)
|
||||
return None
|
||||
|
||||
|
||||
def pre_op_hook(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
|
||||
"""The pre-op seed hook for `op`: the path to a recipe `ops.py` module that defines a
|
||||
`pre_<op>(domain, meta)` callable, or None. cc-ci's tests/<recipe>/ops.py wins; the repo-local
|
||||
ops.py is consulted only for allowlist-approved recipes (HC2). The orchestrator imports the
|
||||
module and calls pre_<op> BEFORE performing the op (HC3 op/assertion split — overlays seed
|
||||
pre-op state here, then assert post-op in test_<op>.py)."""
|
||||
fn = f"pre_{op}"
|
||||
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
|
||||
if not d:
|
||||
continue
|
||||
p = os.path.join(d, "ops.py")
|
||||
if os.path.isfile(p) and _module_defines(p, fn):
|
||||
return (source, p)
|
||||
return None
|
||||
|
||||
|
||||
def _module_defines(path: str, name: str) -> bool:
|
||||
"""Cheap source scan for a top-level `def <name>(` — avoids importing the module just to check."""
|
||||
try:
|
||||
with open(path) as fh:
|
||||
src = fh.read()
|
||||
except OSError:
|
||||
return False
|
||||
return f"def {name}(" in src
|
||||
|
||||
Reference in New Issue
Block a user