mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
feat(safety): projection-exempt lint for gateway event sources (#2840)
* 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: <reason>` 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 `<category>, <detail>` — 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 `<category>, <detail>` format rather than the generic `<reason>`. 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) <noreply@anthropic.com> * 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: <category>, <detail>` 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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. `<word-boundary>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) <noreply@anthropic.com> * 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: <reason>` — update to `<category>, <detail>` 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
.claude/rules/gateway-events.md
Normal file
114
.claude/rules/gateway-events.md
Normal file
@@ -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: <category>, <detail>`.
|
||||
|
||||
## Annotation format
|
||||
|
||||
```rust
|
||||
state.sse.broadcast_for_user(user_id, event); // projection-exempt: channel-lifecycle, extension activation
|
||||
```
|
||||
|
||||
The `<category>` 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:
|
||||
<category>, <detail>` 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. `<word-boundary>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
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <reason>" comment.
|
||||
# For check #7, use "// dispatch-exempt: <reason>" instead.
|
||||
# For check #8, use "// web-identity-exempt: <reason>" instead.
|
||||
# For check #9, use "// projection-exempt: <category>, <detail>" 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: <reason>'."
|
||||
@@ -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: <category>, <detail>` — 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. `<word-boundary>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: <category>, <detail>'. 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: <reason>' to suppress."
|
||||
echo "(For DISPATCH warnings, use '// dispatch-exempt: <reason>' instead.)"
|
||||
echo "(For CREDNAME warnings, use '// web-identity-exempt: <reason>' instead.)"
|
||||
echo "(For PROJECTION warnings, use '// projection-exempt: <category>, <detail>' instead.)"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
170
scripts/test-pre-commit-safety.sh
Executable file
170
scripts/test-pre-commit-safety.sh
Executable file
@@ -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 <positive> | grep -vE <exclusions>`.
|
||||
# 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: <category>, <detail>`, `// 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 ]
|
||||
Reference in New Issue
Block a user