@intentsolutions/audit-harness 1.1.6 → 1.1.8

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.
@@ -50,6 +50,22 @@ EXCLUDED_DIRS = {
50
50
  }
51
51
 
52
52
 
53
+ def is_excluded_dir(name: str) -> bool:
54
+ """Single exclusion predicate shared by the candidate-discovery walk and
55
+ the --json input-hash walk.
56
+
57
+ Both walks MUST agree on which directories they descend into; otherwise the
58
+ set of files that feed the CRAP score can diverge from the set that feeds
59
+ the input_hash, and the score/hash desync (a hash that claims to cover
60
+ files the score never saw, or vice versa). The rule is: skip any dot-dir
61
+ (e.g. `.idea`, `.svn`, `.git`) OR any explicitly-named build/vendor dir in
62
+ EXCLUDED_DIRS. Previously discovery dropped all dot-dirs while the hash walk
63
+ dropped only the named subset, so a dot-dir not in EXCLUDED_DIRS was hashed
64
+ but never scored.
65
+ """
66
+ return name.startswith(".") or name in EXCLUDED_DIRS
67
+
68
+
53
69
  def crap(complexity: int, coverage_pct: float) -> float:
54
70
  cov = max(0.0, min(100.0, coverage_pct)) / 100.0
55
71
  return (complexity ** 2) * ((1.0 - cov) ** 3) + complexity
@@ -98,8 +114,7 @@ def score_python(root: Path, kind: str) -> list[MethodScore]:
98
114
  scanned = [
99
115
  p.name for p in root.iterdir()
100
116
  if p.is_dir()
101
- and not p.name.startswith(".")
102
- and p.name not in EXCLUDED_DIRS
117
+ and not is_excluded_dir(p.name)
103
118
  and p.name not in test_dirs
104
119
  and any(p.rglob("*.py"))
105
120
  ]
@@ -165,7 +180,15 @@ def score_go(root: Path, kind: str) -> list[MethodScore]:
165
180
  print("[crap-score] gocyclo not installed", file=sys.stderr)
166
181
  return []
167
182
 
168
- rc, out, _ = run(["gocyclo", "-ignore", "_test.go" if kind == "src" else ".*\\.go$", "."], root)
183
+ # For kind="src", ignore *_test.go at the gocyclo level. For kind="test",
184
+ # do NOT pass -ignore: a pattern like `.*\.go$` matches every analyzable
185
+ # file (gocyclo only reads .go files), which silenced all test-kind output.
186
+ # The include-filter below keeps only *_test.go rows for kind="test".
187
+ gocyclo_cmd = ["gocyclo"]
188
+ if kind == "src":
189
+ gocyclo_cmd += ["-ignore", "_test.go"]
190
+ gocyclo_cmd.append(".")
191
+ rc, out, _ = run(gocyclo_cmd, root)
169
192
  complexity: list[tuple[str, str, int]] = []
170
193
  for line in out.splitlines():
171
194
  parts = line.strip().split()
@@ -187,11 +210,28 @@ def score_go(root: Path, kind: str) -> list[MethodScore]:
187
210
  if not cov_out.is_file() and which_or_none("go"):
188
211
  run(["go", "test", "-coverprofile=coverage.out", "-covermode=atomic", "./..."], root)
189
212
  if cov_out.is_file() and which_or_none("go"):
213
+ # `go tool cover -func` reports module-qualified paths
214
+ # (github.com/user/repo/pkg/file.go) while gocyclo reports repo-relative
215
+ # paths (pkg/file.go). Strip the module prefix read from go.mod so the
216
+ # coverage keys join the complexity keys.
217
+ module_prefix = ""
218
+ go_mod = root / "go.mod"
219
+ if go_mod.is_file():
220
+ try:
221
+ for mod_line in go_mod.read_text().splitlines():
222
+ mod_line = mod_line.strip()
223
+ if mod_line.startswith("module ") or mod_line.startswith("module\t"):
224
+ module_prefix = mod_line.split(None, 1)[1].strip() + "/"
225
+ break
226
+ except OSError:
227
+ pass
190
228
  rc, out, _ = run(["go", "tool", "cover", "-func=coverage.out"], root)
191
229
  for line in out.splitlines():
192
230
  parts = line.split()
193
231
  if len(parts) >= 3 and parts[-1].endswith("%"):
194
232
  fpath = parts[0].split(":", 1)[0]
233
+ if module_prefix and fpath.startswith(module_prefix):
234
+ fpath = fpath[len(module_prefix):]
195
235
  try:
196
236
  pct = float(parts[-1].rstrip("%"))
197
237
  except ValueError:
@@ -228,6 +268,17 @@ def score_js(root: Path, kind: str) -> list[MethodScore]:
228
268
  except json.JSONDecodeError:
229
269
  return []
230
270
 
