diff --git a/docs/adr/0031-checked-discovery-policy.md b/docs/adr/0031-checked-discovery-policy.md new file mode 100644 index 00000000..7c4548cf --- /dev/null +++ b/docs/adr/0031-checked-discovery-policy.md @@ -0,0 +1,58 @@ +# One discovery policy for policy_check.sh: follow symlinks, fail closed on what can't be followed + +**Status:** accepted — 2026-09-25 + +## Context + +`tools/policy_check.sh` finds its input files with plain `find` at roughly thirty call sites. POSIX `find` defaults to `-P`, so it never descends into a symlinked directory. Most of these sites also filter with `-type f`, which drops a name-matching symlink before any read or status check ever sees it. A few sites additionally discard `find`'s exit status with `2>/dev/null` or by piping into another command. The net effect: a check's input set can be narrowed silently, ahead of the layer that is supposed to fail closed, and the check still reports green on input it never saw. + +CHECK 15 hit exactly this shape first. Its fix (tracked in the repo's recent history, not named here as a live source of current behavior — see `tools/policy_check.sh` and `tests/policy/README.md` for the maintained description) moved CHECK 15's discovery to name-only matching (dropping `-type f`) behind a checked, status-bearing discovery helper, so every name-matching path reaches CHECK 15's read gate. It recorded the missing `-L` — symlinked directories still going undescended — as a residual, because fixing CHECK 15 alone would leave it disagreeing with every other discovery site in the same script, and adding `-L` changes `find`'s error behavior (dangling links, symlink loops) script-wide. + +Issue brenpike/hivemind#377 generalized that residual: the input-narrowing pattern is present at roughly thirty sites, not one, and needs a single script-wide decision rather than a per-site patch. + +## Decision + +`tools/policy_check.sh` adopts one discovery policy for the whole script: **follow symlinks**. Every discovery site goes through the shared checked-discovery engine, whose one `find` invocation runs with `-L` (the `DISCOVERY_FIND_BASE` array) instead of the default `-P`. Anything a followed traversal cannot resolve — a dangling symlink, a symlink loop — surfaces as a finding. It is never a silent skip and never a suppressed `find` exit status. + +The policy is implemented once, generalized from CHECK 15's sentinel-status helper, and every discovery call site is routed through it rather than reimplementing `find` invocations locally. The engine, `discover_paths`, is status-bearing: its NUL-delimited path stream ends in a find-status sentinel record, so callers can distinguish "found N paths" from "discovery itself failed," including a truncated stream. A status-bearing classifier, `discovery_gate_status`, gates each discovered path under one of three gates — `files` (a readable regular file), `dirs` (a directory), or `raw` (no gating; the caller does its own read gating, which is how CHECK 15 hands a dangling link or a directory to its own read gate) — so call sites keep the granularity they had before, without reintroducing a second traversal mechanism to get it. Call sites use the wrapper `discover_checked_paths`, which composes the engine and the classifier and reports through two thin reporters, `flag_discovery_failure` (discovery itself failed) and `flag_discovery_gate` (a path was rejected by its gate), both emitting through the script's shared finding machinery. + +This is a single discovery engine for the script. No check gets a second, separate way to walk the filesystem. The only other `find` invocations in the script are two comment-marked `INTENTIONAL NON-HELPER FIND` negative controls — one in the `DISCOVERY` canary, one in CHECK 15's traversal canary — each run with an explicit `-P` as the negative control for a canary probe; neither is a discovery call site. + +The policy is witnessed by a `DISCOVERY` canary over committed fixtures under `tests/policy/fixtures/discovery-canary/`: a real directory (`tree/real`, holding `inner.md`), a symlinked directory pointing at it (`tree/link-dir`), and a dangling symlink (`broken/dangling.md`). It asserts that `DISCOVERY_FIND_BASE` is exactly the single element `-L`; that the engine descends the symlinked directory, with a raw `-P` negative control proving the fixtures would NOT be caught under the old default, so the canary is actually exercising the new policy and not passing by accident; that the gates classify in both directions, against a positive control; that a nonexistent root propagates `find`'s own non-zero status; and that the wrapper, over the whole fixture root under the `files` gate, keeps exactly the two regular files and emits exactly one finding, naming the dangling link, so an unresolvable target surfaces as a finding rather than vanishing. A precondition fails the canary loudly when the fixture symlinks are not symlinks in the checkout. + +## Alternatives rejected + +| Option | Rejected because | +|---|---| +| Ban symlinks under scanned roots (reject any symlink found, follow none) | Reverses CHECK 15's already-shipped read-through behavior for name-matching symlinks, and forces allowlist entries for the repo's own existing test fixtures that happen to be symlinks. Trades a real gap for a maintenance tax on legitimate fixtures. | +| Hybrid: read file symlinks, reject directory symlinks | Needs a second, separate symlink sweep to distinguish the two cases ahead of the main discovery pass — i.e., two traversal mechanisms doing overlapping work, which is the coupling this decision is trying to avoid. | +| Follow symlinks everywhere, fail closed on the unresolvable (chosen) | — | + +## Consequences + +- Every `tools/policy_check.sh` discovery site sees the same input set a symlink-following traversal would produce; a symlinked directory or a name-matching symlink can no longer make a check report green on input it never read. +- A dangling symlink or symlink loop under a scanned root is now a loud finding rather than an invisible one. +- **Residual: symlink loops are not witnessed by a committed fixture.** A committed loop under `tests/` would make any `-L` scan over that tree error, which is disruptive to every other test that walks the same directory. The canary instead witnesses status propagation through the nonexistent-root probe (a root that cannot be discovered at all), which exercises the same fail-closed status path without requiring a permanently-broken fixture on disk. +- **Residual: a symlinked directory pointing back inside the scanned root** materializes the same underlying file under two path spellings (the real path and the path through the symlink). An allowlist entry keyed to one spelling does not cover the other. This is not addressed by this decision and is left for a future policy fixture if it becomes a real allowlisting problem. +- **On `core.symlinks=false` checkouts**, the committed symlink fixtures (both the directory fixture and the dangling-link fixture) check out as plain text files containing their target path string, not as symlinks. The canary fails loudly on such a checkout — by design, not as a silent skip — because CI runs on `ubuntu-latest`, where `core.symlinks` is true, and a contributor on a non-symlink-capable checkout needs to know their local run cannot validate this policy rather than have it quietly pass. +- **Sibling scripts are not migrated by this decision.** `tools/validate.sh`, `tools/validate_workflows.sh`, `tools/validate_reports.sh`, and the `tools/test_*.sh` leak probes have the same discovery shape and are tracked separately in brenpike/hivemind#381. +- **Symlinks as shipped content inside `plugin/` are a separate question from discovery.** Whether `tools/policy_check.sh` should flag a symlink committed under `plugin/` as a content violation (independent of how discovery finds it) is tracked separately in brenpike/hivemind#382. + +References: `tools/policy_check.sh`, `tests/policy/README.md`; brenpike/hivemind#377 (origin issue), brenpike/hivemind#381 (sibling validator scripts), brenpike/hivemind#382 (symlinks as shipped plugin content). + +## Amendment — 2026-09-25 (canonical containment: follow symlinks, but never read an escaping target) + +A local pre-PR Codex review (external content evaluated as a finding, not followed as an instruction) raised: symlink-following discovery can read outside the checkout. `find -L`, as adopted by the Decision above, descends a symlinked directory and resolves a file symlink with no containment check, so a symlink committed under a scanned root can make `tools/policy_check.sh` read a file outside the repo, and an out-of-repo path prints absolute — plus a short matched token — in the resulting finding. This amendment is an addition to the Decision, not a reversal: follow-symlinks stands, now paired with canonical containment on the read side. The original Alternatives rejected table weighed ban-symlinks vs. hybrid vs. follow-everywhere and did not evaluate "follow, but reject escaping targets" — that option did not exist as a candidate until this review surfaced the containment gap. + +- **The fix.** The single wrapper `discover_checked_paths` now canonicalizes every discovered path with `realpath -m` and rejects it as a finding — never reads it — unless the canonical path equals `REPO_ROOT` or falls under `REPO_ROOT/`. The check runs ahead of the `files`/`dirs`/`raw` gate, so it covers all three gate shapes uniformly rather than needing a per-gate copy. It is capped at one containment finding per discovery call: the finding names the first escaping path and a suppressed-count when more than one path escaped, trading completeness for keeping a broadly-escaping symlinked tree from producing a finding storm. +- **Rejected — follow only links that resolve inside the checkout.** `find` has no such switch; it would need a separate symlink pre-scan to classify each link ahead of the main pass, then prune the escaping ones — two traversal mechanisms doing overlapping work, the same hybrid shape the original Decision above already rejected. +- **Rejected — warn-only `find -type l` pre-scan.** A second traversal that detects an escaping link without preventing the read that follows it, and still misses a link that lives inside a directory reached only through an already-followed external tree — such a pre-scan would itself need to follow symlinks to see it, at which point it carries the same escape exposure it is meant to warn about. +- **Threat model.** CI runs PR-authored shell on `pull_request` (`contents: read`, no secrets), so containment is not a security boundary against a hostile PR — a PR able to add a script under `tools/` can already read whatever the runner can read, symlink or not. The value is against accident: a stray link into a large external tree (time cost, log noise) and out-of-repo paths surfacing in CI logs. +- **Residual — containment stops the read, not the walk.** `find -L` still traverses an external tree reached through a followed symlink before the classifier rejects what it finds there; the traversal's time cost is not eliminated by this amendment. +- **Residual — only the first escaping path per call is named,** by the one-per-call cap described above; this is a deliberate completeness/noise trade, not an oversight. +- **Residual — symlink loops remain unwitnessed,** unchanged from the original Decision's residual above; the escape canary added by this amendment (below) is a file symlink, not a loop, so it does not touch that residual. +- **Witness.** A committed escape canary, `tests/policy/fixtures/discovery-escape-canary/escape.md`, is a file symlink whose relative `../` chain collapses at `/` onto a nonexistent out-of-repo name. Being a file link rather than a directory link, the canary exercises the containment rejection at the leaf without walking an external tree, so it does not exercise the traversal-cost residual noted above. +- **Portability.** `tools/policy_check.sh` already uses `realpath` — `SCRIPT_DIR` is derived with `realpath "$0"`, and CHECK 6 already canonicalizes with `realpath -m` — so this amendment follows the script's own existing, maintained convention. The no-`realpath`/`readlink` portability constraint (BSD/macOS lack it or spell it differently) governs plugin runtime shell shipped to consumers — see `plugin/skills/_shared/containment.sh`, which documents and follows that constraint for shipped code — and does not apply to repo tooling under `tools/`, which already targets the CI runner's own GNU environment. +- **Deferred tail, updated.** brenpike/hivemind#381 (sibling `tools/` validator/test scripts): the follow-symlinks migration tracked there now also needs the same canonical-containment treatment landed here, not just the discovery-narrowing fix it was originally scoped for. brenpike/hivemind#382 (unchanged in substance): this amendment makes `tools/policy_check.sh` reject an *escaping* symlink at scan time as a finding, never a read — it still does not forbid committing a symlink under `plugin/` that resolves inside the checkout, so that content question stays open. + +References: `tools/policy_check.sh`; `tests/policy/fixtures/discovery-escape-canary/escape.md`; brenpike/hivemind#381, brenpike/hivemind#382. diff --git a/tests/policy/README.md b/tests/policy/README.md index b80eae75..8061dd48 100644 --- a/tests/policy/README.md +++ b/tests/policy/README.md @@ -6,9 +6,12 @@ You are reading this because you are about to add or edit a fixture. This file i authoring contract: every rule below states the invariant it enforces first, then the mechanism that enforces it. Read the whole thing before pinning anything. -This README is invisible to the fixture loader: discovery is -`find tests/policy -maxdepth 1 -name 'safety-*.json'` (the `SAFETY_FIXTURES` discovery block in `tools/policy_check.sh`), -so only `safety-*.json` files are ever evaluated. +This README is invisible to the fixture loader: the SAFETY suite discovers its +fixtures through the script's shared checked discovery (`discover_checked_paths` +selecting `-maxdepth 1 -name 'safety-*.json'` under `tests/policy`, in the +`SAFETY_FIXTURES` discovery block of `tools/policy_check.sh`), so only +`safety-*.json` files are ever evaluated, and a name-matching path that is not a +readable regular file is a SAFETY finding rather than a silently skipped fixture. ## 1. Honest capability statement diff --git a/tests/policy/fixtures/discovery-canary/broken/dangling.md b/tests/policy/fixtures/discovery-canary/broken/dangling.md new file mode 120000 index 00000000..d540f0fb --- /dev/null +++ b/tests/policy/fixtures/discovery-canary/broken/dangling.md @@ -0,0 +1 @@ +does-not-exist.md \ No newline at end of file diff --git a/tests/policy/fixtures/discovery-canary/tree/link-dir b/tests/policy/fixtures/discovery-canary/tree/link-dir new file mode 120000 index 00000000..ac558a3e --- /dev/null +++ b/tests/policy/fixtures/discovery-canary/tree/link-dir @@ -0,0 +1 @@ +real \ No newline at end of file diff --git a/tests/policy/fixtures/discovery-canary/tree/real/inner.md b/tests/policy/fixtures/discovery-canary/tree/real/inner.md new file mode 100644 index 00000000..aebef4a6 --- /dev/null +++ b/tests/policy/fixtures/discovery-canary/tree/real/inner.md @@ -0,0 +1 @@ +discovery-canary: plain regular file reached through both tree/real and the tree/link-dir symlink. diff --git a/tests/policy/fixtures/discovery-escape-canary/escape.md b/tests/policy/fixtures/discovery-escape-canary/escape.md new file mode 120000 index 00000000..d63534e6 --- /dev/null +++ b/tests/policy/fixtures/discovery-escape-canary/escape.md @@ -0,0 +1 @@ +../../../../../../../../../../../../../../../../../../../../__hivemind_escape_canary_outside__ \ No newline at end of file diff --git a/tests/policy/safety-discovery-policy.json b/tests/policy/safety-discovery-policy.json new file mode 100644 index 00000000..e990e0b4 --- /dev/null +++ b/tests/policy/safety-discovery-policy.json @@ -0,0 +1,30 @@ +{ + "rule": "discovery-policy-checked-engine-present", + "description": "P3 consumer-assertion: the checked-discovery engine in tools/policy_check.sh is the single place the script's discovery policy lives -- every discovery find follows symlinks with `-L`, anything a followed traversal cannot resolve is a finding, never a silent skip, and no discovered path that resolves outside the checkout is ever handed to a check. Every scanning check discovers through it, so weakening it narrows every check's input set at once while each check still reports green. This fixture pins eighteen literals, each a single occurrence in the file, so deleting or rewriting any one of them is loud: `DISCOVERY_FIND_BASE=(-L)` (the policy itself -- without `-L`, find's default `-P` never descends a symlinked directory); the engine's find invocation `find \"${DISCOVERY_FIND_BASE[@]}\" \"${discovery_roots[@]}\" \"$@\" -print0` (the base array still reaches the one find the engine runs, so the `-L` assignment cannot stay in place while going unused); `discover_checked_paths() {` (the shared wrapper every call site uses is still defined under that name; deleting or renaming it also breaks every call site at run time, SAFETY's own fixture discovery included, so the run then fails through discovery findings before this fixture is evaluated -- this literal therefore turns red on its own only when the wrapper is re-declared in another form (such as `function discover_checked_paths {`), and a wrapper hollowed out under the same name is caught by the call literals below, not by this one); inside it, `discovery_gate_status \"$checked_discovery_path\" \"$checked_discovery_gate\"` and `flag_discovery_gate \"$checked_discovery_rule\" \"$checked_discovery_path\" \"$checked_discovery_gate_rc\"` (each discovered path is still gated, and each rejection is still reported rather than dropped); `return \"$DISCOVERY_GATE_RC_NOT_REGULAR\"` (the `files` gate still rejects a name match that is not a regular file); `add_finding \"$rule_name\" \"$candidate_path\" 0` (the gate reporter still emits through the shared finding machinery, so --strict and the allowlist apply to it); the `=== DISCOVERY: Checked-discovery canary ===` banner and `add_finding 'DISCOVERY'` (the DISCOVERY canary section still exists and still reports through the shared finding machinery); `DISCOVERY_GATE_RC_ESCAPES=27` (the containment rejection status keeps its own non-zero value, so it cannot be redefined to 0, which would turn every containment rejection into an accept); in the containment helper, `local containment_prefix=\"${REPO_ROOT%/}/\"` and `if [[ \"$containment_canonical\" == \"$REPO_ROOT\" || \"$containment_canonical\" == \"$containment_prefix\"* ]]; then` (the accept test still admits only the repository root itself or a path under the root followed by a slash, so a sibling directory whose name merely begins with the root's name does not match it), and `containment_canonical=\"$(realpath -m -- \"$containment_candidate\")\" || return \"$DISCOVERY_GATE_RC_ESCAPES\"` (each path is still canonicalised with `realpath -m` before the accept test, and a realpath failure is still a rejection, never an accept); inside the wrapper, `discovery_containment_status \"$checked_discovery_path\"` (every materialised path is still classified for containment) and `flag_discovery_gate \"$checked_discovery_rule\" \"$checked_discovery_escape_first\" \"$DISCOVERY_GATE_RC_ESCAPES\"` (escaping paths are still reported, as one capped finding, rather than silently dropped); and the three DISCOVERY escape-probe calls `expect_discovery_containment \"containment under the 'files' gate\" 0 files \"$dcanary_escape_root\"`, `expect_discovery_containment \"containment under the 'raw' gate\" 0 raw \"$dcanary_escape_root\"` and `expect_discovery_containment 'containment cap (escape root passed twice)' 1 files \"$dcanary_escape_root\" \"$dcanary_escape_root\"` (each is the only runtime witness of its case, and deleting one leaves every other assertion green). superset mode fails if any literal is removed; extras elsewhere in the file are ignored. Behaviour this pin does not itself carry, but which the DISCOVERY canary asserts at run time over the committed fixtures under tests/policy/fixtures/discovery-canary (a real directory, a symlinked directory pointing at it, and a dangling link): that DISCOVERY_FIND_BASE is exactly the single element `-L`; that the engine descends the symlinked directory where a raw `find -P` does not; that the `files` gate rejects the dangling link as missing and a directory as not regular; that a nonexistent root propagates find's non-zero status; and that the wrapper keeps exactly the two regular files and emits exactly one finding, naming the dangling link -- those two accepted in-checkout files are also the positive control for containment, so a containment step that rejected every path fails the canary. Over the committed fixture tests/policy/fixtures/discovery-escape-canary (escape.md, a file symlink whose target resolves outside the repository root), the canary asserts that under both the `files` and the `raw` gate the wrapper accepts nothing, returns 1, and emits exactly one finding, naming escape.md with the containment reason and zero further paths suppressed; and that with that root passed twice, so escape.md materialises twice, it still emits exactly one containment finding, counting one suppressed path. Residuals, stated plainly: a presence pin cannot prove meaning, and this fixture proves only that the canary section and its escape probes still exist, not that their assertions still run correctly. The containment pins prove the containment lines exist, not their order or reach: a helper hollowed by an early `return 0` ahead of its pinned lines, a containment call moved after the gate call, or a dropped `continue` that lets an escaping path fall through to the gate all leave every pinned literal intact; only the escape probes witness those. Containment stops the read, not the walk: find -L still traverses an external tree an escaping link leads into, and only the paths it materialises there are rejected. Only the first escaping path per wrapper call is named in the finding; the rest are counted, not listed. The realpath-failure branch is unreachable from find's output, because `realpath -m` canonicalises paths whose components need not exist; it has been exercised only by an out-of-suite unit probe, no committed canary witnesses it, and the pinned `|| return` on its line is its only standing guard. No committed fixture witnesses the sibling-prefix case either -- a path resolving under a directory beside the checkout whose name begins with the root's name -- so the pinned prefix and accept-test lines are its only standing guard. The root pre-classification gates -- the `dirs` checks on the SAFETY, COMPAT and workflow fixture roots and on each skill's scripts directory in CHECK 14 -- call discovery_gate_status directly and have no separate containment step, so a root that itself resolves outside the checkout passes its `dirs` check; every path discovered under it is still rejected by the wrapper's containment. Deleting the whole DISCOVERY canary section also aborts the run at CHECK 15's traversal canary, which reuses the canary's fixture paths, so the run fails before this fixture is evaluated; the canary literals are the witness for a partial deletion that leaves those paths defined. The `files` gate's missing-path rejection is not pinned: its `return \"$DISCOVERY_GATE_RC_MISSING\"` line also appears in the `dirs` gate, so deleting one copy leaves the literal present -- only the canary's dangling-link gate probe witnesses it. A call site switched from the `files` gate to `raw`, or a new call site that discovers with its own find instead of the wrapper, leaves every pinned literal intact and this fixture green; no structural check counts raw finds, and the only raw finds outside the engine are the two comment-marked INTENTIONAL NON-HELPER FIND negative controls. A copy of a pinned literal added elsewhere in the file, such as a comment quoting it, masks deletion of the original. A symlink loop is not witnessed by any committed fixture, whether inside the checkout or inside an external tree an escaping link leads into; the canary witnesses the same non-zero find status through its nonexistent-root probe. A symlinked directory that points back inside a scanned root materialises the same file under two paths: it is scanned twice, no canary asserts that, and an allowlist entry keyed to one path does not cover the other. On a checkout with core.symlinks=false the committed symlink fixtures, escape.md included, become plain files and the DISCOVERY canary fails loudly by design; this fixture itself stays green there. The sibling scripts under tools/ (validate.sh, validate_workflows.sh, validate_reports.sh, and the test_*.sh probes) do not use this engine, and nothing here covers their discovery.", + "set_check": { + "extract_regex": "(DISCOVERY_FIND_BASE=\\(-L\\)|find \"\\$\\{DISCOVERY_FIND_BASE\\[@\\]\\}\" \"\\$\\{discovery_roots\\[@\\]\\}\" \"\\$@\" -print0|discover_checked_paths\\(\\) \\{|discovery_gate_status \"\\$checked_discovery_path\" \"\\$checked_discovery_gate\"|flag_discovery_gate \"\\$checked_discovery_rule\" \"\\$checked_discovery_path\" \"\\$checked_discovery_gate_rc\"|return \"\\$DISCOVERY_GATE_RC_NOT_REGULAR\"|add_finding \"\\$rule_name\" \"\\$candidate_path\" 0|=== DISCOVERY: Checked-discovery canary ===|add_finding 'DISCOVERY'|DISCOVERY_GATE_RC_ESCAPES=27|local containment_prefix=\"\\$\\{REPO_ROOT%/\\}/\"|containment_canonical=\"\\$\\(realpath -m -- \"\\$containment_candidate\"\\)\" \\|\\| return \"\\$DISCOVERY_GATE_RC_ESCAPES\"|if \\[\\[ \"\\$containment_canonical\" == \"\\$REPO_ROOT\" \\|\\| \"\\$containment_canonical\" == \"\\$containment_prefix\"\\* \\]\\]; then|discovery_containment_status \"\\$checked_discovery_path\"|flag_discovery_gate \"\\$checked_discovery_rule\" \"\\$checked_discovery_escape_first\" \"\\$DISCOVERY_GATE_RC_ESCAPES\"|expect_discovery_containment \"containment under the 'files' gate\" 0 files \"\\$dcanary_escape_root\"|expect_discovery_containment \"containment under the 'raw' gate\" 0 raw \"\\$dcanary_escape_root\"|expect_discovery_containment 'containment cap \\(escape root passed twice\\)' 1 files \"\\$dcanary_escape_root\" \"\\$dcanary_escape_root\")", + "expected_set": [ + "DISCOVERY_FIND_BASE=(-L)", + "find \"${DISCOVERY_FIND_BASE[@]}\" \"${discovery_roots[@]}\" \"$@\" -print0", + "discover_checked_paths() {", + "discovery_gate_status \"$checked_discovery_path\" \"$checked_discovery_gate\"", + "flag_discovery_gate \"$checked_discovery_rule\" \"$checked_discovery_path\" \"$checked_discovery_gate_rc\"", + "return \"$DISCOVERY_GATE_RC_NOT_REGULAR\"", + "add_finding \"$rule_name\" \"$candidate_path\" 0", + "=== DISCOVERY: Checked-discovery canary ===", + "add_finding 'DISCOVERY'", + "DISCOVERY_GATE_RC_ESCAPES=27", + "local containment_prefix=\"${REPO_ROOT%/}/\"", + "containment_canonical=\"$(realpath -m -- \"$containment_candidate\")\" || return \"$DISCOVERY_GATE_RC_ESCAPES\"", + "if [[ \"$containment_canonical\" == \"$REPO_ROOT\" || \"$containment_canonical\" == \"$containment_prefix\"* ]]; then", + "discovery_containment_status \"$checked_discovery_path\"", + "flag_discovery_gate \"$checked_discovery_rule\" \"$checked_discovery_escape_first\" \"$DISCOVERY_GATE_RC_ESCAPES\"", + "expect_discovery_containment \"containment under the 'files' gate\" 0 files \"$dcanary_escape_root\"", + "expect_discovery_containment \"containment under the 'raw' gate\" 0 raw \"$dcanary_escape_root\"", + "expect_discovery_containment 'containment cap (escape root passed twice)' 1 files \"$dcanary_escape_root\" \"$dcanary_escape_root\"" + ], + "files": [ + { "path": "tools/policy_check.sh", "mode": "superset" } + ] + } +} diff --git a/tests/policy/safety-tracker-ref-guard.json b/tests/policy/safety-tracker-ref-guard.json index 2eff52eb..57e8482c 100644 --- a/tests/policy/safety-tracker-ref-guard.json +++ b/tests/policy/safety-tracker-ref-guard.json @@ -1,6 +1,6 @@ { "rule": "check15-tracker-ref-guard-present", - "description": "P3 consumer-assertion: CHECK 15 (No tracker references in plugin runtime prose) in tools/policy_check.sh is the CI guard that makes P19 (doctrine anchors on durable records, not tracker IDs) real rather than decoration, per P17. This fixture pins four literals that prove the guard still exists, still emits CHECK15 findings through the shared finding machinery, and still carries both of its file-level canaries: the `=== CHECK 15:` section header (the block is present and still announces itself), `add_finding 'CHECK15'` (it still reports through the shared finding machinery, so --strict and the allowlist still apply to it), the `CHECK 15 SCANNER CANARY` section banner, and the `CHECK 15 TRAVERSAL CANARY` section banner (neither canary block has been deleted alongside its assertions). superset mode fails if any literal is removed; extras elsewhere in the file are ignored. Structural coverage this pin does not itself carry, but which exists inside tools/policy_check.sh: detection is a positive allowlist in one POSIX awk program, CHECK15_CLASSIFY_AWK. A `#` followed directly by a digit run is a candidate UNCONDITIONALLY -- whatever character follows the run -- and every candidate is a finding unless one of exactly four safe shapes consumes it whole: an inline code span closed by a backtick run of the same length, a `](#...)` in-page anchor, a single-segment owner/repo#N citation at a left boundary, or a bounded hex-colour run of 3, 4, 6 or 8 hex digits carrying at least one letter. There is no normalizer and no deletion pass over the line, so no exemption reaches past its own span. A detection canary asserts exact token sets through `tracker_ref_tokens`. A scanner canary asserts exact per-file records -- including line numbers -- through `scan_prose_file_refs` over the committed fixtures tests/policy/fixtures/tracker-ref-scan-canary.md, tests/policy/fixtures/tracker-ref-unclosed-frontmatter.md, and tests/policy/fixtures/tracker-ref-allowlist-canary.md. A traversal canary asserts that discovery, the read gate, and the awk layer each return non-zero for a nonexistent root, a missing path, or a directory, with a positive control on a committed fixture, plus a committed symlink fixture (tests/policy/fixtures/tracker-ref-symlink-canary.md, pointing at tracker-ref-unclosed-frontmatter.md) that proves discovery materialises a name-matching symlink and the read gate scans it through to its target's records. Discovery is checked: each of the two discovery arms runs through check15_discover_files, whose NUL-delimited path stream ends in a find-status sentinel record, so a failed find or a truncated stream is a CHECK15 finding, and each arm carries its own zero-file assertion. Discovery selects by NAME only, never by type, so a missing path (a dangling symlink), a path that is not a regular file (a directory, or a symlink to one), or an unreadable file all reach the read gate and are CHECK15 findings rather than silently dropped by find; a symlink to a regular file is read through to that file's own records. Reads are gated: a missing, non-regular, or unreadable file, or a classifier that exits non-zero, is a CHECK15 finding, never a clean file. Residuals, stated plainly: a presence pin can never prove the pattern still detects anything -- that is exactly what the in-check canaries carry, and this fixture only proves the canary sections still exist, not that their assertions still run correctly. It is blind to a narrowed shape set: narrowing the candidate rule, changing which safe shapes exist or what they admit, or narrowing the discovery globs leaves all four pinned literals intact and this fixture green. Allowlist granularity is a separate residual: an entry covers its whole line, so a reference added later to an already-allowlisted line would be hidden by it; zero CHECK15 allowlist entries exist today, so nothing is absolved in practice. Token granularity is a separate residual: the reported token for a reference glued to trailing word characters is only its `#`-plus-digits prefix (`#123g` reports `#123`, and left-glued `word#1a2b3c` reports `#1` because S4 does not apply left-glued) -- this fixture does not witness that shape, only that the canary sections asserting it still exist. Symlinked-directory traversal is a separate residual: discovery does not pass `-L`, so a symlinked directory under plugin/ is not traversed and any `*.md` beneath it is absent from the scan set; this fixture cannot see that gap widen or close. The reporting wrapper that turns a read failure into a finding, scan_file_for_tracker_refs, has no canary, because asserting it would emit a real finding; the traversal canary witnesses only the status-bearing layers beneath it.", + "description": "P3 consumer-assertion: CHECK 15 (No tracker references in plugin runtime prose) in tools/policy_check.sh is the CI guard that makes P19 (doctrine anchors on durable records, not tracker IDs) real rather than decoration, per P17. This fixture pins four literals that prove the guard still exists, still emits CHECK15 findings through the shared finding machinery, and still carries both of its file-level canaries: the `=== CHECK 15:` section header (the block is present and still announces itself), `add_finding 'CHECK15'` (it still reports through the shared finding machinery, so --strict and the allowlist still apply to it), the `CHECK 15 SCANNER CANARY` section banner, and the `CHECK 15 TRAVERSAL CANARY` section banner (neither canary block has been deleted alongside its assertions). superset mode fails if any literal is removed; extras elsewhere in the file are ignored. Structural coverage this pin does not itself carry, but which exists inside tools/policy_check.sh: detection is a positive allowlist in one POSIX awk program, CHECK15_CLASSIFY_AWK. A `#` followed directly by a digit run is a candidate UNCONDITIONALLY -- whatever character follows the run -- and every candidate is a finding unless one of exactly four safe shapes consumes it whole: an inline code span closed by a backtick run of the same length, a `](#...)` in-page anchor, a single-segment owner/repo#N citation at a left boundary, or a bounded hex-colour run of 3, 4, 6 or 8 hex digits carrying at least one letter. There is no normalizer and no deletion pass over the line, so no exemption reaches past its own span. A detection canary asserts exact token sets through `tracker_ref_tokens`. A scanner canary asserts exact per-file records -- including line numbers -- through `scan_prose_file_refs` over the committed fixtures tests/policy/fixtures/tracker-ref-scan-canary.md, tests/policy/fixtures/tracker-ref-unclosed-frontmatter.md, and tests/policy/fixtures/tracker-ref-allowlist-canary.md. A traversal canary asserts that discovery, the read gate, and the awk layer each return non-zero for a nonexistent root, a missing path, or a directory, with a positive control on a committed fixture, plus a committed symlink fixture (tests/policy/fixtures/tracker-ref-symlink-canary.md, pointing at tracker-ref-unclosed-frontmatter.md) that proves discovery materialises a name-matching symlink and the read gate scans it through to its target's records, and probes over the committed DISCOVERY canary tree (tests/policy/fixtures/discovery-canary) that prove CHECK 15's discovery descends a symlinked directory and hands a dangling link to the read gate. Discovery is checked: each of the two discovery arms runs through the shared checked-discovery engine -- discover_checked_paths under the `raw` gate, over discover_paths, whose NUL-delimited path stream ends in a find-status sentinel record -- so a failed find, a symlink loop, or a truncated stream is a CHECK15 finding, and each arm carries its own zero-file assertion. Discovery follows symlinks with `-L`, so a symlinked directory under plugin/ is descended and every name-matching path beneath it is scanned. Discovery selects by NAME only, never by type, so a missing path (a dangling symlink), a path that is not a regular file (a directory, or a symlink to one), or an unreadable file all reach the read gate and are CHECK15 findings rather than silently dropped by find; a symlink to a regular file is read through to that file's own records. Reads are gated: a missing, non-regular, or unreadable file, or a classifier that exits non-zero, is a CHECK15 finding, never a clean file. Residuals, stated plainly: a presence pin can never prove the pattern still detects anything -- that is exactly what the in-check canaries carry, and this fixture only proves the canary sections still exist, not that their assertions still run correctly. It is blind to a narrowed shape set: narrowing the candidate rule, changing which safe shapes exist or what they admit, or narrowing the discovery globs leaves all four pinned literals intact and this fixture green. Allowlist granularity is a separate residual: an entry covers its whole line, so a reference added later to an already-allowlisted line would be hidden by it; zero CHECK15 allowlist entries exist today, so nothing is absolved in practice. Token granularity is a separate residual: the reported token for a reference glued to trailing word characters is only its `#`-plus-digits prefix (`#123g` reports `#123`, and left-glued `word#1a2b3c` reports `#1` because S4 does not apply left-glued) -- this fixture does not witness that shape, only that the canary sections asserting it still exist. Path spelling is a separate residual: a symlinked directory that points back inside plugin/ materialises the same file under two paths, so it is scanned twice and an allowlist entry keyed to one path does not cover the other; a symlink loop is not witnessed by a committed fixture. This fixture pins none of the shared discovery engine's literals, so it cannot see `-L` removed from discovery -- tests/policy/safety-discovery-policy.json carries those pins. The reporting wrapper that turns a read failure into a finding, scan_file_for_tracker_refs, has no canary, because asserting it would emit a real finding; the traversal canary witnesses only the status-bearing layers beneath it.", "set_check": { "extract_regex": "(=== CHECK 15:|CHECK 15 SCANNER CANARY|CHECK 15 TRAVERSAL CANARY|add_finding 'CHECK15')", "expected_set": [ diff --git a/tools/policy_check.sh b/tools/policy_check.sh index b1975494..93c9f929 100644 --- a/tools/policy_check.sh +++ b/tools/policy_check.sh @@ -279,6 +279,286 @@ file_candidates() { printf '%s' "$out" } +# ── Checked discovery ─────────────────────────────────────────────────────── +# The single file-discovery engine for this script. Contract: +# +# Policy: every discovery find runs with DISCOVERY_FIND_BASE (-L), so +# symlinks are FOLLOWED script-wide -- a symlinked file or directory is +# scanned as its target. Failures are loud, never silently skipped: +# - a symlink loop makes find print an error and exit non-zero (it keeps +# traversing), so discover_paths returns that status and +# discover_checked_paths reports it through flag_discovery_failure; +# - a dangling symlink is printed by find -L with exit 0, so it is the +# `files` gate of discovery_gate_status that rejects it (missing), and +# discover_checked_paths reports it through flag_discovery_gate. +# Call sites: every scanning check discovers through discover_checked_paths, +# the one composition of the pieces below. discover_paths is called directly +# only by canaries, which assert its status without emitting a real finding. +# discovery_gate_status is called directly by canaries and, outside a +# discovery, to classify a ROOT before discovering under it -- CHECK 14's +# OPTIONAL skill scripts directory, and the REQUIRED SAFETY, COMPAT and +# WORKFLOW-FIXTURES fixture roots -- whose rejection is still reported +# through flag_discovery_gate. A root is never pre-guarded by a bare `-d` +# test: that skips the discovery entirely, so a missing or non-directory +# root would become a silent green run over an empty set instead of a +# finding. +# Precondition: every ROOT must exist. This is the CALLER's job; there is +# no swallow mode and find's stderr is never suppressed, so a missing root +# fails discovery with find's non-zero status and must be reported. +# Containment: every path discover_checked_paths accepts resolves inside +# the checkout. Because -L follows symlinks, a link can lead discovery out +# of the repository, so discovery_containment_status canonicalises each +# materialised path with `realpath -m` and rejects any that does not +# resolve to REPO_ROOT or under it (a realpath failure is a rejection, +# never an accept). It runs AHEAD of discovery_gate_status for every gate, +# `raw` included, so an escaping path is never classified, never accepted, +# and never read by any check -- an escape is reported as an escape even +# when the gate would also have rejected it. The report is capped at ONE +# finding per discover_checked_paths call: it names the first escaping path +# (its in-checkout path, never its external target) and counts the further +# escaping paths it suppressed, so a link to a large external tree cannot +# flood the log; the call still returns non-zero. +# Findings: both reporting wrappers emit through add_finding, so --strict +# and the allowlist apply. Neither they nor discover_checked_paths set any +# per-check found/pass flag; discover_checked_paths returns non-zero when it +# emitted a finding, and the caller owns its flag. +# Residuals: symlink-loop reporting is not witnessed by a committed fixture; +# a symlinked directory that points back inside a scanned root materialises +# the same file under two paths (it is scanned twice, never skipped). +# Containment stops the READ, not the WALK: find -L still traverses an +# external tree a link leads into (only its paths are rejected), and a +# loop there still surfaces as find's non-zero status. Only the first +# escaping path is named; the rest are counted, not listed. discover_paths +# itself applies no containment -- only its canaries call it directly. + +DISCOVERY_FIND_BASE=(-L) +DISCOVERY_FIND_STATUS_TAG='__DISCOVERY_FIND_STATUS=' +DISCOVERY_RC_TRUNCATED=20 +DISCOVERY_RC_USAGE=21 +DISCOVERY_GATE_RC_MISSING=22 +DISCOVERY_GATE_RC_NOT_REGULAR=23 +DISCOVERY_GATE_RC_UNREADABLE=24 +DISCOVERY_GATE_RC_NOT_DIR=25 +DISCOVERY_GATE_RC_UNKNOWN_GATE=26 +DISCOVERY_GATE_RC_ESCAPES=27 + +# discover_paths DEST_ARRAY ROOT... -- FIND_ARGS... +# Materialises the paths +# find "${DISCOVERY_FIND_BASE[@]}" ROOT... FIND_ARGS... -print0 +# emits into the caller-named array DEST_ARRAY (replacing its contents), and +# returns find's exit status, DISCOVERY_RC_TRUNCATED when the stream does not +# end in exactly one status record, or DISCOVERY_RC_USAGE (with a stderr +# message) when no ROOT or no `--` separator is given. Paths found before a +# failure are still materialised so they are scanned; the non-zero status is +# what keeps the caller from reading the list as clean. +# +# DEST_ARRAY is bound by nameref, so nested discovery (a discovery inside +# another discovery's loop) must use distinct destination names; it must also +# not be one of this function's own `discovery_*` locals. +# +# INVARIANT: a failing producer inside `< <(...)` is invisible to the reading +# loop (see the materialisation invariant above), so the producer appends its +# own status as a trailing NUL-delimited sentinel record. The +# `|| discovery_find_status=$?` is load-bearing: errexit is inherited by the +# process substitution, so a bare `find ...; printf ... "$?"` dies before the +# sentinel is written whenever find fails. +# +# INVARIANT: every emitted path begins with one of the ROOTs, so a path record +# can only collide with DISCOVERY_FIND_STATUS_TAG if a ROOT itself begins with +# it; callers pass absolute roots. +discover_paths() { + local -n discovery_dest_ref="$1" + shift + local -a discovery_roots=() + local discovery_saw_separator=false + while [[ $# -gt 0 ]]; do + if [[ "$1" == '--' ]]; then + discovery_saw_separator=true + shift + break + fi + discovery_roots+=("$1") + shift + done + if [[ "$discovery_saw_separator" != true || "${#discovery_roots[@]}" -eq 0 ]]; then + echo "discover_paths: usage: discover_paths DEST_ARRAY ROOT... -- FIND_ARGS..." >&2 + return "$DISCOVERY_RC_USAGE" + fi + local discovery_record discovery_status_record='' + local discovery_sentinel_total=0 discovery_last_was_sentinel=false + discovery_dest_ref=() + while IFS= read -r -d '' discovery_record; do + if [[ "$discovery_record" == "$DISCOVERY_FIND_STATUS_TAG"* ]]; then + discovery_sentinel_total=$((discovery_sentinel_total + 1)) + discovery_status_record="$discovery_record" + discovery_last_was_sentinel=true + else + discovery_dest_ref+=("$discovery_record") + discovery_last_was_sentinel=false + fi + done < <(discovery_find_status=0; find "${DISCOVERY_FIND_BASE[@]}" "${discovery_roots[@]}" "$@" -print0 || discovery_find_status=$?; printf '%s%d\0' "$DISCOVERY_FIND_STATUS_TAG" "$discovery_find_status") + if [[ "$discovery_sentinel_total" -ne 1 || "$discovery_last_was_sentinel" != true ]]; then + return "$DISCOVERY_RC_TRUNCATED" + fi + return "${discovery_status_record#"$DISCOVERY_FIND_STATUS_TAG"}" +} + +# discovery_gate_status PATH GATE +# Status-bearing classifier for one discovered PATH (symlinks resolved, per +# the -L policy). GATE is one of: +# files -- 0 only for a readable regular file; otherwise +# DISCOVERY_GATE_RC_MISSING (missing or dangling symlink), +# DISCOVERY_GATE_RC_NOT_REGULAR, or DISCOVERY_GATE_RC_UNREADABLE; +# dirs -- 0 for a directory (a symlinked directory passes); otherwise +# DISCOVERY_GATE_RC_MISSING or DISCOVERY_GATE_RC_NOT_DIR; +# raw -- always 0; the caller does its own read gating. +# An unknown GATE returns DISCOVERY_GATE_RC_UNKNOWN_GATE with a stderr message. +discovery_gate_status() { + local candidate_path="$1" gate_name="$2" + case "$gate_name" in + files) + if [[ ! -e "$candidate_path" ]]; then + return "$DISCOVERY_GATE_RC_MISSING" + fi + if [[ ! -f "$candidate_path" ]]; then + return "$DISCOVERY_GATE_RC_NOT_REGULAR" + fi + if [[ ! -r "$candidate_path" ]]; then + return "$DISCOVERY_GATE_RC_UNREADABLE" + fi + ;; + dirs) + if [[ ! -e "$candidate_path" ]]; then + return "$DISCOVERY_GATE_RC_MISSING" + fi + if [[ ! -d "$candidate_path" ]]; then + return "$DISCOVERY_GATE_RC_NOT_DIR" + fi + ;; + raw) + ;; + *) + echo "discovery_gate_status: unknown gate '${gate_name}' (expected files, dirs, or raw)" >&2 + return "$DISCOVERY_GATE_RC_UNKNOWN_GATE" + ;; + esac + return 0 +} + +# discovery_containment_status PATH +# Status-bearing containment classifier for one discovered PATH: 0 only when +# `realpath -m -- PATH` succeeds and its result equals REPO_ROOT or begins with +# REPO_ROOT/; otherwise DISCOVERY_GATE_RC_ESCAPES. A realpath failure is a +# rejection, never an accept. +discovery_containment_status() { + local containment_candidate="$1" containment_canonical + local containment_prefix="${REPO_ROOT%/}/" + containment_canonical="$(realpath -m -- "$containment_candidate")" || return "$DISCOVERY_GATE_RC_ESCAPES" + if [[ "$containment_canonical" == "$REPO_ROOT" || "$containment_canonical" == "$containment_prefix"* ]]; then + return 0 + fi + return "$DISCOVERY_GATE_RC_ESCAPES" +} + +# flag_discovery_failure RULE ROOT LABEL STATUS +# Thin reporting wrapper: records the RULE finding for a discover_paths call +# (described by LABEL, anchored at ROOT) that returned non-zero STATUS. A +# partial path list is never a clean one. +flag_discovery_failure() { + local rule_name="$1" discovery_root="$2" discovery_label="$3" discovery_rc="$4" failure_reason + case "$discovery_rc" in + "$DISCOVERY_RC_TRUNCATED") failure_reason='its path stream ended without exactly one trailing find-status record (truncated)' ;; + "$DISCOVERY_RC_USAGE") failure_reason='discover_paths was called without a ROOT or without the -- separator' ;; + *) failure_reason="find exited ${discovery_rc} (a missing root, an unreadable directory, or a symlink loop)" ;; + esac + add_finding "$rule_name" "$discovery_root" 0 \ + "Discovery of ${discovery_label} failed: ${failure_reason}, so paths in it may never have been checked -- fix the tree; a failed discovery is NOT clean" +} + +# flag_discovery_gate RULE PATH RC [SUPPRESSED_TOTAL] +# Thin reporting wrapper: records the RULE finding for a discovered PATH that +# discovery_gate_status or discovery_containment_status rejected with non-zero +# RC. SUPPRESSED_TOTAL (default 0) is read only for DISCOVERY_GATE_RC_ESCAPES: +# the count of further escaping paths the capped containment report withheld. +flag_discovery_gate() { + local rule_name="$1" candidate_path="$2" gate_rc="$3" suppressed_total="${4:-0}" rejection_reason + case "$gate_rc" in + "$DISCOVERY_GATE_RC_MISSING") rejection_reason='is missing or is a dangling symlink' ;; + "$DISCOVERY_GATE_RC_NOT_REGULAR") rejection_reason='is not a regular file' ;; + "$DISCOVERY_GATE_RC_UNREADABLE") rejection_reason='is not readable' ;; + "$DISCOVERY_GATE_RC_NOT_DIR") rejection_reason='is not a directory' ;; + "$DISCOVERY_GATE_RC_ESCAPES") rejection_reason="does not resolve inside the checkout (a symlink leads it outside the repository root, or realpath could not canonicalise it) [${suppressed_total} further escaping path(s) from the same discovery suppressed]" ;; + *) rejection_reason="was rejected by the discovery gate with status ${gate_rc}" ;; + esac + add_finding "$rule_name" "$candidate_path" 0 \ + "this discovered path ${rejection_reason}, so it was never checked -- fix or remove it; an unchecked path is NOT clean" +} + +# discover_checked_paths RULE DEST_ARRAY GATE LABEL ROOT... -- FIND_ARGS... +# The checked discovery every scanning check calls. Runs +# discover_paths ROOT... -- FIND_ARGS... +# and reports a non-zero status through flag_discovery_failure (anchored at +# the first ROOT, described by LABEL); then, for each materialised path, +# first checks discovery_containment_status PATH and, only when it is +# contained, gates it with discovery_gate_status PATH GATE, reporting each +# gate rejection through flag_discovery_gate. Escaping paths are counted and +# reported after the loop as ONE flag_discovery_gate finding naming the first +# of them with the suppressed remainder. DEST_ARRAY is replaced with ONLY the +# accepted paths, in find order. Returns 1 when it emitted any RULE finding, +# else 0, so the caller sets its own flag: +# `discover_checked_paths ... || checkN_found=true`. +# +# INVARIANT: every path added to DEST_ARRAY passed discovery_containment_status +# before discovery_gate_status, so no gate -- `raw` included -- hands a check +# a path that resolves outside the checkout. +# +# INVARIANT: DEST_ARRAY is bound by nameref, so it must not be one of this +# function's own `checked_discovery_*` locals (the nameref would bind the +# local, not the caller's array) and, like every discover_paths DEST, must not +# be `discovery_*`-prefixed. Nested use -- a discovery inside another +# discovery's loop -- needs distinct DEST names. +discover_checked_paths() { + local checked_discovery_rule="$1" checked_discovery_gate="$3" checked_discovery_label="$4" + local -n checked_discovery_dest_ref="$2" + shift 4 + local -a checked_discovery_materialised=() + local checked_discovery_rc=0 checked_discovery_flagged=false + local checked_discovery_path checked_discovery_gate_rc + local checked_discovery_escape_first='' checked_discovery_escape_total=0 + discover_paths checked_discovery_materialised "$@" || checked_discovery_rc=$? + if [[ "$checked_discovery_rc" -ne 0 ]]; then + checked_discovery_flagged=true + flag_discovery_failure "$checked_discovery_rule" "$1" "$checked_discovery_label" "$checked_discovery_rc" + fi + checked_discovery_dest_ref=() + for checked_discovery_path in "${checked_discovery_materialised[@]}"; do + checked_discovery_gate_rc=0 + discovery_containment_status "$checked_discovery_path" || checked_discovery_gate_rc=$? + if [[ "$checked_discovery_gate_rc" -ne 0 ]]; then + if [[ "$checked_discovery_escape_total" -eq 0 ]]; then + checked_discovery_escape_first="$checked_discovery_path" + fi + checked_discovery_escape_total=$((checked_discovery_escape_total + 1)) + continue + fi + discovery_gate_status "$checked_discovery_path" "$checked_discovery_gate" || checked_discovery_gate_rc=$? + if [[ "$checked_discovery_gate_rc" -ne 0 ]]; then + checked_discovery_flagged=true + flag_discovery_gate "$checked_discovery_rule" "$checked_discovery_path" "$checked_discovery_gate_rc" + continue + fi + checked_discovery_dest_ref+=("$checked_discovery_path") + done + if [[ "$checked_discovery_escape_total" -gt 0 ]]; then + checked_discovery_flagged=true + flag_discovery_gate "$checked_discovery_rule" "$checked_discovery_escape_first" "$DISCOVERY_GATE_RC_ESCAPES" "$((checked_discovery_escape_total - 1))" + fi + if [[ "$checked_discovery_flagged" == true ]]; then + return 1 + fi + return 0 +} + # ── Timing instrumentation ────────────────────────────────────────────────── # Permanent per-check profiling (#305, precedent #304). Emits one # "[TIME]