Skip to content

Commit 9322ba8

Browse files
authored
Surface DIFC-filtered items in tool responses to prevent targeted dispatch drift (#2175)
## Summary Fixes the core gateway-side issue described in gh-aw#21784. When DIFC integrity policy removes items from a tool response (e.g. `list_issues`) in filter/propagate mode, the agent previously received only the accessible items with **no indication that filtering occurred**. An empty result looked identical to a genuine "no items" response, causing targeted-dispatch workflows to silently fall back to scheduled/backlog-scan mode. ## Root cause In `callBackendTool` (Phase 5 of the DIFC reference-monitor pipeline), after `FilterCollection` removes low-integrity items, `ToResult()` returns only the accessible items as a plain array. The `FilteredCollectionLabeledData` struct tracked filtered details internally (for audit logging) but never exposed them to the caller. ## Fix After converting the filtered result to an SDK `CallToolResult`, append an additional `TextContent` block if any items were removed: ``` [DIFC] 1 item(s) in this response were removed by integrity policy and are not shown: issue:org/repo#14 (integrity too low for agent context). ``` For up to 5 filtered items the notice includes per-item description + reason. For larger sets only the count is reported. This applies only in filter/propagate enforcement modes. Strict mode already returns an explicit error blocking the entire response. ## Changes | File | Change | |---|---| | `internal/server/difc_log.go` | `buildDIFCFilteredNotice` helper + `maxFilteredItemsInNotice` constant | | `internal/server/unified.go` | Track `difcFiltered` in `callBackendTool`; append notice after `ConvertToCallToolResult` | | `internal/server/difc_log_test.go` | 6 new unit tests: nil input, empty, single item, within limit, exceeds limit, no description | ## Testing - All existing unit and integration tests pass (`make agent-finished`) - 6 new tests for `buildDIFCFilteredNotice` - CodeQL: 0 alerts ## Security Summary No new security vulnerabilities introduced. The notice text contains only the count of filtered items and their already-logged resource descriptions and denial reasons — no data from the filtered items' payload is exposed.
2 parents af7f45a + d85c043 commit 9322ba8

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

internal/server/difc_log.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"encoding/json"
55
"fmt"
6+
"strings"
67

78
"github.com/github/gh-aw-mcpg/internal/difc"
89
"github.com/github/gh-aw-mcpg/internal/logger"
@@ -93,3 +94,59 @@ func extractNumberField(m map[string]interface{}) string {
9394
}
9495
return ""
9596
}
97+
98+
// maxFilteredItemsInNotice is the maximum number of individual item descriptions
99+
// to include inline in the DIFC filtered notice surfaced to the agent.
100+
const maxFilteredItemsInNotice = 5
101+
102+
// buildDIFCFilteredNotice builds a human-readable notice for the agent when items are
103+
// removed from a tool response by DIFC integrity policy in filter/propagate mode.
104+
//
105+
// The notice is surfaced as an additional text content block appended to the tool
106+
// response so that agents (and targeted-dispatch workflows) are aware that items exist
107+
// but were withheld, rather than concluding the result set is genuinely empty.
108+
//
109+
// For up to maxFilteredItemsInNotice items the description and reason for each item are
110+
// included. For larger sets only the count is reported to keep the message concise.
111+
func buildDIFCFilteredNotice(filtered *difc.FilteredCollectionLabeledData) string {
112+
if filtered == nil {
113+
return ""
114+
}
115+
n := filtered.GetFilteredCount()
116+
if n == 0 {
117+
return ""
118+
}
119+
120+
// For a small number of filtered items, include per-item descriptions and reasons.
121+
if n <= maxFilteredItemsInNotice {
122+
parts := make([]string, 0, n)
123+
for _, detail := range filtered.Filtered {
124+
desc := ""
125+
if detail.Item.Labels != nil {
126+
desc = detail.Item.Labels.Description
127+
}
128+
// Skip items that carry no useful identifying information.
129+
if desc == "" && detail.Reason == "" {
130+
continue
131+
}
132+
if desc != "" && detail.Reason != "" {
133+
parts = append(parts, fmt.Sprintf("%s (%s)", desc, detail.Reason))
134+
} else if desc != "" {
135+
parts = append(parts, desc)
136+
} else {
137+
parts = append(parts, detail.Reason)
138+
}
139+
}
140+
if len(parts) > 0 {
141+
return fmt.Sprintf(
142+
"[DIFC] %d item(s) in this response were removed by integrity policy and are not shown: %s.",
143+
n, strings.Join(parts, "; "),
144+
)
145+
}
146+
}
147+
148+
return fmt.Sprintf(
149+
"[DIFC] %d item(s) in this response were removed by integrity policy and are not shown.",
150+
n,
151+
)
152+
}