271
+ # c8/istanbul's json-summary reporter keys files by ABSOLUTE path while
272
+ # complexity-report (run with a repo-relative target) reports repo-relative
273
+ # paths. Normalize both sides to repo-relative so the coverage join works.
274
+ def _rel_to_root(p: str) -> str:
275
+ if os.path.isabs(p):
276
+ try:
277
+ return os.path.relpath(p, str(root))
278
+ except ValueError:
279
+ return p # e.g. different drive on Windows — keep as-is
280
+ return p
281
+
231
282
  cov_path = root / "coverage" / "coverage-summary.json"
232
283
  coverage: dict[str, float] = {}
233
284
  if cov_path.is_file():
@@ -237,14 +288,14 @@ def score_js(root: Path, kind: str) -> list[MethodScore]:
237
288
  if fpath == "total":
238
289
  continue
239
290
  lines_pct = summary.get("lines", {}).get("pct", 0.0)
240
- coverage[fpath] = float(lines_pct)
291
+ coverage[_rel_to_root(fpath)] = float(lines_pct)
241
292
  except (OSError, json.JSONDecodeError):
242
293
  pass
243
294
 
244
295
  scores: list[MethodScore] = []
245
296
  for report in data.get("reports", []):
246
297
  fpath = report.get("path", "")
247
- cov = coverage.get(fpath, 0.0)
298
+ cov = coverage.get(_rel_to_root(fpath), 0.0)
248
299
  for func in report.get("functions", []):
249
300
  c = int(func.get("cyclomatic", 1))
