From 22ff4957c981e5d5f3df20f915ebb2978b460362 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 23 Apr 2026 00:36:19 +0900 Subject: [PATCH] feat(safety): projection-exempt lint for gateway event sources (#2840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(safety): projection-exempt lint for gateway event sources Phase 1 of the gateway state-convergence epic (#2792): add check #9 to `scripts/pre-commit-safety.sh` that flags newly-added `sse.broadcast(` / `sse.broadcast_for_user(` calls without a `// projection-exempt: ` annotation on the same line. The invariant is documented in the new `.claude/rules/gateway-events.md`: - Every `AppEvent` must project from a typed source log (engine `EventKind`, sandbox `JobEvent`, or a channel-lifecycle log). - A short transport-only allowlist (`Heartbeat`, `StreamChunk`) covers the ephemeral variants with no state backing them. - Direct emits are the root cause of the state-drift class — UI stream and replayable source end up with different stories. Four recent incidents (#2654, #2534, #2731, #2079) share this shape. The lint is diff-based, so pre-existing unannotated call sites aren't broken. Baseline annotation of the ~20 existing emit sites is the next PR under Phase 1 — this one establishes the gate. Suppressions require a named category (`bridge dispatcher`, `channel-lifecycle`, `sandbox JobEvent`, `transport-only, heartbeat`, or `migrate in #NNNN`). An unnamed `legacy` reason is rejected by review, not by the lint itself. Tested locally: - Fires on unannotated `sse.broadcast(...)` in a new file. - Suppressed by `// projection-exempt: transport-only, heartbeat`. - Does not match `Channel::broadcast` (different trait). - Does not match calls inside `#[cfg(test)] mod tests` blocks (via the shared `strip_test_mod_lines` filter). Refs: #2792, #2654 * refactor(safety): address review feedback on projection-exempt check Four review comments from Copilot and Gemini on #2840: 1. **Match rustfmt's method-chain wrapping.** The original regex only caught same-line `sse.broadcast(...)`. Long calls like `state\n .sse\n .broadcast_for_user(...)` — produced by rustfmt and already in-tree at `src/channels/web/features/extensions/mod.rs:645` — would bypass the check. New matcher adds a dangling-method alternation that catches `.broadcast_for_user(` at line start. Only the `_for_user` suffix (SseManager-unique) is matched in dangling form; bare `.broadcast(` can be `Channel::broadcast` trait, which is intentionally out of scope. 2. **Enforce the documented annotation format.** The check previously accepted any `// projection-exempt:` comment, including bare `// projection-exempt: legacy` that the rule doc explicitly forbids. Negative filter now requires `, ` — presence of a comma separating the category from the detail. 3. **Point at the real path in the warning.** Replace `bridge::thread_event_to_app_events` with `thread_event_to_app_events` in `src/bridge/router.rs` — the actual file location. 4. **Update suppression hint** to show the `, ` format rather than the generic ``. Verified against a 6-case fixture (same-line fire + suppress, dangling-chain fire + suppress, unnamed-category fire, `Channel::broadcast` silent). Refs: #2792, #2840 review * fix(safety): match header exclusion against grep -n prefixed output After `grep -nE '^\+'`, every line is prefixed with `N:`, so the `^\+\+\+` anchor for filtering diff header lines (`+++ b/file.rs`) never fires. The positive patterns already exclude header lines by shape, so today this is harmless — but the dead branch masks future defense-in-depth failures if the template is reused with a less specific positive match. Replace `^\+\+\+` with `:\+\+\+ ` in DISPATCH, CREDNAME, and PROJECTION checks so the exclusion works against the `grep -n` output shape. Co-Authored-By: Claude Opus 4.7 (1M context) * test(safety): regression for grep-n-prefixed header exclusion Covers PROJECTION / DISPATCH / CREDNAME pipelines: - diff header lines (`+++ b/path`) are filtered after `grep -n` - real broadcast/state/CredentialName lines are still flagged - `// projection-exempt: , ` exempts - bare `// projection-exempt: legacy` (no comma) is not exempt Locks in that `:\+\+\+ ` (matches the `grep -n` prefixed shape) behaves as intended, where the prior `^\+\+\+` anchor silently never fired. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(deny): ignore RUSTSEC-2026-0104 (rustls-webpki CRL panic) Same transitive pin as 0049/0098/0099 — rustls-webpki 0.102.8 is held by libsql 0.6.0 → rustls 0.22 → hyper-rustls 0.25. The advisory explicitly notes that applications not parsing CRLs are unaffected; we do not parse CRLs. [skip-regression-check] — deny.toml-only config change. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(safety): portable grep boundary + broadened broadcast_for_user match Two PROJECTION bypass paths flagged in review: 1. `\b` is a GNU-grep extension (works in grep 3.x, not portable to BSD grep on macOS dev envs) — replace with `(^|[^[:alnum:]_])sse\.` so the check fires uniformly across `grep -E` implementations. 2. `broadcast_for_user(...)` on a non-`sse` receiver (e.g. `manager.broadcast_for_user(...)`) previously slipped through. The method is defined only on `SseManager` (`src/channels/web/platform/sse.rs:144`), so matching `\.broadcast_for_user\(` on any receiver is safe and makes the enforcement match the documented rule. Regression tests extended: chained-receiver, non-`sse` receiver, bare `sse.broadcast(`, and a portable-boundary negative case (identifier ending in `sse`). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(gateway-events): align matcher description with broadened check Update the enforcement section to describe the two current PROJECTION matcher shapes after the review follow-up in the preceding commit: 1. Any-receiver `.broadcast_for_user(...)` — catches the non-`sse` receiver bypass and rustfmt wraps alike. 2. `sse.broadcast(...)` with a portable boundary (`(^|[^[:alnum:]_])`), which is needed because `grep -E`'s `\b` is a GNU extension and not available on BSD grep. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(safety): tighten CREDNAME + projection-exempt lints, sync header Three follow-ups from the review: 1. CREDNAME portability — `\bCredentialName\b` used GNU-grep `\b`, which BSD grep does not recognise. Replace with the same `(^|[^[:alnum:]_])…([^[:alnum:]_]|$)` boundary used for PROJECTION and matches cleanly across GNU and BSD `grep -E`. 2. Empty-detail suppression bypass — `// projection-exempt: [^,]+,` accepted `// projection-exempt: foo,` (empty detail) as exempt even though `.claude/rules/gateway-events.md` requires a non-empty detail. Tighten to `[^,]+,[[:space:]]*[^[:space:]]` so a comma without a trailing token still fires the check. 3. Header suppression hint (`#24`) said `// projection-exempt: ` — update to `, ` to match what the check actually accepts so contributors don't copy an unsupported format. Regression tests extended: `PROJECTION: empty detail after comma still flagged`, `PROJECTION: comma + whitespace-only detail still flagged`, `CREDNAME: CredentialNameExt (different type) is not flagged`. All 16 cases pass. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/rules/gateway-events.md | 114 ++++++++++++++++++++ deny.toml | 4 + scripts/pre-commit-safety.sh | 54 +++++++++- scripts/test-pre-commit-safety.sh | 170 ++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 .claude/rules/gateway-events.md create mode 100755 scripts/test-pre-commit-safety.sh diff --git a/.claude/rules/gateway-events.md b/.claude/rules/gateway-events.md new file mode 100644 index 0000000000..6b181f46ef --- /dev/null +++ b/.claude/rules/gateway-events.md @@ -0,0 +1,114 @@ +--- +paths: + - "src/**" + - "crates/**" +--- +# Gateway Events — Single Source of Truth + +Every `AppEvent` reaching the SSE/WS stream must come from a **typed +source log**, or be on a small **transport-only allowlist**. Direct +`sse.broadcast(...)` / `sse.broadcast_for_user(...)` calls from tools, +handlers, or extension managers are the root cause of the UI state +drift class tracked by #2792 — the stream and the replayable source +end up telling different stories. + +This is the Phase 1 rule of the gateway state-convergence epic. + +## Why + +When `AppEvent` has producers outside the projection layer, those +producers become a second source of truth. On SSE reconnect, replay +from the engine event log can't reconstruct them (they were never +logged). On tab focus, reconciliation against a GET endpoint can't +confirm them (no persisted state backs them). Four recent bugs +(#2654, #2534, #2731, #2079) share this shape: broadcast emitted, +backend state unchanged, UI diverges. + +## Source logs + +Every `AppEvent` projects from exactly one of: + +| Source log | Projection function | Typical variants | +|---|---|---| +| `ironclaw_engine::EventKind` | `src/bridge/router.rs::thread_event_to_app_events` | Turn progression, tool execution, gates, leases, child threads, skills | +| Sandbox `JobEvent` | `src/worker/job.rs` (currently inline; extract under #2792 Phase 1 PR 3) | `JobStarted`, `JobMessage`, `JobToolUse`, `JobToolResult`, `JobStatus`, `JobResult` | +| Channel-lifecycle logs | `src/channels/web/features/oauth/`, `features/pairing/`, `features/extensions/`, `extensions/manager.rs` | `OnboardingState`, `ExtensionStatus` | + +## Transport-only allowlist + +A small number of `AppEvent` variants don't project from anything +because they have no state backing them. These are documented +exceptions, not a loophole for new state: + +- `Heartbeat` — SSE keepalive, no payload, no state +- `StreamChunk` — LLM token streaming, pre-step-completion by design; formalizing into `EventKind` would pollute the durable log with token-level noise + +New `AppEvent` variants that claim "transport-only" status require +review sign-off and an entry in this table. + +## The rule + +**No call to `SseManager::broadcast` / `SseManager::broadcast_for_user` +is allowed outside:** + +1. The projection dispatcher loop that consumes one of the three source + logs above, **or** +2. A line annotated with `// projection-exempt: , `. + +## Annotation format + +```rust +state.sse.broadcast_for_user(user_id, event); // projection-exempt: channel-lifecycle, extension activation +``` + +The `` must name either: + +- A source log — `bridge dispatcher`, `sandbox JobEvent`, `channel-lifecycle` — plus a short detail. +- A transport-only allowlist entry — `transport-only, heartbeat` or `transport-only, stream_chunk`. +- A scheduled migration — `migrate in #NNNN` where the issue tracks moving the emit into a source log. + +An unnamed category (`// projection-exempt: legacy`) is not sufficient. +Either the site is legitimately exempt and the category explains why, +or it's a violation and should be migrated. + +## Enforcement + +Check #9 in `scripts/pre-commit-safety.sh` (label: `PROJECTION`) flags +added lines that call `SseManager::broadcast` or +`SseManager::broadcast_for_user` without a `// projection-exempt: +, ` annotation on the same line. The comma is +required — the check rejects bare `// projection-exempt: legacy`. Lines +in `#[cfg(test)] mod tests` blocks and under `tests/` are skipped via +the shared `strip_test_mod_lines` filter. + +The matcher covers two call-site shapes: + +1. Any-receiver `.broadcast_for_user(...)` — the method is defined + only on `SseManager` (`src/channels/web/platform/sse.rs`), so + matching the method name alone catches same-line receivers + (`state.sse.broadcast_for_user(...)`), rustfmt wraps + (`state\n .sse\n .broadcast_for_user(...)`), and any other + receiver name (`manager.broadcast_for_user(...)`) without + false-positive risk. +2. `sse.broadcast(...)` — the single-name `broadcast` + is shared with the `Channel` trait, so this arm is deliberately + narrower and only fires when the receiver is literally named + `sse`. The boundary uses `(^|[^[:alnum:]_])` rather than `\b` so + the check is portable across GNU and BSD `grep -E`. + +## Not covered by this rule + +- **`Channel::broadcast` on the `Channel` trait.** Different method, + different trait, different semantics (delivery to a specific channel + endpoint like Telegram, not to SSE subscribers). The `Channel` trait + has its own invariants in `src/channels/`. +- **Non-SSE `broadcast` methods.** If you're broadcasting on a + `tokio::sync::broadcast::Sender` directly, you're below the + `AppEvent` abstraction; the rule doesn't apply. + +## References + +- Epic: #2792 — Gateway state convergence +- Coverage: #2654 — Engine→AppEvent bridge gaps +- Incidents: #2079 (SSE ordering), #2534 (stale approval), #2731 (Telegram thread split) +- Rule cluster: `.claude/rules/types.md` for wire-stable enums; `.claude/rules/tools.md` for the parallel "everything goes through tools" rule this mirrors diff --git a/deny.toml b/deny.toml index 70ef8cf0ce..b5d2afabae 100644 --- a/deny.toml +++ b/deny.toml @@ -9,9 +9,13 @@ ignore = [ "RUSTSEC-2025-0111", # rustls-webpki advisories — 0.102.8 remains pinned by a libsql 0.6.0 transitive dep # (via rustls 0.22 → hyper-rustls 0.25); keep ignored until that pin is gone. + # RUSTSEC-2026-0104: panic on empty `onlySomeReasons` BIT STRING during CRL parsing. + # We do not use CRLs, and the advisory explicitly notes apps that don't parse CRLs + # are unaffected. Same transitive pin as 0049/0098/0099 — tracked with them. "RUSTSEC-2026-0049", "RUSTSEC-2026-0098", "RUSTSEC-2026-0099", + "RUSTSEC-2026-0104", # rand unsoundness with custom logger calling rand::rng() during reseed — we don't use this pattern; # revisit/remove by 2026-06-30, or when transitive deps (tower, nanoid, phf_generator) release rand ≥0.9.3 compat "RUSTSEC-2026-0097", diff --git a/scripts/pre-commit-safety.sh b/scripts/pre-commit-safety.sh index 4432d98d4e..e704dba087 100755 --- a/scripts/pre-commit-safety.sh +++ b/scripts/pre-commit-safety.sh @@ -13,6 +13,7 @@ # 6. .unwrap(), .expect(), assert!() in production code (panics) # 7. Gateway/CLI handlers bypassing ToolDispatcher (must go through tools) # 8. CredentialName referenced in web-layer code (wrong identity at boundary) +# 9. SSE broadcast emitted outside the engine→AppEvent projection bridge # # Also runs check-i18n-parity.sh when crates/ironclaw_gateway/static/i18n/*.js # files are staged, to ensure every language pack has the same key set. @@ -20,6 +21,7 @@ # Suppress individual lines with an inline "// safety: " comment. # For check #7, use "// dispatch-exempt: " instead. # For check #8, use "// web-identity-exempt: " instead. +# For check #9, use "// projection-exempt: , " instead. set -euo pipefail @@ -342,7 +344,7 @@ fi if [ -n "$DISPATCH_DIFF" ]; then DISPATCH_HITS=$(echo "$DISPATCH_DIFF" | grep -nE '^\+' \ | grep -E 'state\.(store|workspace|workspace_pool|extension_manager|skill_registry|session_manager)\.' \ - | grep -vE '// dispatch-exempt:|// safety:|^\+\+\+' \ + | grep -vE '// dispatch-exempt:|// safety:|:\+\+\+ ' \ | head -5 || true) if [ -n "$DISPATCH_HITS" ]; then warn "DISPATCH" "Handler directly touches state.{store,workspace,extension_manager,skill_registry,session_manager}. Route through ToolDispatcher::dispatch() instead. See .claude/rules/tools.md." @@ -370,9 +372,12 @@ if [ -n "$WEB_IDENTITY_DIFF" ]; then # Strip lines inside `#[cfg(test)] mod tests` blocks using the same # precomputed boundaries used for other prod-only checks. WEB_IDENTITY_PROD=$(printf '%s\n' "$WEB_IDENTITY_DIFF" | strip_test_mod_lines) + # `(^|[^[:alnum:]_])CredentialName([^[:alnum:]_]|$)` is a portable + # word boundary; `grep -E`'s `\b` is a GNU extension and is not + # recognised by BSD grep. WEB_IDENTITY_HITS=$(echo "$WEB_IDENTITY_PROD" | grep -nE '^\+' \ - | grep -E '\bCredentialName\b' \ - | grep -vE '// web-identity-exempt:|// safety:|^\+\+\+' \ + | grep -E '(^|[^[:alnum:]_])CredentialName([^[:alnum:]_]|$)' \ + | grep -vE '// web-identity-exempt:|// safety:|:\+\+\+ ' \ | head -5 || true) if [ -n "$WEB_IDENTITY_HITS" ]; then warn "CREDNAME" "\`CredentialName\` referenced in src/channels/web/** — web code takes \`ExtensionName\`; credential identity stays backend-side. Push the mapping into bridge::auth_manager or annotate with '// web-identity-exempt: '." @@ -380,11 +385,54 @@ if [ -n "$WEB_IDENTITY_DIFF" ]; then fi fi +# 9. SSE `AppEvent` broadcast outside the engine→AppEvent projection bridge. +# Every `AppEvent` that hits the SSE/WS stream should project from a typed +# source log (`ironclaw_engine::EventKind`, `JobEvent`, channel-lifecycle) +# or belong to the documented transport-only allowlist. Direct +# `sse.broadcast(...)` / `sse.broadcast_for_user(...)` calls from tools, +# handlers, or extension managers drift the UI stream out of alignment +# with the replayable source, which is the root cause of the state +# desync class tracked by #2792. See `.claude/rules/gateway-events.md`. +# +# Annotation format: `// projection-exempt: , ` — the +# category names the source log (`bridge dispatcher`, `channel-lifecycle`, +# `sandbox JobEvent`, `legacy v1 auth`) or the transport-only allowlist +# (`transport-only, heartbeat`). The comma is required — an unnamed +# `// projection-exempt: legacy` does not suppress the check. +# +# Two match patterns: +# 1. `*.broadcast_for_user(...)` on any receiver — `broadcast_for_user` +# is unique to `SseManager` (see `src/channels/web/platform/sse.rs`), +# so matching the method name alone catches both same-line +# receivers (`state.sse.broadcast_for_user(...)`) and rustfmt +# wraps (`state\n .sse\n .broadcast_for_user(...)`) without +# risk of false positives from other types. +# 2. `sse.broadcast(...)` — the single-name `.broadcast(` +# on its own line can be the `Channel` trait method, so this arm +# is deliberately narrower and anchors on an `sse` receiver. +# `(^|[^[:alnum:]_])sse\.` is a portable boundary; `grep -E`'s +# `\b` is a GNU extension and is not recognised by BSD grep, so +# we avoid it here. +# Suppression regex requires a non-empty detail after the comma: +# `[^,]+,[[:space:]]*[^[:space:]]` — `// projection-exempt: foo,` +# (empty detail) does NOT exempt; `// projection-exempt: foo, bar` +# does. This matches the documented contract in +# `.claude/rules/gateway-events.md`. +PROJECTION_HITS=$(echo "$DIFF_OUTPUT_NO_TESTS" | grep -nE '^\+' \ + | grep -E '(\.broadcast_for_user|(^|[^[:alnum:]_])sse\.broadcast)[[:space:]]*\(' \ + | grep -vE '// projection-exempt: [^,]+,[[:space:]]*[^[:space:]]|// safety:|:\+\+\+ ' \ + | head -5 || true) +if [ -n "$PROJECTION_HITS" ]; then + warn "PROJECTION" "Direct SSE broadcast outside the engine→AppEvent bridge. Route through \`thread_event_to_app_events\` in \`src/bridge/router.rs\` (project from a typed source log) or annotate with '// projection-exempt: , '. See .claude/rules/gateway-events.md." + echo "$PROJECTION_HITS" | sed 's/^/ /' +fi + if [ "$WARNINGS" -gt 0 ]; then echo "" echo "Found $WARNINGS potential issue(s). Fix them or add '// safety: ' to suppress." echo "(For DISPATCH warnings, use '// dispatch-exempt: ' instead.)" echo "(For CREDNAME warnings, use '// web-identity-exempt: ' instead.)" + echo "(For PROJECTION warnings, use '// projection-exempt: , ' instead.)" echo "" exit 1 fi diff --git a/scripts/test-pre-commit-safety.sh b/scripts/test-pre-commit-safety.sh new file mode 100755 index 0000000000..f26459fe7f --- /dev/null +++ b/scripts/test-pre-commit-safety.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Regression tests for the grep pipelines in `pre-commit-safety.sh`. +# +# The PROJECTION / DISPATCH / CREDNAME checks all pipe through +# `grep -nE '^\+' | grep -E | grep -vE `. +# A previous version of the exclusion regex used `^\+\+\+` to filter +# diff header lines (`+++ b/file.rs`), which silently never fired — +# `grep -n` prepends a `N:` line-number prefix, so `^` no longer +# anchors against the `+++` bytes. This test locks in the corrected +# `:\+\+\+ ` shape. + +set -euo pipefail +cd "$(dirname "$0")/.." + +PASS=0 +FAIL=0 + +assert_filtered() { + local label="$1" input="$2" positive="$3" exclusions="$4" + # Emulate the production pipeline: `grep -n '^+'` adds the line-number + # prefix, then positive/negative filters run against that shape. + local result + if result=$(printf '%s\n' "$input" \ + | grep -nE '^\+' \ + | grep -E "$positive" \ + | grep -vE "$exclusions" \ + | head -5 || true); then :; fi + if [ -z "${result:-}" ]; then + echo "OK: $label (correctly filtered)" + PASS=$((PASS + 1)) + else + echo "FAIL: $label — line leaked past exclusions:" + echo "$result" | sed 's/^/ /' + FAIL=$((FAIL + 1)) + fi +} + +assert_flagged() { + local label="$1" input="$2" positive="$3" exclusions="$4" + local result + if result=$(printf '%s\n' "$input" \ + | grep -nE '^\+' \ + | grep -E "$positive" \ + | grep -vE "$exclusions" \ + | head -5 || true); then :; fi + if [ -n "${result:-}" ]; then + echo "OK: $label (correctly flagged)" + PASS=$((PASS + 1)) + else + echo "FAIL: $label — line not flagged by positive pattern" + FAIL=$((FAIL + 1)) + fi +} + +# ── PROJECTION ──────────────────────────────────────────────── +# Positive: any `.broadcast_for_user(` (SseManager-unique method) or +# `sse.broadcast(` with a portable word boundary. +# Exclusions: `// projection-exempt: , `, `// safety:`, +# and diff-header lines (`+++ b/path`) via `:\+\+\+ `. +PROJ_POS='(\.broadcast_for_user|(^|[^[:alnum:]_])sse\.broadcast)[[:space:]]*\(' +PROJ_NEG='// projection-exempt: [^,]+,[[:space:]]*[^[:space:]]|// safety:|:\+\+\+ ' + +# Diff header lines must be filtered. +assert_filtered "PROJECTION: diff header line is filtered" \ + "+++ b/src/bridge/router.rs" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# A real broadcast call is flagged. +assert_flagged "PROJECTION: bare sse.broadcast_for_user is flagged" \ + "+ sse.broadcast_for_user(&user, event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Chained receiver (state.sse.broadcast_for_user) is flagged. +assert_flagged "PROJECTION: chained state.sse.broadcast_for_user is flagged" \ + "+ state.sse.broadcast_for_user(&user, event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# A rustfmt-wrapped call is flagged. +assert_flagged "PROJECTION: rustfmt-wrapped .broadcast_for_user is flagged" \ + "+ .broadcast_for_user(&user, event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Non-`sse` receiver must still fire — `broadcast_for_user` is unique to +# SseManager, so the method name alone is authoritative. +assert_flagged "PROJECTION: non-sse receiver .broadcast_for_user is flagged" \ + "+ manager.broadcast_for_user(&user, event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Plain sse.broadcast call is flagged via the portable word boundary. +assert_flagged "PROJECTION: bare sse.broadcast is flagged" \ + "+ sse.broadcast(event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# The portable boundary must not fire on a longer identifier that ends +# in 'sse' (e.g. `usse.broadcast(...)` — not a real SseManager). +assert_filtered "PROJECTION: identifier ending in sse is not flagged" \ + "+ usse.broadcast(event);" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Correctly annotated call is exempted. +assert_filtered "PROJECTION: annotated call with category+detail is exempt" \ + "+ sse.broadcast_for_user(&user, event); // projection-exempt: bridge dispatcher, auth gate" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Bare `// projection-exempt: legacy` (no comma, no detail) does NOT exempt. +assert_flagged "PROJECTION: unnamed 'legacy' suppression still flagged" \ + "+ sse.broadcast_for_user(&user, event); // projection-exempt: legacy" \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Empty detail after the comma (`// projection-exempt: foo,`) does NOT +# exempt — the documented format requires a non-empty detail. +assert_flagged "PROJECTION: empty detail after comma still flagged" \ + "+ sse.broadcast_for_user(&user, event); // projection-exempt: foo," \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# Trailing whitespace after the comma without a detail also does NOT exempt. +assert_flagged "PROJECTION: comma + whitespace-only detail still flagged" \ + "+ sse.broadcast_for_user(&user, event); // projection-exempt: foo, " \ + "$PROJ_POS" \ + "$PROJ_NEG" + +# ── DISPATCH ────────────────────────────────────────────────── +DISPATCH_POS='state\.(store|workspace|workspace_pool|extension_manager|skill_registry|session_manager)\.' +DISPATCH_NEG='// dispatch-exempt:|// safety:|:\+\+\+ ' + +assert_filtered "DISPATCH: diff header line is filtered" \ + "+++ b/src/channels/web/handlers/foo.rs" \ + "$DISPATCH_POS" \ + "$DISPATCH_NEG" + +assert_flagged "DISPATCH: direct state.store touch is flagged" \ + "+ state.store.create_project(...)" \ + "$DISPATCH_POS" \ + "$DISPATCH_NEG" + +# ── CREDNAME ────────────────────────────────────────────────── +# Portable word boundary: `(^|[^[:alnum:]_])` / `([^[:alnum:]_]|$)` — +# `grep -E`'s `\b` is a GNU extension and not recognised by BSD grep. +CREDNAME_POS='(^|[^[:alnum:]_])CredentialName([^[:alnum:]_]|$)' +CREDNAME_NEG='// web-identity-exempt:|// safety:|:\+\+\+ ' + +assert_filtered "CREDNAME: diff header line is filtered" \ + "+++ b/src/channels/web/features/settings.rs" \ + "$CREDNAME_POS" \ + "$CREDNAME_NEG" + +# A similarly-named but distinct identifier must not fire. +assert_filtered "CREDNAME: CredentialNameExt (different type) is not flagged" \ + "+ let ext: CredentialNameExt = ...;" \ + "$CREDNAME_POS" \ + "$CREDNAME_NEG" + +assert_flagged "CREDNAME: bare CredentialName reference is flagged" \ + "+ let name: CredentialName = ...;" \ + "$CREDNAME_POS" \ + "$CREDNAME_NEG" + +echo "" +echo "Passed: $PASS, Failed: $FAIL" +[ "$FAIL" -eq 0 ]