Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 189/190 (99%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 192/193 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 233/234 (100%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |
6 changes: 5 additions & 1 deletion cmd/mcpproxy/access_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ func fixCommand(f runtime.Fix, tool string) string {
case profile.FixAddServerToProfile:
return fmt.Sprintf("mcpproxy profile update %s --add-server %s", f.Target, server)
case profile.FixMoveClient:
return fmt.Sprintf("mcpproxy client set-profile %s <profile>", f.Target)
dest := f.Profile
if dest == "" {
dest = "<profile>" // a daemon that predates the field
}
return fmt.Sprintf("mcpproxy client set-profile %s %s", f.Target, dest)
case profile.FixEditToken:
return "mcpproxy token create --name <new-name> --profile <profile>"
case profile.FixEnableServer:
Expand Down
18 changes: 18 additions & 0 deletions cmd/mcpproxy/access_fix_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime"
)

// The move_client label names a concrete destination profile, so the command
// beside it must too (FR-035). A daemon that predates Fix.Profile sends none,
// and the hint then falls back to the placeholder.
func TestAccessExplain_MoveClientFixNamesDestinationProfile(t *testing.T) {
for _, tc := range []struct {
name string
fix runtime.Fix
want string
}{
{"names destination", runtime.Fix{Action: profile.FixMoveClient, Target: "cursor", Profile: "work-full"}, "mcpproxy client set-profile cursor work-full"},
{"old daemon, no field", runtime.Fix{Action: profile.FixMoveClient, Target: "cursor"}, "mcpproxy client set-profile cursor <profile>"},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, fixCommand(tc.fix, "github:create_issue"))
})
}
}

// A tool_approval failure targets one tool, so its hint must approve only that
// tool: `upstream approve <server>` with no tool list approves every pending
// tool of the server. Only a quarantined SERVER (target = the server name) is
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/profile-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ A client without an active client credential exits 1 with `mcpproxy connect <id>
mcpproxy access explain --tool github:create_issue (--client cursor | --token ci | --profile work-readonly | --anonymous)
```

Walks the gates a call meets (credential, profile, server in scope, tool rule, tier cap, token permission, global gate, server state, tool approval), prints the verdict (`allowed`, `blocked`, `hidden`) and the fixes in preference order, each with the command that performs it. The verdict is computed by the same predicate that enforces the call, so it never disagrees with what actually happens. Exit code 0 whenever an explanation is produced: the verdict is data.
Walks the gates a call meets (credential, profile, server in scope, tool rule, tier cap, token permission, global gate, server state, tool approval), prints the verdict (`allowed`, `blocked`, `hidden`) and the fixes in preference order, each with the command that performs it. The verdict is computed by the same predicate that enforces the call, so it never disagrees with what actually happens. The move-client fix prints `mcpproxy client set-profile <client> <destination-profile>` with the profile it names. Exit code 0 whenever an explanation is produced: the verdict is data.

## `mcpproxy token`

Expand Down
4 changes: 3 additions & 1 deletion docs/features/activity-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ Each tool call record includes:
Every upstream tool call a sandboxed `code_execution` script makes is recorded
as a first-class `tool_call` record with its own `request_id` and a `parent_id`
equal to the parent `code_execution` record's `request_id` (`source` is
`internal`; a policy-refused sub-call is recorded with status `blocked`).
`internal`; a policy-refused sub-call is recorded with status `blocked`; when
the refusal comes from the caller's profile it also carries `block_reason`:
`profile_tier`, `profile_rule` or `profile_unannotated`).
Filter with `parent_id=<parent request_id>` to list a script's sub-calls, or
`request_id=<child's parent_id>` to find the parent — the Web UI drawer, the
macOS Activity window, and `mcpproxy activity list --parent-id` all expose the
Expand Down
2 changes: 1 addition & 1 deletion docs/features/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Patterns are `server:tool` with `*` as the only wildcard, matched case-sensitive
| `retrieve_tools` | Left out **before** the result limit, and counted in `hidden_by_profile` without naming any of them. The response names the caller's own profile (`profile`) when it came from the caller's own credential or choice, never the operator's anonymous profile |
| `describe_tool` | The same not-found answer as a tool that does not exist |
| `call_tool_read`, `call_tool_write`, `call_tool_destructive`, `/mcp/all`, REST `/tools/call` | Refused before any upstream call with `blocked by profile: <server>:<tool> is a <tier> tool; profile "<title>" (<slug>) allows <cap> tools only`, or `... is denied by a rule in profile "<title>" (<slug>)`, or `... has no tier annotation; an operator can classify it in profile "<title>" (<slug>) to allow it` |
| `code_execution` | Absent and refused when the profile turns it off; every nested `call_tool` goes through the same gate |
| `code_execution` | Absent and refused when the profile turns it off; every nested `call_tool` goes through the same gate and is recorded with the same `block_reason` |