internal/server/difc_log_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"path/filepath"
78
"strings"
@@ -274,3 +275,102 @@ func TestBuildFilteredItemLogEntry_NonMapData(t *testing.T) {
274275
assert.Empty(t, entry.AuthorLogin)
275276
})
276277
}
278+
279+
// TestBuildDIFCFilteredNotice_NilInput verifies that a nil input returns an empty string
280+
// without panicking.
281+
func TestBuildDIFCFilteredNotice_NilInput(t *testing.T) {
282+
assert.NotPanics(t, func() {
283+
assert.Empty(t, buildDIFCFilteredNotice(nil))
284+
})
285+
}
286+
287+
// TestBuildDIFCFilteredNotice_EmptyFiltered verifies that no notice is returned when
288+
// there are no filtered items.
289+
func TestBuildDIFCFilteredNotice_EmptyFiltered(t *testing.T) {
290+
f := &difc.FilteredCollectionLabeledData{
291+
Filtered: []difc.FilteredItemDetail{},
292+
}
293+
assert.Empty(t, buildDIFCFilteredNotice(f))
294+
}
295+
296+
// TestBuildDIFCFilteredNotice_SingleItem verifies the notice for a single filtered item
297+
// includes the item description and reason.
298+
func TestBuildDIFCFilteredNotice_SingleItem(t *testing.T) {
299+
f := &difc.FilteredCollectionLabeledData{
300+
Filtered: []difc.FilteredItemDetail{
301+
newTestFilteredItem(nil, "issue:org/repo#14", "integrity too low for agent context", nil, nil),
302+
},
303+
TotalCount: 1,
304+
}
305+
306+
notice := buildDIFCFilteredNotice(f)
307+
308+
assert.NotEmpty(t, notice)
309+
assert.Contains(t, notice, "[DIFC]")
310+
assert.Contains(t, notice, "1 item(s)")
311+
assert.Contains(t, notice, "issue:org/repo#14")
312+
assert.Contains(t, notice, "integrity too low for agent context")
313+
}
314+
315+
// TestBuildDIFCFilteredNotice_MultipleItemsWithinLimit verifies that up to
316+
// maxFilteredItemsInNotice items are listed individually with their descriptions and reasons.
317+
func TestBuildDIFCFilteredNotice_MultipleItemsWithinLimit(t *testing.T) {
318+
f := &difc.FilteredCollectionLabeledData{
319+
Filtered: []difc.FilteredItemDetail{
320+
newTestFilteredItem(nil, "issue:org/repo#1", "integrity too low", nil, nil),
321+
newTestFilteredItem(nil, "issue:org/repo#2", "integrity too low", nil, nil),
322+
newTestFilteredItem(nil, "issue:org/repo#3", "integrity too low", nil, nil),
323+
},
324+
TotalCount: 3,
325+
}
326+
327+
notice := buildDIFCFilteredNotice(f)
328+
329+
assert.NotEmpty(t, notice)
330+
assert.Contains(t, notice, "[DIFC]")
331+
assert.Contains(t, notice, "3 item(s)")
332+
assert.Contains(t, notice, "issue:org/repo#1")
333+
assert.Contains(t, notice, "issue:org/repo#2")
334+
assert.Contains(t, notice, "issue:org/repo#3")
335+
}
336+
337+
// TestBuildDIFCFilteredNotice_ExceedsLimit verifies that when more than
338+
// maxFilteredItemsInNotice items are filtered, only the count is reported.
339+
func TestBuildDIFCFilteredNotice_ExceedsLimit(t *testing.T) {
340+
items := make([]difc.FilteredItemDetail, maxFilteredItemsInNotice+1)
341+
for i := range items {
342+
items[i] = newTestFilteredItem(nil, fmt.Sprintf("issue:org/repo#%d", i+1), "integrity too low", nil, nil)
343+
}
344+
f := &difc.FilteredCollectionLabeledData{
345+
Filtered: items,
346+
TotalCount: len(items),
347+
}
348+
349+
notice := buildDIFCFilteredNotice(f)
350+
351+
assert.NotEmpty(t, notice)
352+
assert.Contains(t, notice, "[DIFC]")
353+
assert.Contains(t, notice, fmt.Sprintf("%d item(s)", len(items)))
354+
// Individual descriptions should NOT appear when the count exceeds the limit.
355+
assert.NotContains(t, notice, "issue:org/repo#1")
356+
}
357+
358+
// TestBuildDIFCFilteredNotice_ItemWithNoDescription verifies that items without
359+
// a description still produce a valid count-only notice.
360+
func TestBuildDIFCFilteredNotice_ItemWithNoDescription(t *testing.T) {
361+
f := &difc.FilteredCollectionLabeledData{
362+
Filtered: []difc.FilteredItemDetail{
363+
{
364+
Item: difc.LabeledItem{Data: "raw", Labels: difc.NewLabeledResource("")},
365+
Reason: "",
366+
},
367+
},
368+
TotalCount: 1,
369+
}
370+
371+
notice := buildDIFCFilteredNotice(f)
372+
373+
assert.NotEmpty(t, notice)
374+
assert.Contains(t, notice, "[DIFC]")
375+
assert.Contains(t, notice, "1 item(s)")
376+
}

