Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ func TestExecutionNotesCoverageAdditional(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(`{"message":" kept "}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Execution-ID", "exec-2")
req.Header.Set("X-Agent-Node-ID", "node-2")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

Expand Down
102 changes: 101 additions & 1 deletion control-plane/internal/handlers/execution_notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package handlers

import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/Agent-Field/agentfield/control-plane/internal/events"
"github.com/Agent-Field/agentfield/control-plane/internal/server/middleware"
"github.com/Agent-Field/agentfield/control-plane/pkg/types"

"github.com/gin-gonic/gin"
Expand All @@ -20,6 +22,22 @@ type ExecutionNoteStorage interface {
GetExecutionEventBus() *events.ExecutionEventBus
}

type executionNoteDIDDocumentLookup interface {
GetDIDDocument(ctx context.Context, did string) (*types.DIDDocumentRecord, error)
}

type executionNoteAgentDIDLister interface {
ListAgentDIDs(ctx context.Context) ([]*types.AgentDIDInfo, error)
}

type executionNoteAuthorizationError struct {
message string
}

func (e *executionNoteAuthorizationError) Error() string {
return e.message
}

// AddNoteRequest represents the request body for adding a note to an execution
type AddNoteRequest struct {
Message string `json:"message" binding:"required"`
Expand Down Expand Up @@ -76,12 +94,21 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu
}

// Update the execution with the new note
ctx := context.Background()
ctx := c.Request.Context()
callerAgentID, err := executionNoteCallerAgentID(ctx, c, storageProvider)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to resolve caller identity: %v", err)})
return
}

var runID string
updated, err := storageProvider.UpdateExecutionRecord(ctx, executionID, func(execution *types.Execution) (*types.Execution, error) {
if execution == nil {
return nil, fmt.Errorf("execution with ID %s not found", executionID)
}
if err := ensureExecutionNoteOwnership(callerAgentID, execution); err != nil {
return nil, err
}

// Store run ID for SSE event (run_id is the workflow ID equivalent)
runID = execution.RunID
Expand All @@ -99,6 +126,14 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu
})

if err != nil {
var authzErr *executionNoteAuthorizationError
if errors.As(err, &authzErr) {
c.JSON(http.StatusForbidden, gin.H{
"error": "execution_ownership_mismatch",
"message": authzErr.message,
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to add note: %v", err)})
return
}
Expand Down Expand Up @@ -130,6 +165,71 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu
}
}

func ensureExecutionNoteOwnership(callerAgentID string, execution *types.Execution) error {
ownerAgentID := strings.TrimSpace(execution.AgentNodeID)
if ownerAgentID == "" {
return &executionNoteAuthorizationError{message: "execution owner is required to add notes"}
}

if callerAgentID == "" {
return &executionNoteAuthorizationError{message: "caller agent identity is required to add notes to this execution"}
}
if callerAgentID != ownerAgentID {
return &executionNoteAuthorizationError{message: "this execution does not belong to the requesting agent"}
}

return nil
}

func executionNoteCallerAgentID(ctx context.Context, c *gin.Context, storageProvider ExecutionNoteStorage) (string, error) {
Copy link
Copy Markdown
Member

@santoshkumarradha santoshkumarradha May 24, 2026

Choose a reason for hiding this comment

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

🔴 [CRITICAL] Raw-header fallback becomes sole identity source under default config

executionNoteCallerAgentID has 3 tiers: (1) verified DID, (2) CallerAgentIDKey context value, (3) raw X-Caller-Agent-ID/X-Agent-Node-ID headers. Tiers 1 & 2 are config-gated. Under defaults (APIKey="", did_auth_enabled=false), both are skipped — tier 3 accepts attacker-controlled headers with zero validation, flowing directly to ensureExecutionNoteOwnership.

Evidence:

  • routes_middleware.go:77 — DID middleware not installed when disabled
  • auth.go:26-28 — APIKeyAuth no-ops when APIKey=="", never sets context key
  • execution_notes.go:196-201 — raw header read with no validation
  • When APIKeyAuth does run, it reads the same headers (auth.go:118-124), so the fallback is either dead code or active bypass — never legitimate

Fix: Delete the raw-header fallback. Add a startup assertion in routes_middleware.go that refuses to register write routes when both auth methods are disabled.


Compound Analysis · confidence 95%

🤖 Reviewed by AgentField PR-AF

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. This is a real bypass under the default config. I’ll remove the raw-header fallback, require caller identity to come from verified DID or authenticated middleware context only, and add tests for the spoofed-header/no-auth case. I’ll also add a startup/config guard so the write route fails closed when both API key auth and DID auth are disabled.
in this same PR right?

Comment thread
AbirAbbas marked this conversation as resolved.
if callerDID := strings.TrimSpace(middleware.GetVerifiedCallerDID(c)); callerDID != "" {
return resolveExecutionNoteAgentIDByDID(ctx, storageProvider, callerDID)
}

if callerID, exists := c.Get(string(middleware.CallerAgentIDKey)); exists {
Comment thread
AbirAbbas marked this conversation as resolved.
if id, ok := callerID.(string); ok {
if id = strings.TrimSpace(id); id != "" {
return id, nil
}
}
}
if agentID := strings.TrimSpace(c.GetHeader("X-Caller-Agent-ID")); agentID != "" {
return agentID, nil
}
if agentID := strings.TrimSpace(c.GetHeader("X-Agent-Node-ID")); agentID != "" {
return agentID, nil
}

return "", nil
}

func resolveExecutionNoteAgentIDByDID(ctx context.Context, storageProvider ExecutionNoteStorage, callerDID string) (string, error) {
Comment thread
AbirAbbas marked this conversation as resolved.
if lookup, ok := storageProvider.(executionNoteDIDDocumentLookup); ok {
Comment thread
AbirAbbas marked this conversation as resolved.
if record, err := lookup.GetDIDDocument(ctx, callerDID); err == nil && record != nil {
Copy link
Copy Markdown
Member

@santoshkumarradha santoshkumarradha May 24, 2026

Choose a reason for hiding this comment

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

🟡 [MEDIUM] DID resolution lookup doesn't filter revoked records

resolveExecutionNoteAgentIDByDID (line 208) accepts any non-error GetDIDDocument result. LocalStorage.GetDIDDocument (local.go:8305-8334) returns records with RevokedAt populated and err==nil — no revoked_at IS NULL filter, unlike GetDIDDocumentByAgentID (local.go:8345). ListAgentDIDs (local.go:6864-6917) similarly returns rows regardless of agent_dids.status (which can be active|inactive|revoked per migration 002).

Defense-in-depth concern, not a direct bypass for did:web: DIDAuthMiddlewareVerifyDIDOwnershipResolveDID (did_web_service.go:128-149) already returns nil document for revoked did:web, so the auth layer rejects with 401 before reaching this handler. The handler's missing revocation check is a secondary gap — it matters if:

  • The caller is a did:key whose entry in agent_dids was marked revoked (auth still passes because did:key is self-verifying)
  • Revocation happens between auth and handler execution (narrow race)
  • This resolver is later reused in a code path that doesn't run DIDAuthMiddleware first

Fix: After the GetDIDDocument call, if record.IsRevoked() { /* fall through or error */ }. For ListAgentDIDs, skip entries where info.Status == AgentDIDStatusRevoked. Or push the filters into the queries as GetDIDDocumentByAgentID does.


Defense-in-depth: revocation filter · confidence 80%

🤖 Reviewed by AgentField PR-AF

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Even though did:web revocation is already enforced by DID auth, the handler resolver should fail closed too. I’ll reject revoked DIDDocumentRecords, skip non-active AgentDIDInfo entries, and add coverage for revoked DID resolution.
Should i do this in this same PR?

return strings.TrimSpace(record.AgentID), nil
}
}

lister, ok := storageProvider.(executionNoteAgentDIDLister)
if !ok {
return "", nil
}
agentDIDs, err := lister.ListAgentDIDs(ctx)
if err != nil {
return "", fmt.Errorf("failed to resolve caller DID: %w", err)
}
for _, info := range agentDIDs {
if info == nil {
continue
}
if strings.TrimSpace(info.DID) == callerDID {
return strings.TrimSpace(info.AgentNodeID), nil
}
}

return "", nil
}

// GetExecutionNotesHandler handles GET /api/v1/executions/:execution_id/notes
// Retrieves notes for a specific execution with optional tag filtering
func GetExecutionNotesHandler(storageProvider ExecutionNoteStorage) gin.HandlerFunc {
Copy link
Copy Markdown
Member

@santoshkumarradha santoshkumarradha May 24, 2026

Choose a reason for hiding this comment

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

🟠 [IMPORTANT] Read path leaks execution notes — no ownership check

PR fixes IDOR on write path but GetExecutionNotesHandler (line 235) remains open: any API-key-authenticated caller can read any execution's notes by ID. storageProvider.GetExecutionRecord() is called at line 256 with no caller identity resolution or comparison against execution.AgentNodeID — same IDOR pattern just fixed on write.

Notes carry workflow state — phase progress, intermediate results, confidence reasoning. See examples/python_agent_nodes/agentic_rag/main.py:912,914,963,1097. The UI details endpoint GetExecutionDetailsGlobalHandler (ui/executions.go:558, lines 784-785) also exposes NotesCount/LatestNote without ownership enforcement.

No code comment, test, or PR description text explains whether the open read is intentional. Likely oversight given the write-side fix.

Fix: Mirror write path — resolve caller via executionNoteCallerAgentID, then ensureExecutionNoteOwnership, return 403 on mismatch. If intentional, add a code comment + a test confirming non-owner reads are the intended contract.


Authorization Asymmetry · confidence 90%

🤖 Reviewed by AgentField PR-AF

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. The read path should mirror the write path here. I’ll add caller resolution + ensureExecutionNoteOwnership to GetExecutionNotesHandler, return 403 on ownership mismatch, and add tests for owner vs non-owner reads.
There's already an issue raised(#421 ) for this
I will fix it in thats issue's PR.

Expand Down
Loading
Loading