Files
ss-tools/scripts/check_specs_file_policy.py

134 lines
6.3 KiB
Python

#!/usr/bin/env python3
# #region Tooling.SpecsFilePolicy [C:3] [TYPE Module] [SEMANTICS specs,git,policy,admission]
# @BRIEF Admit changed spec paths from exact committed trees against the immutable policy introduction.
# @INVARIANT Unchanged historical files are untouched; deleted paths are allowed; no worktree bytes establish admission.
# @RATIONALE Existing prototypes predate this forward policy; only reviewed schema JSON may be newly changed in specs.
# @REJECTED A full-tree extension ban would silently retire existing tooling; inferred latest/HEAD bases could hide additions.
import argparse
import json
from pathlib import Path, PurePosixPath
import re
import subprocess
POLICY_BASE = "733d632d124ff4c3b264d7ffb21c0089e5f9ddd9"
SCHEMA_PATHS = frozenset({
"specs/037-superset-baseline-engine/contracts/baseline-catalog.schema.json",
"specs/037-superset-baseline-engine/contracts/baseline-pin.schema.json",
"specs/037-superset-baseline-engine/contracts/catalog-revision.schema.json",
"specs/037-superset-baseline-engine/contracts/comparison-types.schema.json",
"specs/037-superset-baseline-engine/contracts/reference-url.schema.json",
"specs/038-dashboard-scenario-model/contracts/agent-evaluation-spec.schema.json",
"specs/038-dashboard-scenario-model/contracts/agent-evaluation.schema.json",
"specs/038-dashboard-scenario-model/contracts/capture-profile.schema.json",
"specs/038-dashboard-scenario-model/contracts/dashboard-test-scenario-v2.schema.json",
"specs/038-dashboard-scenario-model/contracts/dashboard-test-scenario.schema.json",
"specs/038-dashboard-scenario-model/contracts/decision-policy.schema.json",
"specs/044-dashboard-scenario-execution/contracts/result-evidence.schema.json",
"specs/050-mcp-interface/contracts/tool-contracts.schema.json",
})
FORBIDDEN_SUFFIXES = frozenset({".png", ".log", ".json", ".txt", ".py"})
# #region Tooling.SpecsFilePolicy.Refusal [C:1] [TYPE Class]
class PolicyRefusal(ValueError):
def __init__(self, code, paths=()):
self.code, self.paths = code, tuple(paths)
super().__init__(code)
# #endregion Tooling.SpecsFilePolicy.Refusal
# #region Tooling.SpecsFilePolicy.Git [C:2] [TYPE Function]
def git(repo, *arguments):
result = subprocess.run(["git", "-C", str(repo), *arguments], capture_output=True)
if result.returncode:
raise PolicyRefusal("SPEC_POLICY_GIT_OBJECT_UNAVAILABLE")
return result.stdout
# #endregion Tooling.SpecsFilePolicy.Git
# #region Tooling.SpecsFilePolicy.Commits [C:3] [TYPE Function]
# @POST Both identities are exact commit objects and the fixed introduction is an ancestor of the candidate.
def require_commits(repo, base, commit):
if base != POLICY_BASE:
raise PolicyRefusal("SPEC_POLICY_BASE_INVALID")
for value in (base, commit):
if not isinstance(value, str) or re.fullmatch(r"[a-f0-9]{40}", value) is None:
raise PolicyRefusal("SPEC_POLICY_COMMIT_INVALID")
if git(repo, "cat-file", "-t", value).strip() != b"commit":
raise PolicyRefusal("SPEC_POLICY_COMMIT_INVALID")
result = subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor", base, commit], capture_output=True)
if result.returncode:
raise PolicyRefusal("SPEC_POLICY_BASE_NOT_ANCESTOR")
# #endregion Tooling.SpecsFilePolicy.Commits
# #region Tooling.SpecsFilePolicy.Destinations [C:3] [TYPE Function]
# @POST Copies and renames validate their destination, never merely the old allowed path.
def changed_destinations(repo, base, commit):
fields = git(repo, "diff", "--name-status", "-z", "--find-renames", "--find-copies-harder",
"--diff-filter=AMCRT", base, commit, "--", "specs").split(b"\0")
paths, index = [], 0
while fields[index]:
status = fields[index].decode("ascii")
index += 1
if status[0] in "RC":
index += 1
paths.append(fields[index].decode("utf-8"))
index += 1
return paths
# #endregion Tooling.SpecsFilePolicy.Destinations
# #region Tooling.SpecsFilePolicy.Schema [C:3] [TYPE Function]
# @POST Exact allowlisted path also contains a real JSON Schema object, rather than arbitrary fixture data.
def require_schema(repo, commit, path):
mode = git(repo, "ls-tree", commit, "--", path).split(b" ", 1)[0]
if mode != b"100644":
raise PolicyRefusal("SPEC_POLICY_SCHEMA_INVALID", (path,))
try:
value = json.loads(git(repo, "show", f"{commit}:{path}"))
except (ValueError, UnicodeDecodeError):
raise PolicyRefusal("SPEC_POLICY_SCHEMA_INVALID", (path,)) from None
if (not isinstance(value, dict) or not isinstance(value.get("$schema"), str) or not value["$schema"].strip()
or not isinstance(value.get("$id"), str) or not value["$id"].strip()
or not any(isinstance(value.get(key), kind) for key, kind in (
("properties", dict), ("$defs", dict), ("definitions", dict),
("allOf", list), ("oneOf", list), ("anyOf", list)))):
raise PolicyRefusal("SPEC_POLICY_SCHEMA_INVALID", (path,))
# #endregion Tooling.SpecsFilePolicy.Schema
# #region Tooling.SpecsFilePolicy.Check [C:3] [TYPE Function]
def check(repo, base, commit):
require_commits(repo, base, commit)
paths = changed_destinations(repo, base, commit)
for path in paths:
if PurePosixPath(path).suffix.lower() not in FORBIDDEN_SUFFIXES:
continue
if path not in SCHEMA_PATHS:
raise PolicyRefusal("SPEC_POLICY_PATH_FORBIDDEN", (path,))
require_schema(repo, commit, path)
return {"status": "PASS", "base": base, "commit": commit, "changed_spec_paths": len(paths)}
# #endregion Tooling.SpecsFilePolicy.Check
# #region Tooling.SpecsFilePolicy.Main [C:3] [TYPE Function]
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument("--base", required=True)
parser.add_argument("--commit", required=True)
args = parser.parse_args(argv)
try:
result = check(Path(__file__).resolve().parents[1], args.base, args.commit)
except PolicyRefusal as exc:
print(json.dumps({"status": "REFUSED", "error_code": exc.code, "paths": exc.paths}))
return 2
print(json.dumps(result))
return 0
# #endregion Tooling.SpecsFilePolicy.Main
if __name__ == "__main__":
raise SystemExit(main())
# #endregion Tooling.SpecsFilePolicy