internal/server/unified.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName
958958

959959
// **Phase 5: Reference Monitor performs fine-grained filtering (if applicable)**
960960
var finalResult interface{}
961+
var difcFiltered *difc.FilteredCollectionLabeledData // tracks items removed in filter/propagate mode
961962
if labeledData != nil {
962963
// Guard provided fine-grained labels - check if it's a collection
963964
if collection, ok := labeledData.(*difc.CollectionLabeledData); ok {
@@ -986,6 +987,7 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName
986987
if filtered.GetFilteredCount() > 0 {
987988
log.Printf("[DIFC] Filtered out %d items due to DIFC policy", filtered.GetFilteredCount())
988989
logFilteredItems(serverID, toolName, filtered)
990+
difcFiltered = filtered
989991
}
990992

991993
// Convert filtered data to result
@@ -1028,6 +1030,16 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName
10281030
return newErrorCallToolResult(fmt.Errorf("failed to convert result: %w", err))
10291031
}
10301032

1033+
// If items were filtered by DIFC policy in filter/propagate mode, append a notice so
1034+
// the agent knows items exist but were withheld. Without this, an agent receiving an
1035+
// empty (or partial) list has no way to distinguish "no items" from "items filtered",
1036+
// which can cause targeted-dispatch workflows to silently fall back to scheduled mode.
1037+
if difcFiltered != nil {
1038+
if notice := buildDIFCFilteredNotice(difcFiltered); notice != "" {
1039+
callResult.Content = append(callResult.Content, &sdk.TextContent{Text: notice})
1040+
}
1041+
}
1042+
10311043
return callResult, finalResult, nil
10321044
}
10331045

0 commit comments

Comments
 (0)