Skip to content

Commit 698b02f

Browse files
committed
fix(057): address Codex review of PR #622 (deny-all bypass + FR-011 metadata)
Round-1 review fixes for the in-proxy profiles impl (MCP-1281/MCP-1278): 1. code_execution empty-effective-set bypass (MUST-FIX). A deny-all profile (servers: []) or a non-overlapping token∩profile yields an empty effective allow-list, which the JS runtime treated as "allow all" — leaking every server through code_execution. Add ExecutionOptions.RestrictToAllowed: when an active profile is in context, the allow-list is enforced even when empty (empty set = deny all). Set by the profile path in handleCodeExecution. 2. FR-011 metadata written to wrong shape (MUST-FIX). withProfileMeta smuggled the slug into the intent submap, so records landed as metadata.intent.profile and error paths dropped it entirely. Thread an explicit profile slug through EmitActivityToolCallCompleted → metadata["profile"] at top level, on success AND error paths. 3. Spec amendment (no code change): document per-request profile resolution (hot-reload takes effect on the next request) in spec.md + data-model.md, replacing the inaccurate "snapshot-until-reconnect" wording. Per-request re-resolution is kept as the safer behavior. Tests: jsruntime deny-all/non-empty enforcement + backward-compat; Activity service metadata["profile"] (success+error, not nested); code_execution deny-all/non-overlapping rejection at the profile path; upgraded the FR-011 integration test to assert the persisted record's metadata["profile"]. Related #622
1 parent 4d4c249 commit 698b02f

12 files changed

Lines changed: 405 additions & 49 deletions

File tree

