Skip to content

fix(audit): name the profile rule that refused a call in the authz reason - #1715

Merged
Dumbris merged 1 commit into
smart-mcp-proxy:mainfrom
james-crowley:fix/audit-profile-refusal-reasons
Oct 22, 2026
Merged

Dumbris merged 1 commit into
smart-mcp-proxy:mainfrom
james-crowley:fix/audit-profile-refusal-reasons

Conversation

@james-crowley

Copy link
Copy Markdown
Contributor

Description

Fixes #1703. A call refused by a profile's tier cap, deny rule or unannotated-tool policy was audited as authz deny / reason: other, while the activity log carried block_reason: profile_tier|profile_rule|profile_unannotated. The typed cause reached emitActivityPolicyDecisionWithBlockReason but never auditAuthz.

  • The three tool-policy causes are now authz reasons (profile_tier, profile_rule, profile_unannotated, disclosed: true) through the one funnel, so top-level call_tool_*, direct mode and replay all get them.
  • The nested code_execution path used token_scope with disclosed: false for a refusal the script reads in full. It now uses report.BlockReason for the same three reasons. Its other refusals are unchanged.
  • profile_scope is unchanged. profile_code_execution and profile_management are not audit reasons (built-in invocations never reach the audit stream), as discussed in the issue. They still map to other if they ever arrive.
  • telemetry.BlockReason* is not touched: the availability counters keep their fixed enum.
  • Enum added to docs/schemas/audit-line-v1.schema.json and the Spec 107 contract schema (the schema tests check both), plus audit-log.md, the contract doc and the CHANGELOG. This is an additive change to the reason enum, so a consumer that validates audit lines against it must accept the new members; the CHANGELOG says so.

Testing

  • I have tested these changes locally
  • I have added/updated tests that prove my fix is effective or my feature works
  • All existing tests pass

New tests (internal/server/audit_profile_reason_test.go): a table for the mapping, top-level and nested refusals for each of the three reasons (validated against the contract schema), and an allowed call that is still allow. The top-level tests fail without the fix (other).

go test ./internal/audit ./internal/jsruntime ./internal/telemetry pass. In internal/server the audit, profile, code-execution, direct, replay, activity and scope tests pass except TestCodeExecClientModeE2E/client_mode_when_daemon_running, which also fails on a clean main in my environment (daemon not ready after 15s). I'm relying on CI for the rest of the package.

…ason

A call refused by a profile's tier cap, deny rule or unannotated-tool
policy was written to the audit log as authz deny / reason other, while
the activity log carried block_reason profile_tier|profile_rule|
profile_unannotated. The typed cause reached the funnel but not
auditAuthz.

Pass it through: the three tool-policy causes are now authz reasons
(profile_tier, profile_rule, profile_unannotated, disclosed:true), in the
top-level gates, direct mode and replay (one funnel) and in the nested
code_execution path, which used to write token_scope / disclosed:false
for a refusal the script reads in full. profile_scope is unchanged;
profile_code_execution and profile_management are not audit reasons
(built-in invocations are not audited per call).

Schema enum, audit-log docs, the Spec 107 contract and CHANGELOG updated.

Fixes smart-mcp-proxy#1703
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Dumbris Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on a merge with main: build, CI lint config, targeted race run of the audit/profile/code_execution/direct/replay tests; cross-reviewed clean. Three specific reasons, profile_scope unchanged, nested code_execution path folded in, both schema copies updated identically, as discussed on #1703. The earlier red E2E job was the main-branch flake fixed in #1733.

@Dumbris
Dumbris merged commit 81c9206 into smart-mcp-proxy:main Oct 22, 2026
65 of 67 checks passed
@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

Merged, and #1703 is closed with it. Thank you, @james-crowley! The audit stream now says why a profile refused a call (profile_tier, profile_rule, profile_unannotated) instead of other, through the one funnel, so an external reader finally sees the same cause the activity log always had. The nested code_execution path getting the typed reason too was the detail that made this complete.

Contributions like this are what keep MCPProxy moving, and they matter a lot to the project.

One small ask, only if you feel like it: a short public note about your change (a tweet, a LinkedIn post, a line in a community you are part of) helps more people discover MCPProxy. No pressure at all, and I would be glad to repost it.

Dumbris added a commit that referenced this pull request Oct 22, 2026
## Description

`TestInformationalRescan_BusyServerIsRetriedNotDropped` (Spec 118,
#1689) failed on `main` in 4 of the last 8 unit-test runs and was the
only red check on contributor PRs #1727, #1725 and #1715.

**Cause.** `fireInformationalRescan` deletes the server's entry from
`infoRescanTimers` when the debounce timer fires and re-creates it only
after the gates and the claim ran. On every busy fire there is a short
window in which "is a timer armed" is false. The test sampled the map at
one fixed instant (`time.Sleep(4 * debounce)`), which under CI load
lands in that window. Reproduced locally: 1 failure in 150 runs with
`GOMAXPROCS=1` under 2×ncpu CPU burners.

**Fix (test only).** Read `infoRescanSeq` under the mutex before
scheduling; the change itself takes `before+1`, so
`require.Eventually(rescanSeq("srv") > before+1)` is a timing-free proof
that a busy fire re-armed the timer. The "no scan attempted on top of a
claimed slot" and "a busy slot is not a failed attempt" assertions are
unchanged.

**Verification.** 400 runs of the fixed test under the same CPU
contention: 0 failures. `go test -race -count=3 -run
TestInformationalRescan ./internal/server/` green. `golangci-lint` with
the CI v2 config clean. Cross-reviewed with opencode (gpt-6.1-sol), two
rounds: round 1 found the baseline read could hit the same gap (fixed by
reading the counter before scheduling), round 2 CLEAN.

No production code changes.

## Second flake, same PR

`TestRefreshConcurrency_ScansDuringASlowSelfCheckSeeOldOrNew` (from
#1720) failed the darwin-amd64 Build Binaries job on this PR: `119.6ms
is not less than 109.7ms (baseline median 1.19ms)`. Both
refresh-concurrency guards compared the single worst of 300 scans
against `50*median+50ms`, which one GC pause on a macOS runner exceeds.
They now bound p90 relative to the baseline and keep an absolute 2 s
ceiling on the worst scan (no scheduler stall reaches it, any wait
behind the gate or a fetch deadline does). 30 runs under CPU contention:
0 failures. opencode Sol 6.1 round 1 flagged that p90 alone could hide
one scan absorbing a timed-out wait; the absolute ceiling is the answer
to that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: audit authz lines record profile tier/rule/unannotated refusals as reason "other"

3 participants