A refusal names the profile only to a caller whose effective profile is its own: one that came from the caller's pin, its client binding, the URL or `set_profile`, so an agent can tell the operator which profile to change. A caller that connects without a credential and falls under `anonymous_profile` gets the same refusal without the profile name (`... this profile allows <cap> tools only`, `... is denied by a profile rule`, `... in the profile to allow it`), and so does a caller whose profile no longer exists, so the operator's anonymous confinement is never handed out. The title is quoted and escaped, so it cannot add a line to the refusal. The operator sees the profile in the activity record and in the explainer. A blocked call is recorded with `status=blocked` and `block_reason` `profile_tier`, `profile_rule` or `profile_unannotated` (`profile_code_execution` and `profile_management` for the tools above).

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/access_explain_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestAccessExplainRoute_ResponseShapeEqualsTheSharedFixture(t *testing.T) {
Verdict: profile.ExplainVerdictHidden, FirstFailure: profile.StepTierCap,
Fixes: []internalRuntime.Fix{
{Step: profile.StepTierCap, Action: profile.FixAllowInProfile, Target: "work-readonly", Label: "Allow github:create_issue in Work Read-only"},
{Step: profile.StepTierCap, Action: profile.FixMoveClient, Target: "cursor", Label: "Move Cursor to Work Full"},
{Step: profile.StepTierCap, Action: profile.FixMoveClient, Target: "cursor", Profile: "work-full", Label: "Move Cursor to Work Full"},
},
}
for _, s := range profile.StepOrder() {
Expand Down
21 changes: 15 additions & 6 deletions internal/jsruntime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type AuthzGateReport struct {
Message string // the envelope message shown to the script caller
RequiredPerm string // the tier the lookup resolved, when one was resolved
Arguments map[string]interface{}
BlockReason string // host-defined typed cause of a profile policy refusal (Spec 108 FR-029); empty for every other refusal
}

// AuthzObserver receives AuthzGateReports. Implementations must be safe for
Expand Down Expand Up @@ -504,7 +505,7 @@ func (ec *ExecutionContext) checkDispatchGates(serverName, toolName string) (gat
// T104). It is the ONLY reporting seam: resolveDispatchGates calls it on
// every refusing return, once, so a refusal is never re-reported by the
// completion path (which a refused call never reaches). nil observer = no-op.
func (ec *ExecutionContext) reportAuthzRefusal(serverName, toolName string, code ErrorCode, message, requiredPerm string, args map[string]interface{}) {
func (ec *ExecutionContext) reportAuthzRefusal(serverName, toolName string, code ErrorCode, message, requiredPerm string, args map[string]interface{}, blockReason string) {
if ec.authzObserver == nil {
return
}
Expand All @@ -519,6 +520,7 @@ func (ec *ExecutionContext) reportAuthzRefusal(serverName, toolName string, code
Message: message,
RequiredPerm: requiredPerm,
Arguments: stripAuthInjectedArgs(args),
BlockReason: blockReason,
})
}

Expand Down Expand Up @@ -583,12 +585,12 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string, ar
if ec.authInfo != nil && !ec.authInfo.isAdmin() {
if profileDenies || !ec.authInfo.CanAccessServer(serverName) {
message := fmt.Sprintf("token does not have access to server '%s'", serverName)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeAccessDenied, message, "", args)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeAccessDenied, message, "", args, "")
return errorEnvelope(ErrorCodeAccessDenied, message), "", nil
}
} else if profileDenies {
message := fmt.Sprintf("server not allowed: %s", serverName)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeServerNotAllowed, message, "", args)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeServerNotAllowed, message, "", args, "")
return errorEnvelope(ErrorCodeServerNotAllowed, message), "", nil
}

