update doc and optimize
This commit is contained in:
@@ -78,6 +78,23 @@ run "phase-h4-multilingual" bash "$TESTS/phase-h4-multilingual-tests.sh"
|
||||
run "phase-c6-sandbox" bash "$TESTS/phase-c6-sandbox-tests.sh"
|
||||
run "phase-h4-split-inject" bash "$TESTS/phase-h4-split-inject-tests.sh"
|
||||
run "phase10-traceability" bash "$TESTS/phase10-traceability-tests.sh"
|
||||
run "phase08-compression" bash "$TESTS/phase08-compression-tests.sh"
|
||||
run "phase-control-plane" bash "$TESTS/phase-control-plane-tests.sh"
|
||||
run "phase-rbac" bash "$TESTS/phase-rbac-tests.sh"
|
||||
run "phase-rai" bash "$TESTS/phase-rai-tests.sh"
|
||||
run "phase-selfimprove" bash "$TESTS/phase-selfimprove-tests.sh"
|
||||
run "phase-governance-report" bash "$TESTS/phase-governance-report-tests.sh"
|
||||
run "phase-preflight" bash "$TESTS/phase-preflight-tests.sh"
|
||||
|
||||
if [[ "${CASAN_CI_RUN_BACKEND:-1}" == "1" ]]; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
run "backend-tests" npm test -w backend
|
||||
else
|
||||
skip "backend-tests (npm unavailable)"
|
||||
fi
|
||||
else
|
||||
skip "backend-tests (CASAN_CI_RUN_BACKEND=0)"
|
||||
fi
|
||||
|
||||
if [[ "${CASAN_CI_RUN_FRONTEND:-1}" == "1" ]]; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN-native token-killer (Plan-08 Track 3).
|
||||
|
||||
A deterministic tool-output compressor written for CASAN — NOT a wrapper around
|
||||
RTK. It reduces token count of long command/tool output before it enters model
|
||||
context, while (a) always preserving must-keep lines, (b) never compressing on
|
||||
failure (raw passthrough for debugging, RTK-style tee), and (c) reporting the
|
||||
token savings for H6 telemetry.
|
||||
|
||||
Modes:
|
||||
dedup collapse consecutive duplicate lines with an (xN) counter
|
||||
extractive keep only important lines (errors/failures/warnings) + must-keep
|
||||
structural dedup + keep summary/important/must-keep lines (for test/log output)
|
||||
|
||||
Governance note: this runs AFTER `H4 scan raw` + `H5 hash raw` and BEFORE
|
||||
`H4 scan compressed` in the Plan-08 pipeline; it is deterministic and needs no
|
||||
model, so it cannot be used as a path to evade H4.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def compression_enabled() -> bool:
|
||||
"""Read the effective `compression.enabled` from the control-plane settings
|
||||
store (the harness-owned governed settings). Absent/invalid ⇒ enabled (default).
|
||||
This is how a Control Plane setting change actually governs the harness."""
|
||||
store_file = os.environ.get(
|
||||
"CASAN_CP_STORE_FILE",
|
||||
os.path.join(
|
||||
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")),
|
||||
".specify/level5/control-plane-settings.json",
|
||||
),
|
||||
)
|
||||
if not os.path.isfile(store_file):
|
||||
return True
|
||||
try:
|
||||
data = json.load(open(store_file, encoding="utf-8"))
|
||||
setting = data.get("settings", {}).get("compression.enabled")
|
||||
return True if setting is None else bool(setting["value"])
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
IMPORTANT_RE = re.compile(
|
||||
r"\b(error|errors|fail|failed|failure|failing|exception|panic|denied|blocked|warn|warning)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SUMMARY_RE = re.compile(r"\b(\d+)\s+(pass|passed|fail|failed|tests?|errors?|warnings?)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
return len(text.split())
|
||||
|
||||
|
||||
def load_patterns(path: str):
|
||||
if not path:
|
||||
return []
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return [line.strip() for line in fh if line.strip()]
|
||||
|
||||
|
||||
def is_must_keep(line: str, patterns) -> bool:
|
||||
return any(re.search(p, line) for p in patterns)
|
||||
|
||||
|
||||
def dedup(lines):
|
||||
out = []
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
j = i
|
||||
while j + 1 < n and lines[j + 1] == lines[i]:
|
||||
j += 1
|
||||
count = j - i + 1
|
||||
out.append(lines[i] if count == 1 else f"{lines[i]} (x{count})")
|
||||
i = j + 1
|
||||
return out
|
||||
|
||||
|
||||
def compress(text: str, mode: str, must):
|
||||
lines = text.split("\n")
|
||||
if mode == "dedup":
|
||||
return dedup(lines)
|
||||
if mode == "extractive":
|
||||
return [ln for ln in lines if IMPORTANT_RE.search(ln) or is_must_keep(ln, must)]
|
||||
if mode == "structural":
|
||||
kept = [
|
||||
ln
|
||||
for ln in lines
|
||||
if IMPORTANT_RE.search(ln) or SUMMARY_RE.search(ln) or is_must_keep(ln, must)
|
||||
]
|
||||
return dedup(kept)
|
||||
raise ValueError(f"unknown mode: {mode}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--mode", choices=["dedup", "extractive", "structural"], default="structural")
|
||||
ap.add_argument("--input", default="-", help="input file or - for stdin")
|
||||
ap.add_argument("--must-keep-file", default="", help="file with one must-keep regex per line")
|
||||
ap.add_argument("--failed", action="store_true", help="raw passthrough (tee) when the command failed")
|
||||
ap.add_argument(
|
||||
"--respect-policy",
|
||||
action="store_true",
|
||||
help="honor control-plane `compression.enabled`; if disabled, pass raw through",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--require-must-keep-file",
|
||||
default="",
|
||||
help="verify every pattern in this file still appears; exit 1 (gate) if any is missing",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
must = load_patterns(args.must_keep_file)
|
||||
|
||||
if args.failed:
|
||||
# RTK-style tee: never compress failing output; keep raw for debugging.
|
||||
out_text = raw
|
||||
mode_used = "passthrough"
|
||||
elif args.respect_policy and not compression_enabled():
|
||||
# Control-plane setting governs the harness: compression disabled ⇒ raw.
|
||||
out_text = raw
|
||||
mode_used = "policy-disabled"
|
||||
else:
|
||||
out_lines = compress(raw, args.mode, must)
|
||||
out_text = "\n".join(out_lines)
|
||||
mode_used = args.mode
|
||||
|
||||
in_tokens = estimate_tokens(raw)
|
||||
out_tokens = estimate_tokens(out_text)
|
||||
saved = in_tokens - out_tokens
|
||||
ratio = round(out_tokens / in_tokens, 4) if in_tokens else 1.0
|
||||
|
||||
verify_patterns = load_patterns(args.require_must_keep_file)
|
||||
missing = [p for p in verify_patterns if not re.search(p, out_text)]
|
||||
|
||||
sys.stdout.write(out_text)
|
||||
if not out_text.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
print(
|
||||
f"COMPRESS mode={mode_used} in_tokens={in_tokens} out_tokens={out_tokens} "
|
||||
f"saved={saved} ratio={ratio} must_keep_missing={len(missing)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if missing:
|
||||
print(f"COMPRESS_MUST_KEEP_DROPPED {','.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Control Plane — governed settings store (Plan-13 core, harness-owned).
|
||||
|
||||
This is the REUSABLE governance asset: it lives in the core harness, not in any
|
||||
generated app (e.g. OKR). Every write is (1) deny-by-default (only whitelisted
|
||||
keys), (2) approval-gated for security-sensitive keys, (3) versioned, and (4)
|
||||
recorded in a hash-linked audit chain so tampering is detectable.
|
||||
|
||||
The standalone Control Plane web app (control-plane/) calls this CLI for all
|
||||
writes so the governance logic exists exactly once — in the harness.
|
||||
|
||||
Store file: $CASAN_CP_STORE_FILE (default .specify/level5/control-plane-settings.json)
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
SETTINGS_POLICY = {
|
||||
"compression.enabled": {"securitySensitive": False, "description": "Toggle context/token compression"},
|
||||
"compression.mode": {"securitySensitive": False, "description": "extractive | structural | semantic-dedup | abstractive"},
|
||||
"cost.absolute_cap_usd": {"securitySensitive": False, "description": "Absolute per-call cost cap"},
|
||||
"model.primary": {"securitySensitive": False, "description": "Primary model spec (e.g. ollama:ornith:9b)"},
|
||||
"security.strict": {"securitySensitive": True, "description": "H4 fail-closed strict mode"},
|
||||
"kill_switch.global": {"securitySensitive": True, "description": "Global kill-switch engage/disengage"},
|
||||
}
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def project_root() -> str:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
|
||||
def store_path() -> str:
|
||||
return os.environ.get(
|
||||
"CASAN_CP_STORE_FILE",
|
||||
os.path.join(project_root(), ".specify/level5/control-plane-settings.json"),
|
||||
)
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def empty_store():
|
||||
return {"settings": {}, "history": {}, "audit": []}
|
||||
|
||||
|
||||
def load_store():
|
||||
path = store_path()
|
||||
if not os.path.isfile(path):
|
||||
return empty_store()
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return {"settings": data.get("settings", {}), "history": data.get("history", {}), "audit": data.get("audit", [])}
|
||||
|
||||
|
||||
def save_store(store) -> None:
|
||||
path = store_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(store, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def hash_entry(entry) -> str:
|
||||
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def append_audit(store, base) -> None:
|
||||
prev = store["audit"][-1] if store["audit"] else None
|
||||
prev_hash = prev["hash"] if prev else GENESIS_HASH
|
||||
seq = len(store["audit"]) + 1
|
||||
without_hash = {"seq": seq, **base, "prevHash": prev_hash}
|
||||
store["audit"].append({**without_hash, "hash": hash_entry(without_hash)})
|
||||
|
||||
|
||||
def do_set(key, value, actor, reason, approval):
|
||||
policy = SETTINGS_POLICY.get(key)
|
||||
if policy is None:
|
||||
print(f"SETTING_NOT_ALLOWED {key}", file=sys.stderr)
|
||||
return 2
|
||||
if policy["securitySensitive"] and not (approval or "").strip():
|
||||
print(f"APPROVAL_REQUIRED {key}", file=sys.stderr)
|
||||
return 3
|
||||
store = load_store()
|
||||
prev = store["settings"].get(key)
|
||||
if prev is not None:
|
||||
store["history"].setdefault(key, []).append(prev)
|
||||
nxt = {
|
||||
"value": value,
|
||||
"version": (prev["version"] if prev else 0) + 1,
|
||||
"updatedAt": now_iso(),
|
||||
"actor": actor,
|
||||
"reason": reason,
|
||||
}
|
||||
store["settings"][key] = nxt
|
||||
append_audit(store, {
|
||||
"key": key, "action": "set", "value": value,
|
||||
"prevValue": prev["value"] if prev else None,
|
||||
"actor": actor, "reason": reason, "at": nxt["updatedAt"],
|
||||
})
|
||||
save_store(store)
|
||||
print(json.dumps(nxt, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def do_rollback(key, actor, reason):
|
||||
store = load_store()
|
||||
history = store["history"].get(key, [])
|
||||
if not history:
|
||||
print(f"NO_PRIOR_VERSION {key}", file=sys.stderr)
|
||||
return 4
|
||||
previous = history.pop()
|
||||
current = store["settings"].get(key)
|
||||
restored = {
|
||||
"value": previous["value"],
|
||||
"version": ((current["version"] if current else previous["version"]) + 1),
|
||||
"updatedAt": now_iso(),
|
||||
"actor": actor,
|
||||
"reason": reason,
|
||||
}
|
||||
store["settings"][key] = restored
|
||||
append_audit(store, {
|
||||
"key": key, "action": "rollback", "value": previous["value"],
|
||||
"prevValue": current["value"] if current else None,
|
||||
"actor": actor, "reason": reason, "at": restored["updatedAt"],
|
||||
})
|
||||
save_store(store)
|
||||
print(json.dumps(restored, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def verify_audit():
|
||||
store = load_store()
|
||||
prev_hash = GENESIS_HASH
|
||||
for entry in store["audit"]:
|
||||
rest = {k: v for k, v in entry.items() if k != "hash"}
|
||||
if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]:
|
||||
return {"ok": False, "brokenAt": entry["seq"]}
|
||||
prev_hash = entry["hash"]
|
||||
return {"ok": True, "brokenAt": None}
|
||||
|
||||
|
||||
def parse_value(raw):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("list-policy")
|
||||
sub.add_parser("get-all")
|
||||
g = sub.add_parser("get"); g.add_argument("key")
|
||||
s = sub.add_parser("set")
|
||||
s.add_argument("key"); s.add_argument("value")
|
||||
s.add_argument("--actor", required=True); s.add_argument("--reason", required=True); s.add_argument("--approval", default="")
|
||||
r = sub.add_parser("rollback")
|
||||
r.add_argument("key"); r.add_argument("--actor", required=True); r.add_argument("--reason", required=True)
|
||||
sub.add_parser("get-audit")
|
||||
sub.add_parser("verify-audit")
|
||||
e = sub.add_parser("effective")
|
||||
e.add_argument("key")
|
||||
e.add_argument("--default", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "list-policy":
|
||||
print(json.dumps(SETTINGS_POLICY, ensure_ascii=False)); return 0
|
||||
if args.cmd == "get-all":
|
||||
print(json.dumps(load_store()["settings"], ensure_ascii=False)); return 0
|
||||
if args.cmd == "get":
|
||||
print(json.dumps(load_store()["settings"].get(args.key), ensure_ascii=False)); return 0
|
||||
if args.cmd == "set":
|
||||
return do_set(args.key, parse_value(args.value), args.actor, args.reason, args.approval)
|
||||
if args.cmd == "rollback":
|
||||
return do_rollback(args.key, args.actor, args.reason)
|
||||
if args.cmd == "get-audit":
|
||||
print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0
|
||||
if args.cmd == "verify-audit":
|
||||
verdict = verify_audit()
|
||||
print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']}")
|
||||
return 0 if verdict["ok"] else 1
|
||||
if args.cmd == "effective":
|
||||
# Single read path for "effective setting": store value if set, else default.
|
||||
current = load_store()["settings"].get(args.key)
|
||||
if current is None:
|
||||
print(args.default)
|
||||
else:
|
||||
value = current["value"]
|
||||
print(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False))
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN unified governance evidence report (Plan-09 tie-in, harness-owned).
|
||||
|
||||
Aggregates the harness governance controls into ONE certified-run artifact so a
|
||||
reviewer can verify the whole posture from a single file instead of scattered
|
||||
outputs. Deterministic and offline — it composes existing harness cores:
|
||||
|
||||
- Traceability : traceability-matrix.py (REQ→code→test, incl. symbol/line)
|
||||
- Control Plane : control-plane-settings.py verify-audit (audit hash-chain)
|
||||
- RBAC : rbac-check.py list-roles (policy present)
|
||||
- RAI/Data Gov : rai-guard.py presence + optional model-cards
|
||||
- Self-improve : self-improve.py presence
|
||||
|
||||
A run is CERTIFIED only when: traceability has 0 failing FRs AND the control-plane
|
||||
audit chain is intact. `--gate` exits non-zero when not certified (no false
|
||||
certification).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def here() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
def traceability_summary(out_dir):
|
||||
script = os.path.join(here(), "traceability-matrix.py")
|
||||
out = os.path.join(out_dir, "traceability-matrix.json")
|
||||
res = run([sys.executable, script, "--out", out, "--gate"])
|
||||
summary = {"available": os.path.isfile(out), "gate_pass": res.returncode == 0}
|
||||
if summary["available"]:
|
||||
try:
|
||||
data = json.load(open(out, encoding="utf-8"))
|
||||
summary.update(data.get("summary", {}))
|
||||
except ValueError:
|
||||
pass
|
||||
return summary
|
||||
|
||||
|
||||
def audit_integrity():
|
||||
script = os.path.join(here(), "control-plane-settings.py")
|
||||
res = run([sys.executable, script, "verify-audit"])
|
||||
return {"ok": res.returncode == 0, "detail": (res.stdout or res.stderr).strip()}
|
||||
|
||||
|
||||
def rbac_present():
|
||||
script = os.path.join(here(), "rbac-check.py")
|
||||
res = run([sys.executable, script, "list-roles"])
|
||||
return {"available": res.returncode == 0, "roles": len([l for l in res.stdout.splitlines() if l.strip()])}
|
||||
|
||||
|
||||
def control_present(name):
|
||||
return os.path.isfile(os.path.join(here(), name))
|
||||
|
||||
|
||||
def build_report(out_dir):
|
||||
trace = traceability_summary(out_dir)
|
||||
audit = audit_integrity()
|
||||
rbac = rbac_present()
|
||||
controls = {
|
||||
"traceability": control_present("traceability-matrix.py"),
|
||||
"control_plane_settings": control_present("control-plane-settings.py"),
|
||||
"rbac": control_present("rbac-check.py"),
|
||||
"rai_data_governance": control_present("rai-guard.py"),
|
||||
"self_improve": control_present("self-improve.py"),
|
||||
"compression": control_present("context-compress.py"),
|
||||
}
|
||||
certified = (
|
||||
trace.get("gate_pass", False)
|
||||
and int(trace.get("failed", 1)) == 0
|
||||
and audit["ok"]
|
||||
and all(controls.values())
|
||||
)
|
||||
return {
|
||||
"certified": certified,
|
||||
"controls_present": controls,
|
||||
"traceability": trace,
|
||||
"control_plane_audit": audit,
|
||||
"rbac": rbac,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=os.path.join(
|
||||
os.path.abspath(os.path.join(here(), "..", "..", "..")),
|
||||
"docs/output/casan/governance-report.json",
|
||||
))
|
||||
ap.add_argument("--gate", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
out_dir = os.path.dirname(args.out)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
report = build_report(out_dir)
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(report, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
badge = "CERTIFIED" if report["certified"] else "NOT_CERTIFIED"
|
||||
print(
|
||||
f"GOVERNANCE_REPORT badge={badge} traceability_fail={report['traceability'].get('failed')} "
|
||||
f"audit_ok={report['control_plane_audit']['ok']} controls={sum(report['controls_present'].values())}/"
|
||||
f"{len(report['controls_present'])} out={args.out}"
|
||||
)
|
||||
if args.gate and not report["certified"]:
|
||||
print("GOVERNANCE_NOT_CERTIFIED", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN harness preflight (Plan-15/13 enforcement wiring).
|
||||
# Runs governance cores BEFORE a model call. Currently enforces RAI data
|
||||
# governance: a prompt classified PII/confidential must not go to a CLOUD model
|
||||
# without approval. Opt-in via CASAN_PREFLIGHT=1 so existing flows are unchanged.
|
||||
#
|
||||
# Args mirror model-router: <prompt-file> <out-json> [--role R] [--model M]
|
||||
# Env: CASAN_MODEL_APPROVAL (approval token for sending sensitive data to cloud)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROMPT="${1:-}"
|
||||
|
||||
MODEL=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--model) MODEL="${2:-}"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$MODEL" in
|
||||
openai:*|anthropic:*)
|
||||
if [[ -n "$PROMPT" && -f "$PROMPT" ]]; then
|
||||
if ! python3 "$SCRIPT_DIR/rai-guard.py" check-cloud --input "$PROMPT" --target cloud \
|
||||
--approval "${CASAN_MODEL_APPROVAL:-}" >/dev/null 2>&1; then
|
||||
echo "PREFLIGHT_BLOCK rai-check-cloud model=$MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "PREFLIGHT_OK model=${MODEL:-local}"
|
||||
exit 0
|
||||
@@ -9,4 +9,12 @@ set -euo pipefail
|
||||
# and usage logging live in model-call.py.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Opt-in governance preflight (default off ⇒ no change to existing flows).
|
||||
# When enabled, enforces RAI data governance (e.g. PII must not reach a cloud
|
||||
# model without approval) BEFORE the model call.
|
||||
if [[ "${CASAN_PREFLIGHT:-0}" == "1" ]]; then
|
||||
bash "$SCRIPT_DIR/harness-preflight.sh" "$@" >/dev/null || exit $?
|
||||
fi
|
||||
|
||||
exec python "$SCRIPT_DIR/model-call.py" "$@"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Responsible AI & Data Governance guard (Plan-15 core, harness-owned).
|
||||
|
||||
Reusable governance asset in the core harness. Three deterministic controls:
|
||||
classify label text by data sensitivity (PII / confidential / internal / public)
|
||||
check-cloud DENY sending PII/confidential to a cloud model without approval (extends C3)
|
||||
model-card require an approved model card (source/version/role/risks) — block uncarded
|
||||
|
||||
Exit codes: 0 = OK/ALLOW, 1 = DENY/BLOCK. Reasons on stderr.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
PII_PATTERNS = [
|
||||
(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "email"),
|
||||
(r"\b(?:\d[ -]?){13,16}\b", "card-number"),
|
||||
(r"\b\d{3}-\d{2}-\d{4}\b", "ssn"),
|
||||
(r"\b(?:sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,})\b", "secret-token"),
|
||||
(r"(?i)\bpassword\s*[:=]\s*\S+", "password"),
|
||||
(r"(?i)\b(?:\+?\d[\d -]{8,}\d)\b", "phone"),
|
||||
]
|
||||
CONFIDENTIAL_PATTERNS = [
|
||||
(r"(?i)\b(confidential|internal only|top secret|restricted)\b", "marker"),
|
||||
]
|
||||
|
||||
SENSITIVE_LABELS = {"PII", "confidential"}
|
||||
|
||||
|
||||
def classify(text: str):
|
||||
pii = [name for pat, name in PII_PATTERNS if re.search(pat, text)]
|
||||
if pii:
|
||||
return "PII", pii
|
||||
conf = [name for pat, name in CONFIDENTIAL_PATTERNS if re.search(pat, text)]
|
||||
if conf:
|
||||
return "confidential", conf
|
||||
return "internal", []
|
||||
|
||||
|
||||
def read_input(path: str) -> str:
|
||||
return sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
|
||||
|
||||
|
||||
def cmd_classify(args) -> int:
|
||||
label, matches = classify(read_input(args.input))
|
||||
print(f"RAI_CLASSIFY label={label} matches={','.join(matches) if matches else '-'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check_cloud(args) -> int:
|
||||
label, matches = classify(read_input(args.input))
|
||||
if args.target == "cloud" and label in SENSITIVE_LABELS and not (args.approval or "").strip():
|
||||
print(f"RAI_DENY DATA_TO_CLOUD label={label} matches={','.join(matches)}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"RAI_ALLOW target={args.target} label={label}")
|
||||
return 0
|
||||
|
||||
|
||||
REQUIRED_CARD_FIELDS = ["source", "role", "risks"]
|
||||
|
||||
|
||||
def cmd_model_card(args) -> int:
|
||||
try:
|
||||
cards = json.load(open(args.cards, encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
print(f"RAI_DENY MODEL_CARDS_UNREADABLE {args.cards}", file=sys.stderr)
|
||||
return 1
|
||||
card = cards.get(args.model)
|
||||
if card is None:
|
||||
print(f"RAI_DENY MODEL_UNCARDED {args.model}", file=sys.stderr)
|
||||
return 1
|
||||
missing = [f for f in REQUIRED_CARD_FIELDS if not card.get(f)]
|
||||
if "version" not in card and "digest" not in card:
|
||||
missing.append("version|digest")
|
||||
if missing:
|
||||
print(f"RAI_DENY MODEL_CARD_INCOMPLETE {args.model} missing={','.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"RAI_ALLOW MODEL_CARDED {args.model}")
|
||||
return 0
|
||||
|
||||
|
||||
def read_items(path):
|
||||
items = []
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
items.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return items
|
||||
|
||||
|
||||
def cmd_report(args) -> int:
|
||||
"""RAI aggregate: distribution of data sensitivity across a set of items."""
|
||||
dist = {"PII": 0, "confidential": 0, "internal": 0}
|
||||
for item in read_items(args.items):
|
||||
label, _ = classify(str(item.get("text", "")))
|
||||
dist[label] = dist.get(label, 0) + 1
|
||||
print(json.dumps({"distribution": dist, "total": sum(dist.values())}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_retention(args) -> int:
|
||||
"""Data retention: flag items older than the policy; purge writes an audit
|
||||
record. --gate fails when expired items remain un-purged (retention breach)."""
|
||||
now = args.now if args.now is not None else int(time.time())
|
||||
items = read_items(args.items)
|
||||
expired = [i for i in items if (now - int(i.get("created_epoch", now))) / 86400.0 > args.days]
|
||||
purged = 0
|
||||
if args.purge and expired:
|
||||
audit_file = args.audit or os.environ.get("CASAN_RAI_AUDIT", "")
|
||||
if audit_file:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(audit_file)), exist_ok=True)
|
||||
with open(audit_file, "a", encoding="utf-8") as fh:
|
||||
for i in expired:
|
||||
fh.write(json.dumps({"id": i.get("id"), "purged_at": now, "reason": "retention"}, ensure_ascii=False) + "\n")
|
||||
purged = len(expired)
|
||||
remaining = 0 if args.purge else len(expired)
|
||||
print(f"RAI_RETENTION total={len(items)} expired={len(expired)} purged={purged} remaining_expired={remaining}")
|
||||
if args.gate and remaining > 0:
|
||||
print(f"RAI_DENY RETENTION_BREACH expired_unpurged={remaining}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("classify"); c.add_argument("--input", default="-")
|
||||
cc = sub.add_parser("check-cloud")
|
||||
cc.add_argument("--input", default="-"); cc.add_argument("--target", choices=["cloud", "local"], required=True)
|
||||
cc.add_argument("--approval", default="")
|
||||
mc = sub.add_parser("model-card")
|
||||
mc.add_argument("--model", required=True); mc.add_argument("--cards", required=True)
|
||||
rp = sub.add_parser("report"); rp.add_argument("--items", required=True)
|
||||
rt = sub.add_parser("retention")
|
||||
rt.add_argument("--items", required=True)
|
||||
rt.add_argument("--days", type=int, required=True)
|
||||
rt.add_argument("--now", type=int, default=None)
|
||||
rt.add_argument("--purge", action="store_true")
|
||||
rt.add_argument("--audit", default="")
|
||||
rt.add_argument("--gate", action="store_true")
|
||||
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "classify":
|
||||
return cmd_classify(args)
|
||||
if args.cmd == "check-cloud":
|
||||
return cmd_check_cloud(args)
|
||||
if args.cmd == "model-card":
|
||||
return cmd_model_card(args)
|
||||
if args.cmd == "report":
|
||||
return cmd_report(args)
|
||||
if args.cmd == "retention":
|
||||
return cmd_retention(args)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN RBAC — role-based access control decision engine (Plan-14 core, harness-owned).
|
||||
|
||||
Reusable governance asset in the core harness (not in any generated app). The
|
||||
Control Plane web app and any harness action call this to authorize a request.
|
||||
|
||||
Model: role × (resource:action) with scope. deny-by-default + fail-closed:
|
||||
unknown role / action / cross-tenant / sensitive-without-org-admin ⇒ DENY.
|
||||
Separation of Duties (SoD): a proposer cannot approve their own request.
|
||||
|
||||
Exit codes: 0 = ALLOW, 1 = DENY. Reason printed to stderr.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
# scope: "org" (all projects) or "project" (must match the acted-on project)
|
||||
PERMISSIONS = {
|
||||
"org-admin": {"scope": "org", "allow": {"*"}},
|
||||
"project-admin": {"scope": "project", "allow": {"settings:read", "settings:write", "monitoring:read", "audit:read"}},
|
||||
"approver": {"scope": "project", "allow": {"settings:read", "monitoring:read", "approval:grant"}},
|
||||
"operator": {"scope": "project", "allow": {"monitoring:read", "kill_switch:engage"}},
|
||||
"viewer": {"scope": "project", "allow": {"settings:read", "monitoring:read"}},
|
||||
"auditor": {"scope": "org", "allow": {"settings:read", "monitoring:read", "audit:read"}},
|
||||
}
|
||||
|
||||
# Maps an IdP-issued claim (role name / group) to an RBAC role. The IdP (Plan-07
|
||||
# C4) is the identity authority; RBAC only maps a *verified* claim to a role.
|
||||
# deny-by-default: an unmapped claim yields no role.
|
||||
CLAIM_ROLE_MAP = {
|
||||
"casan-org-admin": "org-admin",
|
||||
"casan-project-admin": "project-admin",
|
||||
"casan-approver": "approver",
|
||||
"casan-operator": "operator",
|
||||
"casan-viewer": "viewer",
|
||||
"casan-auditor": "auditor",
|
||||
}
|
||||
|
||||
|
||||
def decide(role, resource, action, role_project, target_project, sensitive):
|
||||
perm = PERMISSIONS.get(role)
|
||||
if perm is None:
|
||||
return False, f"UNKNOWN_ROLE {role}"
|
||||
|
||||
action_key = f"{resource}:{action}"
|
||||
|
||||
# Sensitive settings writes are org-admin only, regardless of other grants.
|
||||
if sensitive and not (resource == "settings" and action == "write"):
|
||||
# sensitivity only meaningful for settings:write
|
||||
pass
|
||||
if sensitive and resource == "settings" and action == "write" and role != "org-admin":
|
||||
return False, f"SENSITIVE_REQUIRES_ORG_ADMIN {action_key}"
|
||||
|
||||
if "*" in perm["allow"]:
|
||||
return True, "ALLOW org-admin"
|
||||
|
||||
if action_key not in perm["allow"]:
|
||||
return False, f"ACTION_NOT_ALLOWED {role} {action_key}"
|
||||
|
||||
if perm["scope"] == "project":
|
||||
if not role_project or not target_project:
|
||||
return False, "PROJECT_SCOPE_REQUIRED"
|
||||
if role_project != target_project:
|
||||
return False, f"CROSS_TENANT {role_project}!={target_project}"
|
||||
|
||||
return True, f"ALLOW {role} {action_key}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("check")
|
||||
c.add_argument("--role", required=True)
|
||||
c.add_argument("--resource", required=True)
|
||||
c.add_argument("--action", required=True)
|
||||
c.add_argument("--role-project", default="")
|
||||
c.add_argument("--target-project", default="")
|
||||
c.add_argument("--sensitive", action="store_true")
|
||||
|
||||
s = sub.add_parser("check-sod")
|
||||
s.add_argument("--proposer", required=True)
|
||||
s.add_argument("--approver", required=True)
|
||||
|
||||
p = sub.add_parser("list-roles")
|
||||
|
||||
m = sub.add_parser("map-claim")
|
||||
m.add_argument("--claim", required=True, help="IdP-issued role/group claim")
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "list-roles":
|
||||
for role, perm in PERMISSIONS.items():
|
||||
print(f"{role} scope={perm['scope']} allow={sorted(perm['allow'])}")
|
||||
return 0
|
||||
|
||||
if args.cmd == "map-claim":
|
||||
role = CLAIM_ROLE_MAP.get(args.claim)
|
||||
if role is None:
|
||||
print(f"RBAC_DENY UNKNOWN_CLAIM {args.claim}", file=sys.stderr)
|
||||
return 1
|
||||
print(role)
|
||||
return 0
|
||||
|
||||
if args.cmd == "check-sod":
|
||||
if args.proposer == args.approver:
|
||||
print(f"RBAC_DENY SOD_SELF_APPROVAL actor={args.approver}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"RBAC_ALLOW SOD_OK proposer={args.proposer} approver={args.approver}")
|
||||
return 0
|
||||
|
||||
allowed, reason = decide(
|
||||
args.role, args.resource, args.action, args.role_project, args.target_project, args.sensitive
|
||||
)
|
||||
if allowed:
|
||||
print(f"RBAC_ALLOW {reason}")
|
||||
return 0
|
||||
print(f"RBAC_DENY {reason}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN self-improve core (Plan-04, harness-owned).
|
||||
|
||||
Reads real telemetry (provider-usage / metrics JSONL + optional drift report) and
|
||||
emits improvement PROPOSALS (dry-run, never writes). Applying a proposal requires
|
||||
human approval (`--approval`) and goes through the governed settings store
|
||||
(control-plane-settings.py) so every change is audited. Loosen / security-sensitive
|
||||
proposals always require approval.
|
||||
|
||||
Subcommands:
|
||||
propose --metrics <jsonl> [--drift <json>] -> proposals JSON on stdout (no writes)
|
||||
apply --proposals <json> --id <ID> [--approval <tok>] -> governed set (needs approval)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def read_jsonl(path):
|
||||
rows = []
|
||||
if not path or not os.path.isfile(path):
|
||||
return rows
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def build_proposals(metrics_rows, drift):
|
||||
proposals = []
|
||||
costs = [r["cost_usd"] for r in metrics_rows if isinstance(r.get("cost_usd"), (int, float))]
|
||||
if costs:
|
||||
cap = round(max(costs) * 1.5, 4)
|
||||
proposals.append({
|
||||
"id": "P-COST-CAP",
|
||||
"type": "calibrate_cost_cap",
|
||||
"key": "cost.absolute_cap_usd",
|
||||
"value": cap,
|
||||
"direction": "tighten",
|
||||
"security_sensitive": False,
|
||||
"reason": f"observed max cost {max(costs)}; set cap to 1.5x = {cap}",
|
||||
})
|
||||
if drift and (drift.get("drift") is True or drift.get("entries")):
|
||||
proposals.append({
|
||||
"id": "P-GOLDEN",
|
||||
"type": "update_golden",
|
||||
"key": None,
|
||||
"value": None,
|
||||
"direction": "loosen",
|
||||
"security_sensitive": True,
|
||||
"reason": "drift detected; updating golden may hide real regressions — needs review",
|
||||
})
|
||||
return proposals
|
||||
|
||||
|
||||
def cmd_propose(args):
|
||||
metrics = read_jsonl(args.metrics)
|
||||
drift = None
|
||||
if args.drift and os.path.isfile(args.drift):
|
||||
try:
|
||||
drift = json.load(open(args.drift, encoding="utf-8"))
|
||||
except ValueError:
|
||||
drift = None
|
||||
proposals = build_proposals(metrics, drift)
|
||||
print(json.dumps({"proposals": proposals, "count": len(proposals)}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_apply(args):
|
||||
try:
|
||||
data = json.load(open(args.proposals, encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
print(f"IMPROVE_DENY PROPOSALS_UNREADABLE {args.proposals}", file=sys.stderr)
|
||||
return 1
|
||||
proposal = next((p for p in data.get("proposals", []) if p.get("id") == args.id), None)
|
||||
if proposal is None:
|
||||
print(f"IMPROVE_DENY UNKNOWN_PROPOSAL {args.id}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Proposal != application: applying ALWAYS requires human approval (Plan-04).
|
||||
if not (args.approval or "").strip():
|
||||
print(f"IMPROVE_DENY APPROVAL_REQUIRED {args.id}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if proposal.get("key") is None:
|
||||
# Non-settings proposal (e.g. update_golden) — record intent, no auto-apply.
|
||||
print(f"IMPROVE_MANUAL {args.id} type={proposal.get('type')} (no auto-apply; do it under review)")
|
||||
return 0
|
||||
|
||||
cps = os.path.join(os.path.dirname(__file__), "control-plane-settings.py")
|
||||
cmd = [
|
||||
sys.executable, cps, "set", proposal["key"], json.dumps(proposal["value"]),
|
||||
"--actor", "casan-improve", "--reason", f"auto-improve {args.id}",
|
||||
]
|
||||
if proposal.get("security_sensitive"):
|
||||
cmd += ["--approval", args.approval]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"IMPROVE_DENY GOVERNED_SET_FAILED {result.stderr.strip()}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"IMPROVE_APPLIED {args.id} key={proposal['key']} value={proposal['value']}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
pr = sub.add_parser("propose")
|
||||
pr.add_argument("--metrics", default="")
|
||||
pr.add_argument("--drift", default="")
|
||||
ap_ = sub.add_parser("apply")
|
||||
ap_.add_argument("--proposals", required=True)
|
||||
ap_.add_argument("--id", required=True)
|
||||
ap_.add_argument("--approval", default="")
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "propose":
|
||||
return cmd_propose(args)
|
||||
if args.cmd == "apply":
|
||||
return cmd_apply(args)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -32,14 +32,71 @@ def parse_requirements(path: str):
|
||||
return [seen[k] for k in sorted(seen)]
|
||||
|
||||
|
||||
def existing_files(root: str, values):
|
||||
def normalize_entry(value):
|
||||
"""Accept either a plain path string or an object with symbol/line refs.
|
||||
|
||||
Backward compatible: a bare string behaves exactly as file-level tracing.
|
||||
Object form: {"file": "path", "symbols": ["name", ...], "lines": [n, ...]}.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value, [], []
|
||||
if isinstance(value, dict):
|
||||
return value.get("file", ""), list(value.get("symbols", [])), list(value.get("lines", []))
|
||||
return "", [], []
|
||||
|
||||
|
||||
def symbol_present(text: str, symbol: str) -> bool:
|
||||
"""Symbol-level check: the symbol appears as a definition or reference.
|
||||
|
||||
Covers common TS/JS/Python forms: `class X`, `function x`, `x(`, `const x`,
|
||||
`x =`, `x:` (method/property). Deliberately permissive but anchored on word
|
||||
boundaries so a substring alone does not count.
|
||||
"""
|
||||
esc = re.escape(symbol)
|
||||
patterns = [
|
||||
rf"\b(?:function|class|interface|type|enum|const|let|var|def)\s+{esc}\b",
|
||||
rf"\b{esc}\s*[=:(]",
|
||||
]
|
||||
return any(re.search(p, text) for p in patterns)
|
||||
|
||||
|
||||
def resolve_files(root: str, values):
|
||||
"""Resolve file existence plus optional symbol/line coverage.
|
||||
|
||||
Returns present files, missing files, per-file symbol results, and the list
|
||||
of unsatisfied symbol/line references (which make the requirement FAIL).
|
||||
"""
|
||||
present, missing = [], []
|
||||
for rel in values or []:
|
||||
if os.path.isfile(os.path.join(root, rel)):
|
||||
present.append(rel)
|
||||
else:
|
||||
symbol_results = []
|
||||
missing_symbols = []
|
||||
missing_lines = []
|
||||
for value in values or []:
|
||||
rel, symbols, lines = normalize_entry(value)
|
||||
if not rel:
|
||||
continue
|
||||
abs_path = os.path.join(root, rel)
|
||||
if not os.path.isfile(abs_path):
|
||||
missing.append(rel)
|
||||
return present, missing
|
||||
for sym in symbols:
|
||||
missing_symbols.append(f"{rel}#{sym}")
|
||||
for ln in lines:
|
||||
missing_lines.append(f"{rel}:L{ln}")
|
||||
continue
|
||||
present.append(rel)
|
||||
if not symbols and not lines:
|
||||
continue
|
||||
with open(abs_path, encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read()
|
||||
total_lines = text.count("\n") + 1
|
||||
for sym in symbols:
|
||||
found = symbol_present(text, sym)
|
||||
symbol_results.append({"file": rel, "symbol": sym, "found": found})
|
||||
if not found:
|
||||
missing_symbols.append(f"{rel}#{sym}")
|
||||
for ln in lines:
|
||||
if not isinstance(ln, int) or ln < 1 or ln > total_lines:
|
||||
missing_lines.append(f"{rel}:L{ln}")
|
||||
return present, missing, symbol_results, missing_symbols, missing_lines
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -57,11 +114,30 @@ def main() -> int:
|
||||
|
||||
rows = []
|
||||
failures = []
|
||||
total_symbols = 0
|
||||
total_symbols_found = 0
|
||||
for req in reqs:
|
||||
entry = mapping.get(req["id"], {})
|
||||
code, missing_code = existing_files(root, entry.get("code", []))
|
||||
tests, missing_tests = existing_files(root, entry.get("tests", []))
|
||||
status = "PASS" if code and tests and not missing_code and not missing_tests else "FAIL"
|
||||
code, missing_code, code_syms, code_missing_syms, code_missing_lines = resolve_files(
|
||||
root, entry.get("code", [])
|
||||
)
|
||||
tests, missing_tests, test_syms, test_missing_syms, test_missing_lines = resolve_files(
|
||||
root, entry.get("tests", [])
|
||||
)
|
||||
symbol_results = code_syms + test_syms
|
||||
missing_symbols = code_missing_syms + test_missing_syms
|
||||
missing_lines = code_missing_lines + test_missing_lines
|
||||
total_symbols += len(symbol_results)
|
||||
total_symbols_found += sum(1 for s in symbol_results if s["found"])
|
||||
ok = (
|
||||
code
|
||||
and tests
|
||||
and not missing_code
|
||||
and not missing_tests
|
||||
and not missing_symbols
|
||||
and not missing_lines
|
||||
)
|
||||
status = "PASS" if ok else "FAIL"
|
||||
row = {
|
||||
"id": req["id"],
|
||||
"name": req["name"],
|
||||
@@ -70,6 +146,9 @@ def main() -> int:
|
||||
"tests": tests,
|
||||
"missing_code": missing_code,
|
||||
"missing_tests": missing_tests,
|
||||
"symbol_refs": symbol_results,
|
||||
"missing_symbols": missing_symbols,
|
||||
"missing_lines": missing_lines,
|
||||
}
|
||||
rows.append(row)
|
||||
if status != "PASS":
|
||||
@@ -84,6 +163,9 @@ def main() -> int:
|
||||
"requirements": len(reqs),
|
||||
"passed": sum(1 for r in rows if r["status"] == "PASS"),
|
||||
"failed": len(failures),
|
||||
"symbol_refs": total_symbols,
|
||||
"symbols_found": total_symbols_found,
|
||||
"symbols_missing": total_symbols - total_symbols_found,
|
||||
"orphan_mappings": orphan_mappings,
|
||||
},
|
||||
"matrix": rows,
|
||||
@@ -97,14 +179,15 @@ def main() -> int:
|
||||
for row in failures:
|
||||
print(
|
||||
f"TRACEABILITY_FAIL {row['id']} code={len(row['code'])} tests={len(row['tests'])} "
|
||||
f"missing_code={len(row['missing_code'])} missing_tests={len(row['missing_tests'])}",
|
||||
f"missing_code={len(row['missing_code'])} missing_tests={len(row['missing_tests'])} "
|
||||
f"missing_symbols={len(row['missing_symbols'])} missing_lines={len(row['missing_lines'])}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if orphan_mappings:
|
||||
print(f"TRACEABILITY_WARN orphan_mappings={','.join(orphan_mappings)}", file=sys.stderr)
|
||||
print(
|
||||
f"TRACEABILITY_MATRIX requirements={len(reqs)} pass={out['summary']['passed']} "
|
||||
f"fail={len(failures)} out={args.out}"
|
||||
f"fail={len(failures)} symbols={total_symbols_found}/{total_symbols} out={args.out}"
|
||||
)
|
||||
if args.gate and failures:
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user