250
301
  scores.append(
@@ -403,7 +454,7 @@ def main() -> int:
403
454
  exts = (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", ".cs", ".php", ".rb")
404
455
  collected: list[Path] = []
405
456
  for dirpath, dirs, files in os.walk(root):
406
- dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
457
+ dirs[:] = [d for d in dirs if not is_excluded_dir(d)]
407
458
  for fn in files:
408
459
  if fn.endswith(exts):
409
460
  collected.append(Path(dirpath) / fn)
@@ -1,18 +1,26 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- audit-harness currency — advisory upstream-currency report (PP-PLAN-040 Phase 5 / E7).
3
+ audit-harness currency — advisory poll-freshness report (PP-PLAN-040 Phase 5 / E7).
4
4
 
5
5
  Currency depends on upstream state, which is non-deterministic and network-bound, so
6
6
  it is deliberately the WEAKEST kind of check: an advisory REPORT with **no exit-code
7
7
  authority, no auto-fix, and no live-fetch**. It reads the per-upstream-identity pin
8
8
  relation (schemas/currency/pins.v1.json) — where each upstream carries its own
9
- pinned_version + the date it was last verified (checked_at) + a staleness window —
10
- and reports which pins are themselves STALE (checked_at older than the window), i.e.
11
- which pins a human should re-verify against upstream.
9
+ pinned_version + the date it was last verified (checked_at) + an advisory
10
+ poll-freshness SLA — and reports which pins are themselves PAST their SLA
11
+ (checked_at older than the SLA window), i.e. which pins a human should re-verify
12
+ against upstream. The SLA gates NOTHING except human attention.
12
13
 
13
14
  This models the pin's OWN staleness as detectable, rather than one opaque
14
- ".schema-version" scalar. The /sync-testing-harness skill consumes this report to
15
- open advisory bump PRs; the report never reddens a build (always exit 0).
15
+ ".schema-version" scalar. Pins are grouped by class (spec-page / schema-file /
16
+ release-feed / internal-contract); SLA resolution order is: explicit per-pin
17
+ staleness_window_days > the pin's class SLA > default_staleness_window_days.
18
+ The /sync-testing-harness skill consumes this report to open advisory bump PRs;
19
+ the report never reddens a build (always exit 0).
20
+
21
+ Follow-up (deliberately NOT wired here, [9k5h.10]): the intent-eval-lab
22
+ detector-health surface will consume the --json output; that cross-repo
23
+ integration is tracked separately.
16
24
 
17
25
  Stdlib only. No network. No filesystem mutation.
18
26
  """
@@ -25,6 +33,8 @@ from datetime import datetime, timezone
25
33
  HERE = os.path.dirname(os.path.abspath(__file__))
26
34
  DEFAULT_PINS = os.path.join(HERE, "..", "schemas", "currency", "pins.v1.json")
27
35
 
36
+ UNCLASSED = "(unclassed)"
37
+
28
38
 
29
39
  def parse_date(s):
30
40
  try:
@@ -33,12 +43,23 @@ def parse_date(s):
33
43
  return None
34
44
 
35
45
 
46
+ def resolve_window(pin, classes, default_window):
47
+ """SLA resolution: explicit per-pin window > class SLA > file default."""
48
+ if pin.get("staleness_window_days") is not None:
49
+ return pin["staleness_window_days"]
50
+ cls = classes.get(pin.get("class") or "", {})
51
+ if cls.get("staleness_window_days") is not None:
52
+ return cls["staleness_window_days"]
53
+ return default_window
54
+
55
+
36
56
  def build_report(pins_doc, today):
37
57
  default_window = pins_doc.get("default_staleness_window_days", 90)
58
+ classes = pins_doc.get("staleness_classes", {})
38
59
  out = []
39
60
  for pin in pins_doc.get("pins", []):
40
61
  checked = parse_date(pin.get("checked_at", ""))
41
- window = pin.get("staleness_window_days", default_window)
62
+ window = resolve_window(pin, classes, default_window)
42
63
  if checked is None:
43
64
  age, status = None, "unknown-checked_at"
44
65
  else:
@@ -46,6 +67,7 @@ def build_report(pins_doc, today):
46
67
  status = "stale" if age > window else "current"
47
68
  out.append({
48
69
  "identity": pin.get("identity"),
70
+ "class": pin.get("class") or UNCLASSED,
49
71
  "pinned_version": pin.get("pinned_version"),
50
72
  "checked_at": pin.get("checked_at"),
51
73
  "age_days": age,
@@ -57,8 +79,18 @@ def build_report(pins_doc, today):
57
79
  return out
58
80
 
59
81
 
82
+ def group_by_class(report):
83
+ """Ordered {class: [rows]} grouping, classes sorted, (unclassed) last."""
84
+ grouped = {}
85
+ for r in report:
86
+ grouped.setdefault(r["class"], []).append(r)
87
+ ordered = sorted(grouped, key=lambda c: (c == UNCLASSED, c))
88
+ return {c: grouped[c] for c in ordered}
89
+
90
+
60
91
  def main():
61
- ap = argparse.ArgumentParser(description="Advisory upstream-currency report (no exit authority)")
92
+ ap = argparse.ArgumentParser(
93
+ description="Advisory poll-freshness report (no exit authority — the SLA gates nothing but human attention)")
62
94
  ap.add_argument("--pins", default=DEFAULT_PINS, help="path to the pin relation datum")
63
95
  ap.add_argument("--json", action="store_true", help="emit JSON report")
64
96
  ap.add_argument("--today", default=None, help="override 'today' (YYYY-MM-DD) for reproducible reports/tests")
@@ -74,39 +106,52 @@ def main():
74
106
 
75
107
  today = parse_date(args.today) if args.today else datetime.now(timezone.utc).date()
76
108
  report = build_report(pins_doc, today)
109
+ grouped = group_by_class(report)
77
110
  stale = [r for r in report if r["status"] == "stale"]
78
111
  unknown = [r for r in report if r["status"] == "unknown-checked_at"]
79
112
 
80
113
  if args.json:
114
+ by_class = {}
115
+ for cls, rows in grouped.items():
116
+ by_class[cls] = {
117
+ "total": len(rows),
118
+ "stale": sum(1 for r in rows if r["status"] == "stale"),
119
+ "current": sum(1 for r in rows if r["status"] == "current"),
120
+ "unknown": sum(1 for r in rows if r["status"] == "unknown-checked_at"),
121
+ }
81
122
  print(json.dumps({
82
123
  "report": "currency/v1",
83
124
  "generated_for": today.strftime("%Y-%m-%d"),
84
125
  "pins": report,
126
+ "by_class": by_class,
85
127
  "stale_count": len(stale),
86
128
  "advisory": True,
87
129
  }, indent=2))
88
130
  else:
89
- print(f"Upstream currency (advisory) — as of {today.strftime('%Y-%m-%d')}")
90
- print(f"{'identity':<24} {'pinned':<14} {'checked_at':<12} {'age':>5} {'win':>4} status")
91
- for r in report:
92
- age = "—" if r["age_days"] is None else str(r["age_days"]) + "d"
93
- if r["status"] == "stale":
94
- mark = " STALE"
95
- elif r["status"] == "current":
96
- mark = "current"
97
- else:
98
- mark = "? " + r["status"]
99
- print(f"{(r['identity'] or ''):<24} {(r['pinned_version'] or ''):<14} "
100
- f"{(r['checked_at'] or ''):<12} {age:>5} {r['window_days']:>4} {mark}")
131
+ print(f"Upstream currency advisory poll-freshness SLA report — as of {today.strftime('%Y-%m-%d')}")
132
+ print(f"{'identity':<26} {'pinned':<18} {'checked_at':<12} {'age':>5} {'sla':>4} status")
133
+ for cls, rows in grouped.items():
134
+ print(f"[{cls}] {len(rows)} pin(s)")
135
+ for r in rows:
136
+ age = "—" if r["age_days"] is None else str(r["age_days"]) + "d"
137
+ if r["status"] == "stale":
138
+ mark = "⚠ PAST SLA"
139
+ elif r["status"] == "current":
140
+ mark = "current"
141
+ else:
142
+ mark = "? " + r["status"]
143
+ print(f" {(r['identity'] or ''):<24} {(r['pinned_version'] or ''):<18} "
144
+ f"{(r['checked_at'] or ''):<12} {age:>5} {r['window_days']:>4} {mark}")
101
145
  print()
102
146
  if stale:
103
- print(f"{len(stale)} pin(s) past their staleness windowre-verify against upstream, "
104
- f"then bump pinned_version + checked_at in schemas/currency/pins.v1.json:")
147
+ print(f"{len(stale)} pin(s) past their poll-freshness SLAthe SLA gates nothing but human "
148
+ f"attention: re-verify against upstream, then bump pinned_version + checked_at in "
149
+ f"schemas/currency/pins.v1.json:")
105
150
  for r in stale:
106
- print(f" - {r['identity']}: last checked {r['checked_at']} "
107
- f"({r['age_days']}d ago > {r['window_days']}d)")
151
+ print(f" - {r['identity']} [{r['class']}]: last checked {r['checked_at']} "
152
+ f"({r['age_days']}d ago > {r['window_days']}d SLA)")
108
153
  else:
109
- print("All pins within their staleness window.")
154
+ print("All pins within their poll-freshness SLA.")
110
155
  if unknown:
111
156
  print(f"{len(unknown)} pin(s) have an unparseable checked_at — fix the date format (YYYY-MM-DD).")
112
157
 
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # dnssec-check.sh — verify a namespace is DNSSEC-signed before a production
3
+ # signed attestation is anchored against it.
4
+ #
5
+ # WHY THIS EXISTS (CISO binding, DR-010 Q5 / ISEDC v1 Q1 2026-05-10):
6
+ # Predicate URIs for the Evidence Bundle live ONLY at evals.intentsolutions.io.
7
+ # Pushing a signed in-toto Statement to a PUBLIC transparency log (Rekor)
8
+ # against an unsigned namespace is irreversible and lets an attacker who can
9
+ # spoof the zone mint look-alike attestations. DNSSEC must be verified on the
10
+ # namespace BEFORE the first production attestation. This script is that gate.
11
+ # It anchors NOTHING — it is a read-only verification that can only make
12
+ # signing MORE conservative (fail-closed).
13
+ #
14
+ # WHY IT QUERIES AN EXPLICIT RESOLVER (the bug this version fixes):
15
+ # Querying the LOCAL STUB RESOLVER (plain `dig`, no `@server`) FALSE-NEGATIVES
16
+ # on hosts whose stub resolver strips DNSSEC records or never sets the AD bit
17
+ # (systemd-resolved, most CI runners, dev boxes behind a caching forwarder).
18
+ # On such a host a correctly DNSSEC-signed zone looks unsigned. For a
19
+ # fail-closed gate that is the WRONG failure mode for usability AND it can
20
+ # block a legitimate production signing while a genuinely-unsigned zone would
21
+ # also block — i.e. it loses all discriminating power. The fix is to query a
22
+ # TRUSTED VALIDATING resolver and require the resolver to assert validation
23
+ # (`delv` full-chain "fully validated", or `dig`'s AD bit + an RRSIG). The
24
+ # gate stays fail-closed: PASS only on positive confirmation from a trusted
25
+ # resolver; UNKNOWN / unreachable / no-tool => non-zero.
26
+ #
27
+ # Usage:
28
+ # bash scripts/dnssec-check.sh [DOMAIN]
29
+ # DNSSEC_CHECK_DOMAIN=evals.intentsolutions.io bash scripts/dnssec-check.sh
30
+ #
31
+ # Resolution order for the domain:
32
+ # 1. $1 (positional)
33
+ # 2. $DNSSEC_CHECK_DOMAIN
34
+ # 3. default: evals.intentsolutions.io
35
+ #
36
+ # Behavior:
37
+ # - Queries each resolver in $DNSSEC_CHECK_RESOLVERS (default 1.1.1.1 8.8.8.8),
38
+ # in order, and PASSES on the FIRST that confirms DNSSEC validation.
39
+ # - For each resolver: prefers `delv @<resolver>` (full DNSSEC chain validation
40
+ # against the IANA trust anchor; "fully validated" => PASS). Falls back to
41
+ # `dig @<resolver> +dnssec` and requires BOTH the AD (Authenticated Data)
42
+ # header flag AND the presence of an RRSIG record (a non-validating answer,
43
+ # i.e. RRSIG but no AD, does NOT pass — a malicious/forwarding resolver that
44
+ # returns records without validating the chain cannot trivially pass).
45
+ # - If NO resolver confirms validation (every resolver says unsigned, or is
46
+ # unreachable), exits 1 (fail-closed).
47
+ # - If NEITHER delv NOR dig is installed, emits a typed UNKNOWN/UNREACHABLE
48
+ # result and exits 2 (fail-closed for production).
49
+ #
50
+ # Exit codes:
51
+ # 0 — DNSSEC verified (a trusted resolver fully validated, or set AD + RRSIG)
52
+ # 1 — DNSSEC NOT verified (no trusted resolver confirmed; zone unsigned /
53
+ # validation failed / all resolvers unreachable)
54
+ # 2 — UNKNOWN/UNREACHABLE (no resolver tool installed at all)
55
+ #
56
+ # Override knobs:
57
+ # DNSSEC_CHECK_RESOLVERS — space-separated list of validating/public resolvers
58
+ # to query in order (default: "1.1.1.1 8.8.8.8").
59
+ # DNSSEC_CHECK_DELV_CMD — command used in place of `delv` (default: delv)
60
+ # DNSSEC_CHECK_DIG_CMD — command used in place of `dig` (default: dig)
61
+
62
+ set -euo pipefail
63
+
64
+ DOMAIN="${1:-${DNSSEC_CHECK_DOMAIN:-evals.intentsolutions.io}}"
65
+ DELV_CMD="${DNSSEC_CHECK_DELV_CMD:-delv}"
66
+ DIG_CMD="${DNSSEC_CHECK_DIG_CMD:-dig}"
67
+ # Trusted validating/public resolvers, queried in order. Cloudflare (1.1.1.1)
68
+ # and Google (8.8.8.8) both perform DNSSEC validation and set the AD bit.
69
+ RESOLVERS="${DNSSEC_CHECK_RESOLVERS:-1.1.1.1 8.8.8.8}"
70
+
71
+ log() { printf 'dnssec-check: %s\n' "$1" >&2; }
72
+
73
+ if [[ "$DOMAIN" == "-h" || "$DOMAIN" == "--help" ]]; then
74
+ sed -n '2,60p' "$0"
75
+ exit 0
76
+ fi
77
+
78
+ have() { command -v "$1" >/dev/null 2>&1; }
79
+
80
+ have_delv=0
81
+ have_dig=0
82
+ have "$DELV_CMD" && have_delv=1
83
+ have "$DIG_CMD" && have_dig=1
84
+
85
+ # --- No resolver tool at all -> typed UNKNOWN, fail-closed (exit 2) ---
86
+ if [[ "$have_delv" -eq 0 && "$have_dig" -eq 0 ]]; then
87
+ log "UNKNOWN/UNREACHABLE — neither '$DELV_CMD' nor '$DIG_CMD' is installed"
88
+ log " cannot verify DNSSEC for '$DOMAIN'; failing closed (production must not sign on UNKNOWN)"
89
+ log " remediation: install bind9-dnsutils (provides dig + delv) on the signing host"
90
+ exit 2
91
+ fi
92
+
93
+ # delv_validates RESOLVER -> 0 if delv reports the chain fully validated.
94
+ delv_validates() {
95
+ local resolver="$1" out
96
+ # delv prints "; fully validated" on each validated RRset when the chain of
97
+ # trust holds; "; unsigned answer" / "resolution failed" otherwise. delv
98
+ # validates LOCALLY against the IANA trust anchor regardless of which resolver
99
+ # serves the records, so a non-validating @resolver cannot fake a pass.
100
+ out="$("$DELV_CMD" "$DOMAIN" "@$resolver" 2>&1 || true)"
101
+ printf '%s\n' "$out" | grep -q "fully validated"
102
+ }
103
+
104
+ # dig_validates RESOLVER -> 0 if the resolver set the AD bit AND an RRSIG is
105
+ # present. BOTH are required: AD alone could be spoofed by a lying resolver
106
+ # without signatures, RRSIG alone proves the zone publishes signatures but not
107
+ # that the chain validated. Requiring AD means a non-validating resolver's
108
+ # answer (RRSIG copied through, AD never set) does NOT pass.
109
+ dig_validates() {
110
+ local resolver="$1" out ad_flag=0 rrsig=0
111
+ out="$("$DIG_CMD" "@$resolver" +dnssec +multiline "$DOMAIN" 2>&1 || true)"
112
+ if printf '%s\n' "$out" | grep -qE '^;; flags:[^;]*\bad\b'; then
113
+ ad_flag=1
114
+ fi
115
+ if printf '%s\n' "$out" | grep -qE '[[:space:]]RRSIG[[:space:]]'; then
116
+ rrsig=1
117
+ fi
118
+ [[ "$ad_flag" -eq 1 && "$rrsig" -eq 1 ]]
119
+ }
120
+
121
+ saw_unsigned=0 # at least one resolver answered, and said NOT validated
122
+
123
+ for resolver in $RESOLVERS; do
124
+ # --- Path 1: delv @resolver (authoritative DNSSEC chain validation) ---
125
+ if [[ "$have_delv" -eq 1 ]]; then
126
+ log "validating DNSSEC for '$DOMAIN' via $DELV_CMD @$resolver"
127
+ if delv_validates "$resolver"; then
128
+ log "VERIFIED — '$DOMAIN' is DNSSEC-signed (delv @$resolver: fully validated)"
129
+ exit 0
130
+ fi
131
+ saw_unsigned=1
132
+ log "delv @$resolver did not confirm validation; trying dig @$resolver"
133
+ fi
134
+
135
+ # --- Path 2: dig @resolver +dnssec (AD bit + RRSIG presence) ---
136
+ if [[ "$have_dig" -eq 1 ]]; then
137
+ log "checking DNSSEC for '$DOMAIN' via $DIG_CMD @$resolver +dnssec"
138
+ if dig_validates "$resolver"; then
139
+ log "VERIFIED — '$DOMAIN' is DNSSEC-signed (dig @$resolver: AD bit set + RRSIG present)"
140
+ exit 0
141
+ fi
142
+ saw_unsigned=1
143
+ log "dig @$resolver did not confirm validation (no AD+RRSIG) for '$DOMAIN'"
144
+ fi
145
+ done
146
+
147
+ # No resolver confirmed validation. Distinguish "answered but unsigned" from
148
+ # "nothing reachable" only for the operator message — both fail-closed (exit 1).
149
+ if [[ "$saw_unsigned" -eq 1 ]]; then
150
+ log "NOT VERIFIED — no trusted resolver confirmed DNSSEC for '$DOMAIN' (zone appears unsigned / chain not validated)"
151
+ log " resolvers tried: $RESOLVERS"
152
+ log " remediation: sign the zone (DNSSEC) at the registrar/DNS host, then re-run"
153
+ else
154
+ log "NOT VERIFIED — could not reach any resolver to validate DNSSEC for '$DOMAIN'"
155
+ log " resolvers tried: $RESOLVERS"
156
+ log " failing closed (production must not sign without positive confirmation)"
157
+ fi
158
+ exit 1
@@ -35,15 +35,28 @@
35
35
  # 1 — input JSON malformed or missing required fields
36
36
  # 2 — signing requested but cosign not available
37
37
  # 3 — Rekor push requested but failed
38
+ # 4 — production DNSSEC/CAA pre-flight FAILED (fail-closed; nothing was signed)
38
39
  #
39
- # CISO gate (per ISEDC v1 Q1, 2026-05-10): pushing to a public transparency log
40
- # (Rekor) against the predicate URI https://evals.intentsolutions.io/gate-result/v1
41
- # is BLOCKED until DNSSEC + CAA records are verified on the namespace. The script
42
- # does NOT enforce this that is operator discipline. See bead `iel-4zr` in
43
- # intent-eval-platform/intent-eval-lab/.beads/.
40
+ # CISO gate (per DR-010 Q5 / ISEDC v1 Q1, 2026-05-10): pushing to a PUBLIC
41
+ # transparency log (Rekor) against the predicate URI
42
+ # https://evals.intentsolutions.io/gate-result/v1 is BLOCKED until DNSSEC + CAA
43
+ # records are verified on the namespace. This script ENFORCES that: when a
44
+ # production Rekor push is requested (--rekor-url / non-empty REKOR_URL), it runs
45
+ # scripts/dnssec-check.sh then scripts/caa-check.sh against the predicate
46
+ # namespace and REFUSES to sign (exit 4) if either fails. The gate is read-only —
47
+ # it anchors nothing and can only make signing MORE conservative.
48
+ #
49
+ # Opt-out (NON-PRODUCTION / staging ONLY): EVIDENCE_SKIP_DNS_PREFLIGHT=1 skips the
50
+ # pre-flight. It is honored ONLY when no production Rekor push is requested; a
51
+ # real Rekor push can NEVER be silently skipped.
44
52
 
45
53
  set -euo pipefail
46
54
 
55
+ # Bash version floor: these gates rely on bash 4+ features. Refuse early with a
56
+ # clear message on bash 3.x (e.g. macOS system bash) instead of failing later
57
+ # with a cryptic syntax error (jcgw).
58
+ [ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
59
+
47
60
  INPUT="-"
48
61
  OUTPUT=""
49
62
  SIGN=0
@@ -54,6 +67,9 @@ RUNNER_VERSION_OVERRIDE=""
54
67
  COMMIT_SHA_OVERRIDE=""
55
68
  PREDICATE_URI="https://evals.intentsolutions.io/gate-result/v1"
56
69
  STATEMENT_TYPE="https://in-toto.io/Statement/v1"
70
+ # The namespace whose DNSSEC + CAA posture gates production attestations. Derived
71
+ # from the predicate URI host; overridable for testing via EVIDENCE_PREDICATE_DOMAIN.
72
+ PREDICATE_DOMAIN="${EVIDENCE_PREDICATE_DOMAIN:-evals.intentsolutions.io}"
57
73
 
58
74
  while [[ $# -gt 0 ]]; do
59
75
  case "$1" in
@@ -181,10 +197,30 @@ fi
181
197
  # OR an OTEL_EXPORTER_OTLP_ENDPOINT is set. Real exporter wiring is consumer-side;
182
198
  # we emit a structured signal that any collector can scrape via stderr capture.
183
199
  if [[ "${AUDIT_HARNESS_OTEL:-0}" == "1" ]] || [[ -n "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ]]; then
184
- GATE_ID=$(echo "$GATE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('gate_id',''))" 2>/dev/null || echo "")
185
- RESULT=$(echo "$GATE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('result',''))" 2>/dev/null || echo "")
186
- printf '[OTEL] {"name":"agent.rollout.gate.evaluated","attributes":{"gate.id":"%s","gate.result":"%s","gate.runner":"%s","gate.commit_sha":"%s"},"timestamp":"%s"}\n' \
187
- "$GATE_ID" "$RESULT" "$RUNNER" "$COMMIT_SHA" "$TIMESTAMP" >&2
200
+ # Compose the JSON via python so every attribute value is JSON-escaped.
201
+ # printf-interpolating gate_id/result/runner into a JSON format string
202
+ # emitted structurally invalid JSON whenever a value carried a double quote
203
+ # (e.g. AUDIT_HARNESS_SIDE='ci"injection' flowing into gate_id).
204
+ OTEL_LINE=$(GATE_JSON="$GATE_JSON" RUNNER="$RUNNER" COMMIT_SHA="$COMMIT_SHA" TIMESTAMP="$TIMESTAMP" \
205
+ python3 - <<'PY' 2>/dev/null || echo ""
206
+ import json, os
207
+ try:
208
+ gate = json.loads(os.environ["GATE_JSON"])
209
+ except (json.JSONDecodeError, ValueError):
210
+ gate = {}
211
+ print(json.dumps({
212
+ "name": "agent.rollout.gate.evaluated",
213
+ "attributes": {
214
+ "gate.id": str(gate.get("gate_id", "")),
215
+ "gate.result": str(gate.get("result", "")),
216
+ "gate.runner": os.environ["RUNNER"],
217
+ "gate.commit_sha": os.environ["COMMIT_SHA"],
218
+ },
219
+ "timestamp": os.environ["TIMESTAMP"],
220
+ }, separators=(",", ":")))
221
+ PY
222
+ )
223
+ [[ -n "$OTEL_LINE" ]] && printf '[OTEL] %s\n' "$OTEL_LINE" >&2
188
224
  fi
189
225
 
190
226
  # --- Sign + emit ---
@@ -212,6 +248,40 @@ if ! command -v cosign >/dev/null 2>&1; then
212
248
  exit 2
213
249
  fi
214
250
 
251
+ # --- Production DNSSEC + CAA pre-flight gate (CISO binding DR-010 Q5) ----------
252
+ # A "production" signing event is one that pushes a signed Statement to a PUBLIC
253
+ # transparency log (Rekor) — i.e. REKOR_URL is non-empty. Before that irreversible
254
+ # anchor, the predicate namespace MUST be DNSSEC-signed AND CAA-pinned. We run the
255
+ # two read-only checks; if EITHER fails we REFUSE to sign and exit 4.
256
+ #
257
+ # The opt-out EVIDENCE_SKIP_DNS_PREFLIGHT=1 is honored ONLY for non-production
258
+ # (no Rekor push). A real Rekor push can never be silently skipped.
259
+ if [[ -n "$REKOR_URL" ]]; then
260
+ PREFLIGHT_DIR="$(cd "$(dirname "$0")" && pwd)"
261
+ if [[ "${EVIDENCE_SKIP_DNS_PREFLIGHT:-0}" == "1" ]]; then
262
+ echo "emit-evidence: IGNORING EVIDENCE_SKIP_DNS_PREFLIGHT=1 — a Rekor push (REKOR_URL=$REKOR_URL) is a production attestation and CANNOT skip the DNSSEC/CAA pre-flight." >&2
263
+ fi
264
+ echo "emit-evidence: production Rekor push requested — running DNSSEC + CAA pre-flight on '$PREDICATE_DOMAIN'" >&2
265
+
266
+ if ! bash "$PREFLIGHT_DIR/dnssec-check.sh" "$PREDICATE_DOMAIN" >&2; then
267
+ echo "emit-evidence: REFUSING TO SIGN — DNSSEC pre-flight FAILED for '$PREDICATE_DOMAIN'." >&2
268
+ echo "emit-evidence: remediation: pin DNSSEC + CAA on $PREDICATE_DOMAIN before any production attestation." >&2
269
+ echo "emit-evidence: see intent-eval-platform/intent-eval-lab/000-docs (DR-010 Q5 CISO binding) + the iah-E06 runbook." >&2
270
+ exit 4
271
+ fi
272
+ if ! bash "$PREFLIGHT_DIR/caa-check.sh" "$PREDICATE_DOMAIN" >&2; then
273
+ echo "emit-evidence: REFUSING TO SIGN — CAA pre-flight FAILED for '$PREDICATE_DOMAIN'." >&2
274
+ echo "emit-evidence: remediation: pin DNSSEC + CAA on $PREDICATE_DOMAIN before any production attestation." >&2
275
+ echo "emit-evidence: set EXPECTED_CAA_ISSUER to the published CA, then publish a CAA record pinning it." >&2
276
+ exit 4
277
+ fi
278
+ echo "emit-evidence: DNSSEC + CAA pre-flight PASSED for '$PREDICATE_DOMAIN' — proceeding to sign." >&2
279
+ elif [[ "${EVIDENCE_SKIP_DNS_PREFLIGHT:-0}" == "1" ]]; then
280
+ # Non-production sign (no Rekor push) with the explicit opt-out set: keep
281
+ # existing staging flows green without running the network-bound checks.
282
+ echo "emit-evidence: non-production sign (no Rekor push); DNSSEC/CAA pre-flight skipped per EVIDENCE_SKIP_DNS_PREFLIGHT=1." >&2
283
+ fi
284
+
215
285
  # Stage the Statement to a temp file for cosign to consume
216
286
  TMP=$(mktemp -d)
217
287
  trap 'rm -rf "$TMP"' EXIT
@@ -28,6 +28,23 @@
28
28
 
29
29
  set -euo pipefail
30
30
 
31
+ # Bash version floor: these gates rely on bash 4+ features. Refuse early with a
32
+ # clear message on bash 3.x (e.g. macOS system bash) instead of failing later
33
+ # with a cryptic syntax error (jcgw).
34
+ [ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
35
+
36
+ # Cross-platform SHA-256: `sha256sum` ships with GNU coreutils (Linux);
37
+ # macOS only has `shasum -a 256`. Both produce identical `<hash> <file>`
38
+ # output, so downstream awk parsing is unchanged. Mirrors harness-hash.sh.
39
+ if command -v sha256sum >/dev/null 2>&1; then
40
+ SHA256_CMD=(sha256sum)
41
+ elif command -v shasum >/dev/null 2>&1; then
42
+ SHA256_CMD=(shasum -a 256)
43
+ else
44
+ echo "escape-scan: neither sha256sum nor shasum found in PATH" >&2
45
+ exit 2
46
+ fi
47
+
31
48
  DIFF_SRC=""
32
49
  VERIFY_HASH=1
33
50
  JSON_OUT=0
@@ -51,7 +68,15 @@ if [[ "$#" -eq 0 ]]; then
51
68
  fi
52
69
 
53
70
  case "$1" in
54
- -) DIFF_SRC="/dev/stdin" ;;
71
+ -)
72
+ # Buffer stdin into a temp file so the diff can be read multiple times.
73
+ # /dev/stdin is drained by the first grep, which would leave later reads
74
+ # (notably the input_hash sha256) seeing an empty stream — emitting the
75
+ # SHA-256 of "" instead of the real diff hash.
76
+ DIFF_SRC=$(mktemp)
77
+ trap 'rm -f "$DIFF_SRC"' EXIT
78
+ cat > "$DIFF_SRC"
79
+ ;;
55
80
  --staged)
56
81
  DIFF_SRC=$(mktemp)
57
82
  trap 'rm -f "$DIFF_SRC"' EXIT
@@ -198,10 +223,10 @@ if [[ "$JSON_OUT" -eq 1 ]]; then
198
223
  elif [[ "$FLAG" -gt 0 ]]; then
199
224
  result="ADVISORY"
200
225
  fi
201
- input_hash=$(sha256sum "$DIFF_SRC" | awk '{print "sha256:"$1}')
226
+ input_hash=$("${SHA256_CMD[@]}" "$DIFF_SRC" | awk '{print "sha256:"$1}')
202
227
  policy_hash="sha256:0000000000000000000000000000000000000000000000000000000000000000"
203
228
  if [[ -f "$TESTING_MD" ]]; then
204
- policy_hash=$(sha256sum "$TESTING_MD" | awk '{print "sha256:"$1}')
229
+ policy_hash=$("${SHA256_CMD[@]}" "$TESTING_MD" | awk '{print "sha256:"$1}')
205
230
  fi
206
231
  printf '{"gate_id":"audit-harness:%s:escape-scan","result":"%s","input_hash":"%s","policy_hash":"%s","metadata":{"refuse":%d,"challenge":%d,"flag":%d,"coverage_line_floor":%d,"coverage_branch_floor":%d,"mutation_floor":%d}' \
207
232
  "${AUDIT_HARNESS_SIDE:-ci}" "$result" "$input_hash" "$policy_hash" "$REFUSE" "$CHALLENGE" "$FLAG" \
@@ -13,6 +13,11 @@
13
13
 
14
14
  set -euo pipefail
15
15
 
16
+ # Bash version floor: these gates rely on bash 4+ features. Refuse early with a
17
+ # clear message on bash 3.x (e.g. macOS system bash) instead of failing later
18
+ # with a cryptic syntax error (jcgw).
19
+ [ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
20
+
16
21
  PATH_ARG="features/"
17
22
  STRICT=0
18
23
  JSON_OUT=0
@@ -23,6 +23,11 @@
23
23
 
24
24
  set -euo pipefail
25
25
 
26
+ # Bash version floor: these gates rely on bash 4+ features. Refuse early with a
27
+ # clear message on bash 3.x (e.g. macOS system bash) instead of failing later
28
+ # with a cryptic syntax error (jcgw).
29
+ [ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
30
+
26
31
  # Cross-platform SHA-256: `sha256sum` ships with GNU coreutils (Linux);
27
32
  # macOS only has `shasum -a 256`. Both produce identical `<hash> <file>`
28
33
  # output, so downstream awk parsing is unchanged.