[Bug]: audit authz lines record profile tier/rule/unannotated refusals as reason "other" #1703
Description
What happened?
A tool call refused by a profile's tier cap, deny rule or unannotated-tool policy is written to the audit log as authz / decision: deny / reason: other. The activity log for the same call carries the specific cause (block_reason: profile_tier), so the two records disagree in precision and the audit stream, the one meant for an external reader, loses the distinction.
Observed on v0.71.1: a token pinned to a read-cap profile called a write tool. The caller saw blocked by profile: fx:note_set is a write tool; profile "research" allows read tools only; the audit line was:
{"event":"authz","surface":"direct","server":"fx","tool":"note_set","decision":"deny","reason":"other","caller":{"kind":"agent_token","token_name":"tls-wk3","profile_pin":"research"}, ...}while mcpproxy activity list --status blocked shows block_reason: profile_tier for it.
Cause: auditReasonFromBlockKey (internal/server/audit_funnel.go) maps the telemetry.BlockReason* keys onto the authz vocabulary and sends everything it does not list to other. The profile refusals use a separate enum (profile.BlockReason: profile_tier, profile_rule, profile_unannotated, profile_code_execution, profile_management, profile_server_scope) that the mapping never sees, and the schema's closed reason enum (docs/schemas/audit-line-v1.schema.json, audit-log.md) has no member for them. Only a server-outside-the-profile refusal gets a specific value (profile_scope). The nested code_execution path has the same gap, and a code comment there already says so: "the audit reason below stays the Spec 107 vocabulary even for a profile refusal ... aligning it with the top-level gate's other is a Spec 107/108 follow-up" (audit_funnel.go, around line 533).
Expected behavior
The audit reason for a profile refusal says which profile rule refused it, so a log query such as "calls refused by the tier cap this week" or "any deny-rule hit" works without joining against the activity log. For example, add the profile members to the closed reason vocabulary (profile_tier, profile_rule, profile_unannotated, profile_code_execution, profile_management), keep profile_scope for the server-scope case, map them in auditReasonFromBlockKey and in the nested path, and extend the schema enum, audit-log.md and the audit-line contract. They are disclosed refusals (the caller's own error text already names the cause), so disclosed: true stays correct.
I did not look at how you version enum additions to schema v1 (additive member vs. a v2). If you prefer to keep the vocabulary small, a single profile_policy member would already separate profile refusals from other. I can send a PR for whichever you choose.
Steps to reproduce
- Enable
audit_log({"enabled": true, "path": "audit.jsonl"}) and createmcpproxy profile create research --servers fx --max-tier read. - Mint a token pinned to it:
mcpproxy token create --name t --profile research. - Call any write-tier tool of
fxthrough/mcp/allwith that token. tail -1 audit.jsonl:"reason":"other".mcpproxy activity list --status blocked -o jsonshows"block_reason":"profile_tier"for the same request.
MCPProxy version
v0.71.1 (personal) linux/amd64; the mapping is unchanged on main at 3558c53.
Operating system
Linux
Relevant logs
See the audit line above.
- I searched existing issues and did not find a duplicate (searched for audit reason / profile refusal /
other/ vocabulary in issues and PRs; related only: feat(audit): attributable JSONL audit line at every authorization and tool-call funnel (Spec 107 PR-D) #1296 introduced the audit line, test: cover profile refusal audit and stored selection #1447 asserts thetoken_scopereason for scope refusals, fix(profiles): nested refusal block_reason and explain move-client hint (Spec fix-nested-refusal) #1468 fixed the nestedblock_reasonfor activity records, [Feature]: audit log has no record of who changed the rules (admin_action events) #1698 asks for a different thing, admin-action events)
Activity
Dumbris commentedon Oct 20, 2026
Confirmed, and thanks for the precise write-up. The cause is dropped at the funnel (the gates pass it, auditAuthz never receives it), so the fix is a bit more than a mapping line. Please go with the three specific members rather than a single profile_policy, keep profile_scope as is, and leave out profile_code_execution and profile_management since built-in invocations never reach the audit stream. One more thing worth folding in: the nested code_execution path currently writes token_scope with disclosed:false for a profile refusal the script can read in full, so it should use report.BlockReason for the three policy reasons too. Happy to take the PR.
james-crowley commentedon Oct 20, 2026
PR: #1715. Three specific members as asked,
profile_scopeunchanged,profile_code_executionandprofile_managementleft out, and the nestedcode_executionpath folded in.fix(audit): name the profile rule that refused a call in the authz re…