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
21 changes: 20 additions & 1 deletion cmd/gait/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ func runGateEval(arguments []string) int {
result.Violations = mergeUniqueSorted(result.Violations, []string{"rate_limit_exceeded"})
}
}
if outcome.DestructiveBudget.Requests > 0 && gate.IntentContainsDestructiveTarget(intent.Targets) {
if outcome.DestructiveBudget.Requests > 0 && gateIntentContainsDestructiveTarget(intent) {
budgetIntent := intent
budgetIntent.ToolName = "destructive_budget|" + strings.TrimSpace(intent.ToolName)
destructiveBudgetDecision, err = gate.EnforceRateLimit(rateLimitState, outcome.DestructiveBudget, budgetIntent, time.Now().UTC())
Expand Down Expand Up @@ -885,6 +885,25 @@ func gateIntentOperationCount(intent schemagate.IntentRequest) int {
return len(intent.Targets)
}

func gateIntentContainsDestructiveTarget(intent schemagate.IntentRequest) bool {
if intent.Script != nil && len(intent.Script.Steps) > 0 {
sawScriptTargets := false
for _, step := range intent.Script.Steps {
if len(step.Targets) == 0 {
continue
}
sawScriptTargets = true
if gate.IntentContainsDestructiveTarget(step.Targets) {
return true
}
}
if sawScriptTargets {
return false
}
}
return gate.IntentContainsDestructiveTarget(intent.Targets)
}

func buildPreApprovedOutcome(intent schemagate.IntentRequest, producerVersion string, match gate.ApprovedScriptMatch) (gate.EvalOutcome, error) {
normalizedIntent, err := gate.NormalizeIntent(intent)
if err != nil {
Expand Down
36 changes: 36 additions & 0 deletions cmd/gait/gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,39 @@ func TestGateIntentOperationCountDefaultsToOneWithoutTargets(t *testing.T) {
t.Fatalf("gateIntentOperationCount() = %d, want 1", got)
}
}

func TestGateIntentContainsDestructiveTargetUsesScriptTargets(t *testing.T) {
intent := schemagate.IntentRequest{
Script: &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{
ToolName: "tool.delete",
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/a", EndpointClass: "fs.delete", Destructive: true},
},
},
},
},
}

if !gateIntentContainsDestructiveTarget(intent) {
t.Fatalf("expected script targets to be considered for destructive budget enforcement")
}
}

func TestGateIntentContainsDestructiveTargetFallsBackToTopLevel(t *testing.T) {
intent := schemagate.IntentRequest{
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/a", EndpointClass: "fs.delete", Destructive: true},
},
Script: &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{ToolName: "tool.script"},
},
},
}

if !gateIntentContainsDestructiveTarget(intent) {
t.Fatalf("expected fallback to top-level targets when script targets are empty")
}
}
87 changes: 87 additions & 0 deletions cmd/gait/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,93 @@ func TestGateEvalCredentialCommandBrokerAndRateLimit(t *testing.T) {
}
}

func TestGateEvalDestructiveBudgetAppliesToScriptTargets(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)

policyPath := filepath.Join(workDir, "policy_destructive_budget.yaml")
mustWriteFile(t, policyPath, strings.Join([]string{
"default_verdict: allow",
"rules:",
" - name: destructive-script-budget",
" effect: allow",
" destructive_budget:",
" requests: 1",
" scope: tool_identity",
" window: minute",
" match:",
" endpoint_classes: [fs.delete]",
}, "\n")+"\n")

intent := schemagate.IntentRequest{
SchemaID: "gait.gate.intent_request",
SchemaVersion: "1.0.0",
CreatedAt: time.Date(2026, time.February, 24, 12, 0, 0, 0, time.UTC),
ProducerVersion: "test",
ToolName: "script",
Args: map[string]any{},
Targets: []schemagate.IntentTarget{},
Context: schemagate.IntentContext{
Identity: "alice",
Workspace: "/repo/gait",
RiskClass: "high",
Phase: "plan",
},
Script: &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{
ToolName: "tool.delete",
Args: map[string]any{"path": "/tmp/a"},
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/a", Operation: "delete", EndpointClass: "fs.delete", Destructive: true},
},
},
},
},
}
intentPath := filepath.Join(workDir, "intent_script_delete.json")
rawIntent, err := json.MarshalIndent(intent, "", " ")
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
mustWriteFile(t, intentPath, string(rawIntent)+"\n")

rateStatePath := filepath.Join(workDir, "rate_state.json")
if code := runGateEval([]string{
"--policy", policyPath,
"--intent", intentPath,
"--rate-limit-state", rateStatePath,
"--json",
}); code != exitOK {
t.Fatalf("runGateEval first script call: expected %d got %d", exitOK, code)
}

rawSecond := captureStdout(t, func() {
if code := runGateEval([]string{
"--policy", policyPath,
"--intent", intentPath,
"--rate-limit-state", rateStatePath,
"--json",
}); code != exitPolicyBlocked {
t.Fatalf("runGateEval second script call: expected %d got %d", exitPolicyBlocked, code)
}
})
var out gateEvalOutput
if err := json.Unmarshal([]byte(rawSecond), &out); err != nil {
t.Fatalf("decode second script gate eval output: %v (%s)", err, rawSecond)
}
foundBudgetReason := false
for _, reason := range out.ReasonCodes {
if reason == "destructive_budget_exceeded" {
foundBudgetReason = true
break
}
}
if !foundBudgetReason {
t.Fatalf("expected destructive_budget_exceeded reason code, got %#v", out.ReasonCodes)
}
}

func TestGateEvalCredentialCommandBrokerFailureDoesNotLeakSecrets(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)
Expand Down
Loading