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
5 changes: 3 additions & 2 deletions internal/difc/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,9 @@ func (e *Evaluator) FilterCollection(
filtered.Accessible = append(filtered.Accessible, item)
} else {
filtered.Filtered = append(filtered.Filtered, FilteredItemDetail{
Item: item,
Reason: result.Reason,
Item: item,
Reason: result.Reason,
IsSecrecyViolation: len(result.SecrecyToAdd) > 0,
})
}
}
Expand Down
31 changes: 31 additions & 0 deletions internal/difc/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,8 @@ func TestEvaluator_FilterCollection_FilteredItemsHaveReasons(t *testing.T) {
require.Equal(t, 1, filtered.GetFilteredCount(), "item should be filtered")
assert.NotEmpty(t, filtered.Filtered[0].Reason,
"filtered item must carry a denial reason for the audit trail")
assert.True(t, filtered.Filtered[0].IsSecrecyViolation,
"secrecy-blocked item must have IsSecrecyViolation=true")
})

t.Run("integrity violation reason is non-empty", func(t *testing.T) {
Expand All @@ -1111,6 +1113,8 @@ func TestEvaluator_FilterCollection_FilteredItemsHaveReasons(t *testing.T) {
require.Equal(t, 1, filtered.GetFilteredCount(), "item should be filtered")
assert.NotEmpty(t, filtered.Filtered[0].Reason,
"filtered item must carry a denial reason for the audit trail")
assert.False(t, filtered.Filtered[0].IsSecrecyViolation,
"integrity-blocked item must have IsSecrecyViolation=false")
})

t.Run("every filtered item in a mixed collection has a reason", func(t *testing.T) {
Expand All @@ -1131,8 +1135,35 @@ func TestEvaluator_FilterCollection_FilteredItemsHaveReasons(t *testing.T) {
for i, detail := range filtered.Filtered {
assert.NotEmpty(t, detail.Reason,
"filtered item[%d] must have a non-empty denial reason", i)
assert.True(t, detail.IsSecrecyViolation,
"filtered item[%d] blocked by secrecy must have IsSecrecyViolation=true", i)
}
})

t.Run("IsSecrecyViolation is false for integrity-only violation", func(t *testing.T) {
// Agent requires approved integrity; item has none.
agentSecrecy := NewSecrecyLabel()
agentIntegrity := NewIntegrityLabelWithTags([]Tag{"approved:org/repo"})

collection := &CollectionLabeledData{
Items: []LabeledItem{
{
Data: "low-integrity-item",
Labels: &LabeledResource{
Description: "unapproved PR",
Secrecy: *NewSecrecyLabel(),
Integrity: *NewIntegrityLabel(), // empty
},
},
},
}

filtered := eval.FilterCollection(agentSecrecy, agentIntegrity, collection, OperationRead)

require.Equal(t, 1, filtered.GetFilteredCount())
assert.False(t, filtered.Filtered[0].IsSecrecyViolation,
"integrity-only violation must not be marked as secrecy")
})
}

// TestEvaluator_StrictMode_Read_Unchanged verifies strict mode still denies reads
Expand Down
5 changes: 3 additions & 2 deletions internal/difc/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ func (c *CollectionLabeledData) ToResult() (interface{}, error) {

// FilteredItemDetail pairs a filtered item with the reason it was denied
type FilteredItemDetail struct {
Item LabeledItem
Reason string // Human-readable denial reason from EvaluationResult
Item LabeledItem
Reason string // Human-readable denial reason from EvaluationResult
IsSecrecyViolation bool // True when the item was blocked due to secrecy requirements; false when due to integrity
}

// FilteredCollectionLabeledData represents a collection with some items filtered out
Expand Down
38 changes: 33 additions & 5 deletions internal/server/difc_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,16 @@ func extractNumberField(m map[string]interface{}) string {
const maxFilteredItemsInNotice = 5

// buildDIFCFilteredNotice builds a human-readable notice for the agent when items are
// removed from a tool response by DIFC integrity policy in filter/propagate mode.
// removed from a tool response by DIFC policy in filter/propagate mode.
//
// The notice is surfaced as an additional text content block appended to the tool
// response so that agents (and targeted-dispatch workflows) are aware that items exist
// but were withheld, rather than concluding the result set is genuinely empty.
//
// The notice distinguishes between secrecy-blocked and integrity-blocked items so that
// downstream consumers can provide accurate guidance (e.g. secrecy violations cannot be
// resolved by lowering min-integrity).
//
// For up to maxFilteredItemsInNotice items the description and reason for each item are
// included. For larger sets only the count is reported to keep the message concise.
func buildDIFCFilteredNotice(filtered *difc.FilteredCollectionLabeledData) string {
Expand All @@ -124,6 +128,9 @@ func buildDIFCFilteredNotice(filtered *difc.FilteredCollectionLabeledData) strin

logDifcLog.Printf("Building DIFC filtered notice: filteredCount=%d, maxInline=%d", n, maxFilteredItemsInNotice)

// Determine the policy label: distinguish secrecy-only, integrity-only, or mixed.
policyLabel := difcPolicyLabel(filtered.Filtered)

// For a small number of filtered items, include per-item descriptions and reasons.
if n <= maxFilteredItemsInNotice {
logDifcLog.Printf("Using per-item notice format for %d item(s)", n)
Expand All @@ -147,14 +154,35 @@ func buildDIFCFilteredNotice(filtered *difc.FilteredCollectionLabeledData) strin
}
if len(parts) > 0 {
return fmt.Sprintf(
"[DIFC] %d item(s) in this response were removed by integrity policy and are not shown: %s.",
n, strings.Join(parts, "; "),
"[Filtered] %d item(s) in this response were removed by %s and are not shown: %s.",
n, policyLabel, strings.Join(parts, "; "),
)
}
}

return fmt.Sprintf(
"[DIFC] %d item(s) in this response were removed by integrity policy and are not shown.",
n,
"[Filtered] %d item(s) in this response were removed by %s and are not shown.",
n, policyLabel,
)
}

// difcPolicyLabel returns a human-readable policy label based on whether the filtered
// items were blocked due to secrecy, integrity, or a mix of both.
func difcPolicyLabel(items []difc.FilteredItemDetail) string {
secrecyCount, integrityCount := 0, 0
for _, d := range items {
if d.IsSecrecyViolation {
secrecyCount++
} else {
integrityCount++
}
}
switch {
case secrecyCount > 0 && integrityCount == 0:
return "secrecy policy"
case integrityCount > 0 && secrecyCount == 0:
return "integrity policy"
default:
return "access policy"
}
}
123 changes: 117 additions & 6 deletions internal/server/difc_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (
)

// newTestFilteredItem builds a FilteredItemDetail with the given map data, secrecy tags,
// integrity tags, description, and denial reason.
// integrity tags, description, and denial reason. IsSecrecyViolation is inferred from
// whether secrecyTags is non-empty (matching how FilterCollection sets the field).
func newTestFilteredItem(data map[string]interface{}, description, reason string, secrecyTags, integrityTags []string) difc.FilteredItemDetail {
labels := difc.NewLabeledResource(description)
if len(secrecyTags) > 0 {
Expand All @@ -29,7 +30,26 @@ func newTestFilteredItem(data map[string]interface{}, description, reason string
Data: data,
Labels: labels,
},
Reason: reason,
Reason: reason,
IsSecrecyViolation: len(secrecyTags) > 0,
}
Comment on lines 17 to +35

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

newTestFilteredItem sets IsSecrecyViolation based on whether secrecyTags is non-empty, but in the main codepath (Evaluator.FilterCollection) the flag is set based on whether a secrecy violation occurred (len(result.SecrecyToAdd) > 0). An item can legitimately have secrecy tags while being filtered for integrity reasons (or have secrecy-related reason text without tags in these tests), so this inference can produce incorrect IsSecrecyViolation values and make tests brittle/misleading. Consider either (a) passing an explicit isSecrecyViolation parameter to the helper, (b) deriving it from the same condition the production code uses for filtered items in these tests, or (c) leaving it unset in helpers that aren’t specifically constructing filtered-by-secrecy cases.

Copilot uses AI. Check for mistakes.
}

// newSecrecyFilteredItem builds a FilteredItemDetail explicitly marked as a secrecy violation.
func newSecrecyFilteredItem(description, reason string) difc.FilteredItemDetail {
return difc.FilteredItemDetail{
Item: difc.LabeledItem{Labels: difc.NewLabeledResource(description)},
Reason: reason,
IsSecrecyViolation: true,
}
}

// newIntegrityFilteredItem builds a FilteredItemDetail explicitly marked as an integrity violation.
func newIntegrityFilteredItem(description, reason string) difc.FilteredItemDetail {
return difc.FilteredItemDetail{
Item: difc.LabeledItem{Labels: difc.NewLabeledResource(description)},
Reason: reason,
IsSecrecyViolation: false,
}
}

Expand Down Expand Up @@ -306,7 +326,7 @@ func TestBuildDIFCFilteredNotice_SingleItem(t *testing.T) {
notice := buildDIFCFilteredNotice(f)

assert.NotEmpty(t, notice)
assert.Contains(t, notice, "[DIFC]")
assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, "1 item(s)")
assert.Contains(t, notice, "issue:org/repo#14")
assert.Contains(t, notice, "integrity too low for agent context")
Expand All @@ -327,7 +347,7 @@ func TestBuildDIFCFilteredNotice_MultipleItemsWithinLimit(t *testing.T) {
notice := buildDIFCFilteredNotice(f)

assert.NotEmpty(t, notice)
assert.Contains(t, notice, "[DIFC]")
assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, "3 item(s)")
assert.Contains(t, notice, "issue:org/repo#1")
assert.Contains(t, notice, "issue:org/repo#2")
Expand All @@ -349,7 +369,7 @@ func TestBuildDIFCFilteredNotice_ExceedsLimit(t *testing.T) {
notice := buildDIFCFilteredNotice(f)

assert.NotEmpty(t, notice)
assert.Contains(t, notice, "[DIFC]")
assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, fmt.Sprintf("%d item(s)", len(items)))
// Individual descriptions should NOT appear when the count exceeds the limit.
assert.NotContains(t, notice, "issue:org/repo#1")
Expand All @@ -371,6 +391,97 @@ func TestBuildDIFCFilteredNotice_ItemWithNoDescription(t *testing.T) {
notice := buildDIFCFilteredNotice(f)

assert.NotEmpty(t, notice)
assert.Contains(t, notice, "[DIFC]")
assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, "1 item(s)")
}

// TestBuildDIFCFilteredNotice_SecrecyViolation verifies that secrecy-blocked items
// produce a notice that says "secrecy policy", not "integrity policy".
func TestBuildDIFCFilteredNotice_SecrecyViolation(t *testing.T) {
f := &difc.FilteredCollectionLabeledData{
Filtered: []difc.FilteredItemDetail{
newSecrecyFilteredItem("resource:actions_get", "has secrecy requirements that agent doesn't meet"),
},
TotalCount: 1,
}

notice := buildDIFCFilteredNotice(f)

assert.NotEmpty(t, notice)
assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, "1 item(s)")
assert.Contains(t, notice, "secrecy policy")
assert.NotContains(t, notice, "integrity policy")
assert.Contains(t, notice, "resource:actions_get")
}