Expand Down Expand Up @@ -616,12 +618,19 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string, ar
if requiredPerm == PermissionTierUnresolved {
message := fmt.Sprintf("permission denied: tool '%s:%s' cannot be resolved against the current tool list of server '%s' (undiscovered or stale name), so no permission tier applies to it",
serverName, toolName, serverName)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, message, requiredPerm, args)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, message, requiredPerm, args, "")
return errorEnvelope(ErrorCodePermissionDenied, message), "", nil
}
if refusal, ok := gate.(interface{ ProfilePolicyRefusal() string }); ok {
if message := refusal.ProfilePolicyRefusal(); message != "" {
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeAccessDenied, message, requiredPerm, args)
// The typed reason comes from a SECOND optional method so the
// refusal never depends on it (Spec 108 FR-029): a gate that
// only implements ProfilePolicyRefusal still refuses.
blockReason := ""
if typed, ok := gate.(interface{ ProfilePolicyBlockReason() string }); ok {
blockReason = typed.ProfilePolicyBlockReason()
}
ec.reportAuthzRefusal(serverName, toolName, ErrorCodeAccessDenied, message, requiredPerm, args, blockReason)
return errorEnvelope(ErrorCodeAccessDenied, message), "", nil
}
}
Expand All @@ -636,7 +645,7 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string, ar

if !ec.authInfo.HasPermission(requiredPerm) {
message := fmt.Sprintf("token does not have '%s' permission for tool '%s:%s'", requiredPerm, serverName, toolName)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, message, requiredPerm, args)
ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, message, requiredPerm, args, "")
return errorEnvelope(ErrorCodePermissionDenied, message), "", nil
}

Expand Down
83 changes: 83 additions & 0 deletions internal/jsruntime/runtime_authz_blockreason_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package jsruntime

import (
"context"
"testing"
)

// fakeProfileGate stands in for the host's per-call gate capture. refusal is
// the disclosed profile-refusal text; reason is the typed block reason.
type fakeProfileGate struct {
refusal string
reason string
}

func (g *fakeProfileGate) ProfilePolicyRefusal() string { return g.refusal }
func (g *fakeProfileGate) ProfilePolicyBlockReason() string { return g.reason }

// refusalOnlyGate implements only ProfilePolicyRefusal: the refusal must never
// depend on the optional block-reason method (Spec 108 FR-029, T166).
type refusalOnlyGate struct{ refusal string }

func (g *refusalOnlyGate) ProfilePolicyRefusal() string { return g.refusal }

// TestAuthzObserver_ProfileRefusalReportsBlockReason proves a profile policy
// refusal reaches the observer with the host's typed block reason, that the
// refusal itself does not depend on the optional method, and that a
// non-profile refusal carries no reason.
func TestAuthzObserver_ProfileRefusalReportsBlockReason(t *testing.T) {
const message = "blocked by profile: s:t is above the cap"
lookup := func(gate ToolGate) ToolGateLookup {
return func(serverName, toolName string) (string, ToolGate) { return "write", gate }
}

cases := []struct {
name string
opts ExecutionOptions
wantCode ErrorCode
wantReason string
}{
{
name: "profile refusal with typed reason",
opts: ExecutionOptions{ToolGateFunc: lookup(&fakeProfileGate{refusal: message, reason: "profile_tier"})},
wantCode: ErrorCodeAccessDenied,
wantReason: "profile_tier",
},
{
name: "refusal-only gate still refuses with empty reason",
opts: ExecutionOptions{ToolGateFunc: lookup(&refusalOnlyGate{refusal: message})},
wantCode: ErrorCodeAccessDenied,
wantReason: "",
},
{
name: "non-profile refusal carries no reason",
opts: ExecutionOptions{AllowedServers: []string{"other"}},
wantCode: ErrorCodeServerNotAllowed,
wantReason: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := newMockToolCaller()
observer := &recordingAuthzObserver{}
tc.opts.AuthzObserver = observer
result := Execute(context.Background(), caller, `call_tool("s", "t", {})`, tc.opts)
if !result.Ok {
t.Fatalf("execution failed: %+v", result.Error)
}
if len(caller.calls) != 0 {
t.Fatalf("a refused call must never dispatch upstream, got %d", len(caller.calls))
}
reports := observer.snapshot()
if len(reports) != 1 {
t.Fatalf("exactly one authz report expected, got %d", len(reports))
}
if reports[0].Code != tc.wantCode {
t.Fatalf("Code = %q, want %q", reports[0].Code, tc.wantCode)
}
if reports[0].BlockReason != tc.wantReason {
t.Fatalf("BlockReason = %q, want %q", reports[0].BlockReason, tc.wantReason)
}
})
}
}
9 changes: 6 additions & 3 deletions internal/profile/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@ func TestContractFixtures_Decode(t *testing.T) {
Verdict ExplainVerdict `json:"verdict"`
FirstFailure ExplainStep `json:"first_failure"`
Fixes []struct {
Step ExplainStep `json:"step"`
Action FixAction `json:"action"`
Target string `json:"target"`
Step ExplainStep `json:"step"`
Action FixAction `json:"action"`
Target string `json:"target"`
Profile string `json:"profile"`
} `json:"fixes"`
}
require.NoError(t, json.Unmarshal(readFixture(t, "explain_blocked.json"), &explanation))
Expand All @@ -125,6 +126,8 @@ func TestContractFixtures_Decode(t *testing.T) {
require.Equal(t, "work-readonly", explanation.Fixes[0].Target)
require.Equal(t, FixMoveClient, explanation.Fixes[1].Action)
require.Equal(t, "cursor", explanation.Fixes[1].Target, "move_client targets the client id")
require.Equal(t, "work-full", explanation.Fixes[1].Profile, "move_client names the destination profile slug")
require.Empty(t, explanation.Fixes[0].Profile, "only move_client carries a destination profile")
})