internal/jsruntime/runtime.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ type ExecutionOptions struct {
1515
Input map[string]interface{} // Input data accessible as global `input` variable
1616
TimeoutMs int // Execution timeout in milliseconds
1717
MaxToolCalls int // Maximum number of call_tool() invocations (0 = unlimited)
18-
AllowedServers []string // Whitelist of allowed server names (empty = all allowed)
18+
AllowedServers []string // Whitelist of allowed server names (empty = all allowed, unless RestrictToAllowed)
1919
ExecutionID string // Unique execution ID for logging (auto-generated if empty)
2020
Language string // Source language: "javascript" (default) or "typescript"
2121

22+
// RestrictToAllowed enforces AllowedServers even when it is empty. Set by the
23+
// Spec 057 profile path: an active profile with an empty effective server set
24+
// (deny-all profile, or a non-overlapping token∩profile) must deny ALL
25+
// call_tool() invocations rather than fall back to the "empty = allow all"
26+
// default. Leave false for unrestricted code_execution and agent-token scopes.
27+
RestrictToAllowed bool
28+
2229
// Auth enforcement (Spec 031)
2330
AuthContext *AuthInfo // Auth context for permission enforcement (nil = no restrictions)
2431
ToolAnnotationFunc ToolAnnotationLookup // Function to look up tool annotations for permission checking
@@ -73,16 +80,17 @@ type ToolCaller interface {
7380

7481
// ExecutionContext tracks the state of a single JavaScript execution
7582
type ExecutionContext struct {
76-
ExecutionID string
77-
StartTime time.Time
78-
EndTime *time.Time
79-
Status string // "running", "success", "error", "timeout"
80-
ToolCalls []ToolCallRecord
81-
ResultValue interface{}
82-
ErrorDetails *JsError
83-
toolCaller ToolCaller
84-
maxToolCalls int
85-
allowedServerMap map[string]bool
83+
ExecutionID string
84+
StartTime time.Time
85+
EndTime *time.Time
86+
Status string // "running", "success", "error", "timeout"
87+
ToolCalls []ToolCallRecord
88+
ResultValue interface{}
89+
ErrorDetails *JsError
90+
toolCaller ToolCaller
91+
maxToolCalls int
92+
allowedServerMap map[string]bool
93+
restrictToAllowed bool // enforce allowedServerMap even when empty (Spec 057 deny-all profile)
8694

8795
// Auth enforcement (Spec 031)
8896
authInfo *AuthInfo
@@ -133,6 +141,7 @@ func Execute(ctx context.Context, caller ToolCaller, code string, opts Execution
133141
toolCaller: caller,
134142
maxToolCalls: opts.MaxToolCalls,
135143
allowedServerMap: make(map[string]bool),
144+
restrictToAllowed: opts.RestrictToAllowed,
136145
authInfo: opts.AuthContext,
137146
toolAnnotationFunc: opts.ToolAnnotationFunc,
138147
maxPermissionLevel: "",
@@ -300,8 +309,10 @@ func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.Fun
300309
})
301310
}
302311

303-
// Check allowed servers
304-
if len(ec.allowedServerMap) > 0 && !ec.allowedServerMap[serverName] {
312+
// Check allowed servers. When restrictToAllowed is set (active Spec 057
313+
// profile), the map is enforced even when empty — an empty effective set
314+
// means "deny everything". Otherwise an empty map means "no restriction".
315+
if (ec.restrictToAllowed || len(ec.allowedServerMap) > 0) && !ec.allowedServerMap[serverName] {
305316
return vm.ToValue(map[string]interface{}{
306317
"ok": false,
307318
"error": map[string]interface{}{

internal/jsruntime/runtime_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,92 @@ func TestExecuteAllowedServers(t *testing.T) {
308308
}
309309
}
310310

311+
// TestExecuteRestrictToAllowedEmptyDeniesAll verifies that when RestrictToAllowed
312+
// is set — i.e. an active Spec 057 profile whose effective server set is empty
313+
// (deny-all profile, or a non-overlapping token∩profile intersection) — an empty
314+
// AllowedServers list denies ALL call_tool() invocations. Without this flag an
315+
// empty allow-list is treated as "allow all", which let the most locked-down
316+
// profiles leak every server through code_execution (Codex PR #622 finding #1).
317+
func TestExecuteRestrictToAllowedEmptyDeniesAll(t *testing.T) {
318+
caller := newMockToolCaller()
319+
320+
code := `
321+
var res = call_tool("anything", "tool", {});
322+
({ ok: res.ok, code: res.error ? res.error.code : null })
323+
`
324+
opts := ExecutionOptions{
325+
RestrictToAllowed: true,
326+
AllowedServers: nil, // active profile with empty effective set
327+
}
328+
329+
result := Execute(context.Background(), caller, code, opts)
330+
if !result.Ok {
331+
t.Fatalf("expected ok=true (script itself ran), got error: %v", result.Error)
332+
}
333+
334+
resultMap := result.Value.(map[string]interface{})
335+
if resultMap["ok"] != false {
336+
t.Errorf("expected call_tool ok=false (deny-all profile), got %v", resultMap["ok"])
337+
}
338+
if resultMap["code"] != string(ErrorCodeServerNotAllowed) {
339+
t.Errorf("expected code=SERVER_NOT_ALLOWED, got %v", resultMap["code"])
340+
}
341+
if len(caller.calls) != 0 {
342+
t.Errorf("expected upstream CallTool to never be reached, got %d calls", len(caller.calls))
343+
}
344+
}
345+
346+
// TestExecuteNoRestrictEmptyAllowsAll guards the backward-compatible path: with no
347+
// RestrictToAllowed flag and an empty AllowedServers list, call_tool() is allowed
348+
// (empty = no restriction). This must stay true for /mcp, /mcp/call, and
349+
// unrestricted code_execution after the finding-#1 fix.
350+
func TestExecuteNoRestrictEmptyAllowsAll(t *testing.T) {
351+
caller := newMockToolCaller()
352+
353+
code := `var res = call_tool("anything", "tool", {}); ({ ok: res.ok })`
354+
opts := ExecutionOptions{} // no restriction, empty allow-list
355+
356+
result := Execute(context.Background(), caller, code, opts)
357+
if !result.Ok {
358+
t.Fatalf("expected ok=true, got error: %v", result.Error)
359+
}
360+
resultMap := result.Value.(map[string]interface{})
361+
if resultMap["ok"] != true {
362+
t.Errorf("expected call_tool ok=true (no restriction), got %v", resultMap["ok"])
363+
}
364+
}
365+
366+
// TestExecuteRestrictToAllowedEnforcesNonEmpty verifies RestrictToAllowed with a
367+
// non-empty list still allows listed servers and blocks unlisted ones.
368+
func TestExecuteRestrictToAllowedEnforcesNonEmpty(t *testing.T) {
369+
caller := newMockToolCaller()
370+
371+
code := `
372+
var a = call_tool("allowed", "tool", {});
373+
var b = call_tool("blocked", "tool", {});
374+
({ a_ok: a.ok, b_ok: b.ok, b_code: b.error ? b.error.code : null })
375+
`
376+
opts := ExecutionOptions{
377+
RestrictToAllowed: true,
378+
AllowedServers: []string{"allowed"},
379+
}
380+
381+
result := Execute(context.Background(), caller, code, opts)
382+
if !result.Ok {
383+
t.Fatalf("expected ok=true, got error: %v", result.Error)
384+
}
385+
resultMap := result.Value.(map[string]interface{})
386+
if resultMap["a_ok"] != true {
387+
t.Errorf("expected a_ok=true (allowed server), got %v", resultMap["a_ok"])
388+
}
389+
if resultMap["b_ok"] != false {
390+
t.Errorf("expected b_ok=false (blocked server), got %v", resultMap["b_ok"])
391+
}
392+
if resultMap["b_code"] != string(ErrorCodeServerNotAllowed) {
393+
t.Errorf("expected b_code=SERVER_NOT_ALLOWED, got %v", resultMap["b_code"])
394+
}
395+
}
396+
311397
// TestExecuteNonSerializableResult tests rejection of non-JSON-serializable results
312398
func TestExecuteNonSerializableResult(t *testing.T) {
313399
caller := newMockToolCaller()

internal/runtime/activity_service.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) {
277277
intent := getMapPayload(evt.Payload, "intent")
278278
// Extract content trust metadata if present (Spec 035)
279279
contentTrust := getStringPayload(evt.Payload, "content_trust")
280+
// Spec 057 FR-011: profile slug for tool calls from a /mcp/p/<slug> URL.
281+
profileSlug := getStringPayload(evt.Payload, "profile")
280282
// Default source to "mcp" if not specified (backwards compatibility)
281283
activitySource := storage.ActivitySourceMCP
282284
if source != "" {
@@ -285,7 +287,7 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) {
285287

286288
// Build metadata with intent information if present
287289
var metadata map[string]interface{}
288-
if toolVariant != "" || intent != nil || contentTrust != "" {
290+
if toolVariant != "" || intent != nil || contentTrust != "" || profileSlug != "" {
289291
metadata = make(map[string]interface{})
290292
if toolVariant != "" {
291293
metadata["tool_variant"] = toolVariant
@@ -297,6 +299,11 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) {
297299
if contentTrust != "" {
298300
metadata["content_trust"] = contentTrust
299301
}
302+
// Spec 057 FR-011: top-level profile slug (NOT nested under intent), so
303+
// operators can correlate activity to the profile it came from.
304+
if profileSlug != "" {
305+
metadata["profile"] = profileSlug
306+
}
300307
}
301308

302309
// Spec 069 A1: byte sizes measured pre-truncation by the emitter.

internal/runtime/activity_service_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,87 @@ func TestHandleToolCallCompleted_NoUserIdentity(t *testing.T) {
565565
assert.Empty(t, record.UserEmail, "UserEmail should be empty when no _auth_user_email is present")
566566
}
567567

568+
// TestHandleToolCallCompleted_ProfileMetadata verifies Spec 057 FR-011: the
569+
// profile slug from a /mcp/p/<slug> tool call lands at the TOP-LEVEL
570+
// metadata["profile"], NOT smuggled under metadata.intent.profile. Covers both
571+
// success and error paths (Codex PR #622 finding #2).
572+
func TestHandleToolCallCompleted_ProfileMetadata(t *testing.T) {
573+
for _, status := range []string{"success", "error"} {
574+
t.Run(status, func(t *testing.T) {
575+
store, cleanup := setupTestStorage(t)
576+
defer cleanup()
577+
578+
svc := NewActivityService(store, zap.NewNop())
579+
580+
evt := Event{
581+
Type: EventTypeActivityToolCallCompleted,
582+
Timestamp: time.Now().UTC(),
583+
Payload: map[string]any{
584+
"server_name": "research-srv",
585+
"tool_name": "search_papers",
586+
"status": status,
587+
"duration_ms": int64(10),
588+
"tool_variant": "read",
589+
"profile": "research",
590+
// An intent map is also present; profile must NOT live inside it.
591+
"intent": map[string]interface{}{
592+
"operation_type": "read",
593+
},
594+
},
595+
}
596+
597+
svc.handleEvent(evt)
598+
599+
records, _, err := store.ListActivities(storage.DefaultActivityFilter())
600+
require.NoError(t, err)
601+
require.Len(t, records, 1)
602+
603+
md := records[0].Metadata
604+
require.NotNil(t, md)
605+
assert.Equal(t, "research", md["profile"],
606+
"FR-011: profile slug must be at top-level metadata[\"profile\"]")
607+
608+
// Must NOT be nested inside the intent submap.
609+
if intent, ok := md["intent"].(map[string]interface{}); ok {
610+
_, nested := intent["profile"]
611+
assert.False(t, nested, "profile must not be nested under metadata.intent.profile")
612+
}
613+
})
614+
}
615+
}
616+
617+
// TestHandleToolCallCompleted_NoProfileMetadata verifies that a tool call from
618+
// /mcp (no profile) omits metadata["profile"] entirely (FR-011 backward compat).
619+
func TestHandleToolCallCompleted_NoProfileMetadata(t *testing.T) {
620+
store, cleanup := setupTestStorage(t)
621+
defer cleanup()
622+
623+
svc := NewActivityService(store, zap.NewNop())
624+
625+
evt := Event{
626+
Type: EventTypeActivityToolCallCompleted,
627+
Timestamp: time.Now().UTC(),
628+
Payload: map[string]any{
629+
"server_name": "github",
630+
"tool_name": "list_repos",
631+
"status": "success",
632+
"duration_ms": int64(10),
633+
"tool_variant": "read",
634+
},
635+
}
636+
637+
svc.handleEvent(evt)
638+
639+
records, _, err := store.ListActivities(storage.DefaultActivityFilter())
640+
require.NoError(t, err)
641+
require.Len(t, records, 1)
642+
643+
if md := records[0].Metadata; md != nil {
644+
_, ok := md["profile"]
645+
assert.False(t, ok, "records from /mcp must omit metadata[\"profile\"]")
646+
}
647+
}
648+
568649
// TestHandleToolCallCompleted_NilArguments verifies no panic when arguments is nil.
569650
func TestHandleToolCallCompleted_NilArguments(t *testing.T) {
570651
store, cleanup := setupTestStorage(t)

internal/runtime/event_bus.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ func (r *Runtime) EmitActivityToolCallStarted(serverName, toolName, sessionID, r
397397
// arguments is the input parameters passed to the tool call
398398
// toolVariant is the MCP tool variant used (call_tool_read/write/destructive) - optional
399399
// intent is the intent declaration metadata - optional
400-
func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust string, requestBytes, responseBytes int) {
400+
func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust, profile string, requestBytes, responseBytes int) {
401401
// Spec 042: classify failed tool calls into the upstream error categories.
402402
// We never record the error message itself; only a fixed enum value.
403403
if status == "error" && errorMsg != "" {
@@ -438,6 +438,10 @@ func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID,
438438
if contentTrust != "" {
439439
payload["content_trust"] = contentTrust
440440
}
441+
// Spec 057 FR-011: profile slug for tool calls from a /mcp/p/<slug> URL.
442+
if profile != "" {
443+
payload["profile"] = profile
444+
}
441445
// Spec 069 A1: pre-truncation byte sizes (0 means not measured / legacy callers)
442446
if requestBytes > 0 {
443447
payload["request_bytes"] = requestBytes

0 commit comments

Comments
 (0)