// TestBuildDIFCFilteredNotice_IntegrityViolation verifies that integrity-blocked items
// produce a notice that says "integrity policy".
func TestBuildDIFCFilteredNotice_IntegrityViolation(t *testing.T) {
f := &difc.FilteredCollectionLabeledData{
Filtered: []difc.FilteredItemDetail{
newIntegrityFilteredItem("issue:org/repo#14", "integrity too low for agent context"),
},
TotalCount: 1,
}

notice := buildDIFCFilteredNotice(f)

assert.Contains(t, notice, "integrity policy")
assert.NotContains(t, notice, "secrecy policy")
}

// TestBuildDIFCFilteredNotice_MixedViolations verifies that a mix of secrecy and
// integrity blocks produces a notice that says "access policy".
func TestBuildDIFCFilteredNotice_MixedViolations(t *testing.T) {
f := &difc.FilteredCollectionLabeledData{
Filtered: []difc.FilteredItemDetail{
newSecrecyFilteredItem("resource:actions_get", "secrecy mismatch"),
newIntegrityFilteredItem("issue:org/repo#1", "integrity too low"),
},
TotalCount: 2,
}

notice := buildDIFCFilteredNotice(f)

assert.Contains(t, notice, "[Filtered]")
assert.Contains(t, notice, "2 item(s)")
assert.Contains(t, notice, "access policy")
assert.NotContains(t, notice, "integrity policy")
assert.NotContains(t, notice, "secrecy policy")
}

// TestDifcPolicyLabel verifies the policy label selection logic.
func TestDifcPolicyLabel(t *testing.T) {
tests := []struct {
name string
items []difc.FilteredItemDetail
expected string
}{
{
name: "all secrecy violations",
items: []difc.FilteredItemDetail{{IsSecrecyViolation: true}, {IsSecrecyViolation: true}},
expected: "secrecy policy",
},
{
name: "all integrity violations",
items: []difc.FilteredItemDetail{{IsSecrecyViolation: false}, {IsSecrecyViolation: false}},
expected: "integrity policy",
},
{
name: "mixed violations",
items: []difc.FilteredItemDetail{{IsSecrecyViolation: true}, {IsSecrecyViolation: false}},
expected: "access policy",
},
{
name: "empty items defaults to access policy",
items: []difc.FilteredItemDetail{},
expected: "access policy",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, difcPolicyLabel(tc.items))
})
}
}
Loading