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
12 changes: 9 additions & 3 deletions core/gate/approved_scripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,15 @@ func ReadApprovedScriptRegistry(path string) ([]schemagate.ApprovedScriptEntry,
type registryEnvelope struct {
Entries []schemagate.ApprovedScriptEntry `json:"entries"`
}
var envelope registryEnvelope
if err := json.Unmarshal(content, &envelope); err == nil && len(envelope.Entries) > 0 {
return normalizeApprovedScriptEntries(envelope.Entries)
var envelopeRaw map[string]json.RawMessage
if err := json.Unmarshal(content, &envelopeRaw); err == nil {
if rawEntries, ok := envelopeRaw["entries"]; ok {
var envelope registryEnvelope
if err := json.Unmarshal(rawEntries, &envelope.Entries); err != nil {
return nil, fmt.Errorf("parse approved script registry: %w", err)
}
return normalizeApprovedScriptEntries(envelope.Entries)
Comment on lines +184 to +187

Choose a reason for hiding this comment

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

P2 Badge Validate entries is an array in registry envelope

This parsing path also treats {"entries":null} as valid because unmarshalling null into a slice returns no error, so invalid registry content is silently normalized to an empty set. cmd/gait/gate.go only enters its fail-closed path for configured approved-script registries when ReadApprovedScriptRegistry returns an error, so malformed registry state no longer surfaces as an unavailable/invalid registry in high-risk or oss-prod contexts.

Useful? React with 👍 / 👎.

}
}

var entries []schemagate.ApprovedScriptEntry
Expand Down
12 changes: 12 additions & 0 deletions core/gate/approved_scripts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ func TestReadApprovedScriptRegistryVariants(t *testing.T) {
t.Fatalf("expected empty entries for blank registry file, got %#v", entries)
}

emptyEnvelopePath := filepath.Join(t.TempDir(), "empty_envelope.json")
if err := os.WriteFile(emptyEnvelopePath, []byte(`{"entries":[]}`), 0o600); err != nil {
t.Fatalf("write empty envelope registry fixture: %v", err)
}
entries, err = ReadApprovedScriptRegistry(emptyEnvelopePath)
if err != nil {
t.Fatalf("read empty envelope approved script registry: %v", err)
}
if len(entries) != 0 {
t.Fatalf("expected empty entries for empty envelope registry, got %#v", entries)
}

nowUTC := time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC)
legacyPath := filepath.Join(t.TempDir(), "legacy.json")
legacyJSON := []byte(`[
Expand Down
16 changes: 13 additions & 3 deletions core/gate/context_wrkr.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,19 @@ func parseWrkrInventory(content []byte) (map[string]WrkrToolMetadata, error) {
}

entries := []item{}
var wrapped envelope
if err := json.Unmarshal(content, &wrapped); err == nil && len(wrapped.Items) > 0 {
entries = wrapped.Items
var wrappedRaw map[string]json.RawMessage
if err := json.Unmarshal(content, &wrappedRaw); err == nil {
if rawItems, ok := wrappedRaw["items"]; ok {
var wrapped envelope
if err := json.Unmarshal(rawItems, &wrapped.Items); err != nil {
return nil, fmt.Errorf("parse wrkr inventory: %w", err)
}
entries = wrapped.Items
Comment on lines +101 to +104

Choose a reason for hiding this comment

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

P1 Badge Validate items is an array in Wrkr envelope

This branch now accepts {"items":null} because unmarshalling null into a slice succeeds and yields nil, so malformed inventory payloads are treated as an empty inventory instead of an error. In cmd/gait/gate.go, high-risk/oss-prod fail-closed behavior for --wrkr-inventory is only triggered when LoadWrkrInventory returns an error, so this allows a broken inventory file to silently disable wrkr.* context enrichment and can cause context-based block rules to be skipped.

Useful? React with 👍 / 👎.

} else {
if err := json.Unmarshal(content, &entries); err != nil {
return nil, fmt.Errorf("parse wrkr inventory: %w", err)
}
}
} else {
if err := json.Unmarshal(content, &entries); err != nil {
return nil, fmt.Errorf("parse wrkr inventory: %w", err)
Expand Down
14 changes: 14 additions & 0 deletions core/gate/context_wrkr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ func TestLoadWrkrInventoryRejectsInvalidPayload(t *testing.T) {
}
}

func TestLoadWrkrInventoryAcceptsEmptyEnvelope(t *testing.T) {
workDir := t.TempDir()
path := filepath.Join(workDir, "wrkr_inventory.json")
mustWriteWrkrInventoryFile(t, path, `{"items":[]}`)

inventory, err := LoadWrkrInventory(path)
if err != nil {
t.Fatalf("LoadWrkrInventory returned error for empty envelope: %v", err)
}
if len(inventory.Tools) != 0 {
t.Fatalf("expected no tools from empty envelope, got %#v", inventory.Tools)
}
}

func TestApplyWrkrContextAddsMetadata(t *testing.T) {
intent := schemagate.IntentRequest{
Context: schemagate.IntentContext{},
Expand Down
13 changes: 13 additions & 0 deletions core/gate/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@ func evaluateScriptPolicyDetailed(policy Policy, intent schemagate.IntentRequest

for index, step := range intent.Script.Steps {
stepIntent := intent
stepIntent.Context = intent.Context
stepIntent.Context.AuthContext = cloneAuthContext(intent.Context.AuthContext)
stepIntent.Script = nil
stepIntent.ScriptHash = ""
stepIntent.ToolName = step.ToolName
Expand Down Expand Up @@ -501,6 +503,17 @@ func mergeRateLimitPolicy(current RateLimitPolicy, candidate RateLimitPolicy) Ra
return current
}

func cloneAuthContext(input map[string]any) map[string]any {
if input == nil {
return nil
}
output := make(map[string]any, len(input))
for key, value := range input {
output[key] = value
}
return output
}

func normalizedWindowPriority(window string) int {
switch strings.ToLower(strings.TrimSpace(window)) {
case "minute":
Expand Down
57 changes: 57 additions & 0 deletions core/gate/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,63 @@ rules:
}
}

func TestEvaluateScriptIntentWrkrContextDoesNotLeakAcrossSteps(t *testing.T) {
policy, err := ParsePolicyYAML([]byte(`
default_verdict: allow
rules:
- name: block-write-with-pii-context
effect: block
match:
tool_names: [tool.write]
context_data_classes: [pii]
`))
if err != nil {
t.Fatalf("parse policy: %v", err)
}
intent := baseIntent()
intent.ToolName = "script"
intent.Script = &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{
ToolName: "tool.read",
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/in.txt", Operation: "read"},
},
},
{
ToolName: "tool.write",
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/out.txt", Operation: "write"},
},
},
},
}

outcome, err := EvaluatePolicyDetailed(policy, intent, EvalOptions{
ProducerVersion: "test",
WrkrInventory: map[string]WrkrToolMetadata{
"tool.read": {
ToolName: "tool.read",
DataClass: "pii",
EndpointClass: "fs.read",
AutonomyLevel: "assist",
},
},
})
if err != nil {
t.Fatalf("evaluate script policy: %v", err)
}
if outcome.Result.Verdict != "allow" {
t.Fatalf("expected allow verdict without context leakage, got %#v", outcome.Result)
}
if len(outcome.StepVerdicts) != 2 {
t.Fatalf("expected two step verdicts, got %#v", outcome.StepVerdicts)
}
if outcome.StepVerdicts[1].Verdict != "allow" {
t.Fatalf("expected write step to remain allow without wrkr match, got %#v", outcome.StepVerdicts[1])
}
}

func TestRuleMatchesWrkrContextFields(t *testing.T) {
policy, err := ParsePolicyYAML([]byte(`
default_verdict: allow
Expand Down
Loading