t.Run("activity_attributed.json decodes source and block-reason enums", func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/profile/testdata/contract/explain_blocked.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
"first_failure": "tier_cap",
"fixes": [
{"step": "tier_cap", "action": "allow_in_profile", "target": "work-readonly", "label": "Allow github:create_issue in Work Read-only"},
{"step": "tier_cap", "action": "move_client", "target": "cursor", "label": "Move Cursor to Work Full"}
{"step": "tier_cap", "action": "move_client", "target": "cursor", "profile": "work-full", "label": "Move Cursor to Work Full"}
]
}
68 changes: 68 additions & 0 deletions internal/runtime/activity_block_reason_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package runtime

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
)

// TestActivityService_ToolCallCompletedPersistsBlockReason proves the typed
// block reason of a profile-refused code_execution sub-call (Spec 108 FR-029,
// T166) travels emit -> event -> record: on record.BlockReason and, for one
// release, on Metadata["block_reason"], next to parent_id and attribution.
// An empty reason must leave the legacy record shape byte-identical.
func TestActivityService_ToolCallCompletedPersistsBlockReason(t *testing.T) {
cases := []struct {
name string
blockReason string
}{
{name: "profile rule", blockReason: "profile_rule"},
{name: "no reason", blockReason: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store, cleanup := setupTestStorage(t)
defer cleanup()
svc := NewActivityService(store, zap.NewNop())

rt := &Runtime{
eventSubs: make(map[chan Event]struct{}),
internalEventSubs: make(map[chan Event]struct{}),
}
sub := rt.subscribeInternalEvents()
defer rt.UnsubscribeEvents(sub)

rt.EmitActivityToolCallCompletedAttributed(
"github", "create_issue", "", "req-1", "internal", "blocked", "blocked by profile", 0,
nil, "", false, "", nil, "", "", 0, 0,
"", nil, "p-1", tc.blockReason,
ActivityAttribution{Profile: "x", ProfileSource: "pin"},
)

select {
case evt := <-sub:
svc.handleEvent(evt)
case <-time.After(time.Second):
t.Fatal("no completion event published")
}

records, _, err := store.ListActivities(storage.DefaultActivityFilter())
require.NoError(t, err)
require.Len(t, records, 1)
rec := records[0]
assert.Equal(t, "p-1", rec.ParentID)
assert.Equal(t, "x", rec.Profile)
assert.Equal(t, tc.blockReason, rec.BlockReason)
if tc.blockReason == "" {
assert.Nil(t, rec.Metadata, "no reason and nothing else to record: Metadata stays nil, no empty map")
return
}
assert.Equal(t, tc.blockReason, rec.Metadata[storage.MetadataKeyBlockReason])
})
}
}
Loading
Loading