Skip to content

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

Closed

Description

@james-crowley

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

  1. Enable audit_log ({"enabled": true, "path": "audit.jsonl"}) and create mcpproxy profile create research --servers fx --max-tier read.
  2. Mint a token pinned to it: mcpproxy token create --name t --profile research.
  3. Call any write-tier tool of fx through /mcp/all with that token.
  4. tail -1 audit.jsonl: "reason":"other". mcpproxy activity list --status blocked -o json shows "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.

Activity

  1. Dumbris commented on Oct 20, 2026

    @Dumbris
    Member

    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.

  2. james-crowley commented on Oct 20, 2026

    @james-crowley
    ContributorAuthor

    PR: #1715. Three specific members as asked, profile_scope unchanged, profile_code_execution and profile_management left out, and the nested code_execution path folded in.

  3. added a commit that references this issue on Oct 22, 2026
    81c9206
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions