Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-MCP-AUDIT-ON-DENIAL — emit audit at cap-gate denial#150

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-MCP-AUDIT-ON-DENIAL
May 23, 2026
Merged

feat(kora): KR-MCP-AUDIT-ON-DENIAL — emit audit at cap-gate denial#150
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-MCP-AUDIT-ON-DENIAL

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

Activates CC#1's pinned forward-compat alert rule from KR-ALERTS-PANEL-FLIP (#145). The `capability_denied_24h` rule already greps `mcp.tool_called` audit JSONL for `details.result == "capability_denied"`, but the cap-gate at `listeners/mcp.py:_check_cap_gate` returned -32001 before ever reaching the audit emit — so the rule was emitting zero alerts. This PR wires the audit emit at the gate; the rule now fires when the threshold is exceeded.

Two denial paths audited

Both write `seam=mcp.tool_called` BEFORE returning the -32001 envelope. Best-effort emit (sink failure is caught + logged but does not mask the denial response — verified by `test_cap_gate_denial_audit_emitted_before_envelope`).

  • `_emit_capability_denied_audit` — `details.result="capability_denied"`. Fires when `_check_cap_gate` returns a -32001 envelope (caller's `allowed_caps` doesn't include the tool).
  • `_emit_actor_id_required_audit` — `details.result="actor_id_required"`. Fires from the `kora__request_stop` extra-gate path introduced in KR-MCP-STOP-CONTROL ST2 (feat(kora): KR-MCP-STOP-CONTROL ST2 — stop tool + kora_control writer + actor_id #147). Distinct discriminator — the alert rule's `detail_match` doesn't conflate this with a cap denial since they're different operator-fix paths (cap-not-granted vs actor_id-field-absent in `mcp_callers.yaml`).

Schema (both denial paths)

field value
`seam` `mcp.tool_called`
`source` `mcp_http`
`details.tool_name` tool that was denied
`details.tool_kind` `mutating`
`details.caller_actor_kind` resolved `Caller.actor_kind`
`details.caller_actor_id` resolved `Caller.actor_id` (or `None`)
`details.required_capability` tool name (mirrors error `data.required_capability`)
`details.duration_ms` `0` (no executor ran)
`details.tool_status` `not_allowed`
`details.result` `capability_denied` OR `actor_id_required`

Reason text NEVER in audit (caller-supplied string risk) — same posture as the stop-tool's success-path audit.

CC#1 pinned test updated

`tests/kora_cli/alerts/test_aggregator.py`:

  • Renamed `test_capability_denied_today_no_alert_since_audit_doesnt_emit_denials` → `test_capability_denied_today_emits_alert_when_threshold_exceeded`
  • Flipped behavior: asserts the alert fires exactly once when 11 `capability_denied` audit rows accumulate in 24h. Mixes in 7 `ok` + 5 `actor_id_required` rows to verify `detail_match` correctly excludes them (alert count = 11, not 23).
  • `kora_cli/alerts/aggregator.py` module docstring + `_rules_from_capability_denied_audit` function docstring updated to reflect the now-live wiring.

Test plan

  • 8 new tests in `tests/kora_cli/test_listeners/test_mcp_audit_on_denial.py` cover the cap-gate happy + sad paths, missing-actor_id audit shape, audit-emitted-before-envelope (sink failure doesn't mask denial), executor-not-invoked-on-denial, no-denial-audit-on-success, and SECURITY (bearer-token-never-in-audit-row).
  • 27 tests in `tests/kora_cli/alerts/test_aggregator.py` green including the renamed pinned test.
  • Full `tests/kora_cli/test_listeners` + `tests/kora_cli/alerts` + `tests/kora_cli/clients` regression green (413 passed).
  • Full `tests/kora_cli + tests/agent` regression: 9097 passed; 47 failed identical to ST2 baseline (zero new regressions).
  • Walk-payload security sweep passes — bearer token never serialized into any audit row.

After this lands, the alerts panel surfaces real "N capability_denied responses in 24h" warnings when callers are misconfigured.

🤖 Generated with Claude Code

Activates CC#1's pinned-no-alert rule from KR-ALERTS-PANEL-FLIP
(#145). Two denial paths now write JSONL audit rows BEFORE
returning the -32001 envelope, so the capability_denied_24h alert
rule surfaces real warnings when callers are misconfigured.

Two helpers in kora_cli/listeners/mcp.py:

  - _emit_capability_denied_audit — fires at the cap-gate (when
    _check_cap_gate returns a -32001 envelope). Writes
    seam=mcp.tool_called with details.result="capability_denied"
    plus tool_name, tool_kind="mutating", caller_actor_kind,
    caller_actor_id, required_capability, duration_ms=0,
    tool_status="not_allowed".

  - _emit_actor_id_required_audit — fires at the actor_id-required
    gate inside kora__request_stop (the extra gate from
    KR-MCP-STOP-CONTROL ST2). Same shape but
    details.result="actor_id_required" — distinct discriminator
    so the alert rule's detail_match doesn't conflate two
    different operator-fix paths (cap-not-granted vs
    actor_id-field-absent in mcp_callers.yaml).

Both helpers are best-effort emit: any exception from the audit
sink is caught + logged but does NOT mask the denial response
to the caller (verified by test_cap_gate_denial_audit_emitted_
before_envelope).

Reason text NEVER in the denial audit (caller-supplied string
might leak context) — same posture as the stop-tool's
success-path audit from #147.

Documentation:

  - kora_cli/alerts/aggregator.py docstring: removed the
    forward-compat "rule emits zero today" block; replaced with
    a description of the now-live wiring + the two result
    discriminators.

Tests:

  - tests/kora_cli/test_listeners/test_mcp_audit_on_denial.py:
    8 new tests covering cap-gate denial audit shape, missing
    actor_id audit shape, audit-emitted-before-envelope (sink
    failure doesn't mask denial), executor-not-invoked-on-
    denial, no-denial-audit-on-success, and the SECURITY sweep
    (bearer token never in any audit row).

  - tests/kora_cli/alerts/test_aggregator.py: renamed
    test_capability_denied_today_no_alert_since_audit_doesnt_emit_denials
    → test_capability_denied_today_emits_alert_when_threshold_exceeded.
    Flipped behavior to assert the alert fires when 11+
    capability_denied audit rows accumulate in 24h. The new
    test also seeds 7 "ok" + 5 "actor_id_required" rows to
    verify the rule's detail_match correctly excludes them
    (alert count is 11, not 23).

Full regression: 9097 passed; 47 failed identical to ST2
baseline = zero new regressions. All failures are pre-existing
in tests/agent/test_anthropic_adapter.py +
tests/kora_cli/test_web_server*.py — unrelated to this PR.

After this lands, the alerts panel surfaces real
"N capability_denied responses in 24h" warnings when callers
are misconfigured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit dcedbdc into feature/phase2-upgrades May 23, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-MCP-AUDIT-ON-DENIAL branch May 23, 2026 21:03
rafe-walker added a commit that referenced this pull request May 23, 2026
…152)

CI determinism restored. 77 insertions across 3 files — test-side only, no production changes.

Diagnostic findings:
1. Two leak sources identified — both from CC#3 prior buckets:
   - test_mcp_tools_stop_control.py (PR #142, ST1) — 13 sites set h_mod._HOLDER = holder with no teardown
   - test_mcp_audit_on_denial.py (PR #150, ST3) — 1 success-path test does the same
2. Why xdist surfaces it: xdist workers are separate processes (no cross-worker bleed), but within a worker tests run sequentially in scheduler order — and xdist worksteal scheduler does not preserve serial mode alphabetic file ordering that happens to put handlers/ before test_listeners/.
3. Why serial does not flake: alphabetic ordering means email handler tests always precede the mutating test_listeners tests in serial mode.

Fix: three autouse _reset_operational_state_holder fixtures — two plug the leak sources, the third is defensive belt-and-suspenders on the email handler tests so a future leak source cannot re-poison them. Pattern documented in fixture docstrings + PR body.

Production code (the _HOLDER module-level singleton) untouched — spec §3 explicit non-scope. Test-side fix only.

30/30 xdist verification runs all green; 5/5 spec-mandated runs all green; zero new failures vs ST3 baseline.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant