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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ See: [2,880 tool calls gate-checked in 24 hours](docs/blog/openclaw_24h_boundary

**Incident → CI gate in one command** — `gait regress bootstrap` converts a bad run into a permanent regression fixture with JUnit output. Exit 0 = pass, exit 5 = drift. Never debug the same failure twice.

**Durable jobs** — dispatch long-running agent work that survives failures. Checkpoints, pause/resume/cancel, approval gates, deterministic stop reasons. No more lost state at step 47.
**Durable jobs** — dispatch long-running agent work that survives failures. Checkpoints, pause/resume/stop/cancel, approval gates, deterministic stop reasons, and emergency preemption for queued dispatches. No more lost state at step 47.

**Destructive safety boundary** — enforce phase-aware plan/apply behavior (`plan` stays non-destructive, destructive `apply` requires approval), plus fail-closed destructive budgets and bounded approval token scopes (`max-targets`, `max-ops`).

**Deterministic replay and diff** — replay an agent run using recorded results as stubs (no real API calls). Diff two packs to see what changed, including context drift classification.

Expand Down Expand Up @@ -198,7 +200,7 @@ gait tour Interactive walkthrough
gait verify <run_id|path> Verify integrity offline
gait verify chain|session-chain Multi-artifact chain verification
gait job submit|status|checkpoint|pause|resume Durable job lifecycle
gait job approve|cancel|inspect Job approval and inspection
gait job stop|approve|cancel|inspect Emergency stop, approval, and inspection
gait pack build|verify|inspect|diff|export Unified pack operations + OTEL/Postgres sinks
gait regress init|bootstrap|run Incident → CI gate
gait gate eval Policy enforcement + signed trace
Expand Down
12 changes: 11 additions & 1 deletion cmd/gait/approve.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type approveOutput struct {
ExpiresAt string `json:"expires_at,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
Scope []string `json:"scope,omitempty"`
MaxTargets int `json:"max_targets,omitempty"`
MaxOps int `json:"max_ops,omitempty"`
KeyID string `json:"key_id,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
Expand All @@ -38,6 +40,8 @@ func runApprove(arguments []string) int {
var scope string
var approver string
var reasonCode string
var maxTargets int
var maxOps int
var outputPath string
var keyMode string
var privateKeyPath string
Expand All @@ -52,6 +56,8 @@ func runApprove(arguments []string) int {
flagSet.StringVar(&scope, "scope", "", "comma-separated approval scope values (for example tool:tool.write)")
flagSet.StringVar(&approver, "approver", "", "approver identity")
flagSet.StringVar(&reasonCode, "reason-code", "", "approval reason code")
flagSet.IntVar(&maxTargets, "max-targets", 0, "optional max target count bound for destructive approval scope (0 disables)")
flagSet.IntVar(&maxOps, "max-ops", 0, "optional max operation count bound for destructive approval scope (0 disables)")
flagSet.StringVar(&outputPath, "out", "", "path to emitted approval token (default approval_<token_id>.json)")
flagSet.StringVar(&keyMode, "key-mode", string(sign.ModeDev), "signing key mode: dev or prod")
flagSet.StringVar(&privateKeyPath, "private-key", "", "path to base64 private signing key")
Expand Down Expand Up @@ -96,6 +102,8 @@ func runApprove(arguments []string) int {
PolicyDigest: policyDigest,
DelegationBindingDigest: delegationBindingDigest,
Scope: scopeValues,
MaxTargets: maxTargets,
MaxOps: maxOps,
TTL: ttlDuration,
SigningPrivateKey: keyPair.Private,
TokenPath: outputPath,
Expand All @@ -115,6 +123,8 @@ func runApprove(arguments []string) int {
ExpiresAt: result.Token.ExpiresAt.UTC().Format(time.RFC3339),
ReasonCode: result.Token.ReasonCode,
Scope: result.Token.Scope,
MaxTargets: result.Token.MaxTargets,
MaxOps: result.Token.MaxOps,
KeyID: keyID,
Warnings: warnings,
Description: "signed approval token created",
Expand Down Expand Up @@ -149,5 +159,5 @@ func writeApproveOutput(jsonOutput bool, output approveOutput, exitCode int) int

func printApproveUsage() {
fmt.Println("Usage:")
fmt.Println(" gait approve --intent-digest <sha256> --policy-digest <sha256> [--delegation-binding-digest <sha256>] --ttl <duration> --scope <csv> --approver <identity> --reason-code <code> [--out token.json] [--key-mode dev|prod] [--private-key <path>|--private-key-env <VAR>] [--json] [--explain]")
fmt.Println(" gait approve --intent-digest <sha256> --policy-digest <sha256> [--delegation-binding-digest <sha256>] --ttl <duration> --scope <csv> --approver <identity> --reason-code <code> [--max-targets <n>] [--max-ops <n>] [--out token.json] [--key-mode dev|prod] [--private-key <path>|--private-key-env <VAR>] [--json] [--explain]")
}
49 changes: 49 additions & 0 deletions cmd/gait/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ type gateEvalOutput struct {
PatternID string `json:"pattern_id,omitempty"`
RegistryReason string `json:"registry_reason,omitempty"`
MatchedRule string `json:"matched_rule,omitempty"`
Phase string `json:"phase,omitempty"`
RateLimitScope string `json:"rate_limit_scope,omitempty"`
RateLimitKey string `json:"rate_limit_key,omitempty"`
RateLimitUsed int `json:"rate_limit_used,omitempty"`
RateLimitRemaining int `json:"rate_limit_remaining,omitempty"`
DestructiveBudgetScope string `json:"destructive_budget_scope,omitempty"`
DestructiveBudgetKey string `json:"destructive_budget_key,omitempty"`
DestructiveBudgetUsed int `json:"destructive_budget_used,omitempty"`
DestructiveBudgetRemaining int `json:"destructive_budget_remaining,omitempty"`
CredentialIssuer string `json:"credential_issuer,omitempty"`
CredentialRef string `json:"credential_ref,omitempty"`
CredentialEvidencePath string `json:"credential_evidence_path,omitempty"`
Expand Down Expand Up @@ -394,6 +399,7 @@ func runGateEval(arguments []string) int {
}

var rateDecision gate.RateLimitDecision
var destructiveBudgetDecision gate.RateLimitDecision
if outcome.RateLimit.Requests > 0 {
rateDecision, err = gate.EnforceRateLimit(rateLimitState, outcome.RateLimit, intent, time.Now().UTC())
if err != nil {
Expand All @@ -405,6 +411,19 @@ func runGateEval(arguments []string) int {
result.Violations = mergeUniqueSorted(result.Violations, []string{"rate_limit_exceeded"})
}
}
if outcome.DestructiveBudget.Requests > 0 && gate.IntentContainsDestructiveTarget(intent.Targets) {

Choose a reason for hiding this comment

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

P1 Badge Enforce destructive budget for script step targets

runGateEval only applies outcome.DestructiveBudget when IntentContainsDestructiveTarget(intent.Targets) is true, but script evaluations put destructive operations in intent.script.steps[*].targets and often leave top-level intent.targets empty. In that common script context, the destructive budget is computed by policy (evaluateScriptPolicyDetailed aggregates it) but never enforced here, so repeated destructive script executions can bypass the configured destructive budget guard.

Useful? React with 👍 / 👎.

budgetIntent := intent
budgetIntent.ToolName = "destructive_budget|" + strings.TrimSpace(intent.ToolName)
destructiveBudgetDecision, err = gate.EnforceRateLimit(rateLimitState, outcome.DestructiveBudget, budgetIntent, time.Now().UTC())
if err != nil {
return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput))
}
if !destructiveBudgetDecision.Allowed {
result.Verdict = "block"
result.ReasonCodes = mergeUniqueSorted(result.ReasonCodes, []string{"destructive_budget_exceeded"})
result.Violations = mergeUniqueSorted(result.Violations, []string{"destructive_budget_exceeded"})
}
}

keyPair, signingWarnings, err := sign.LoadSigningKey(sign.KeyConfig{
Mode: sign.KeyMode(keyMode),
Expand Down Expand Up @@ -578,6 +597,8 @@ func runGateEval(arguments []string) int {
ExpectedPolicyDigest: policyDigestForContext,
ExpectedDelegationBindingDigest: delegationBindingDigest,
RequiredScope: requiredApprovalScope,
TargetCount: gateIntentTargetCount(intent),
OperationCount: gateIntentOperationCount(intent),
})
if err != nil {
reasonCode := gate.ApprovalCodeSchemaInvalid
Expand Down Expand Up @@ -803,10 +824,15 @@ func runGateEval(arguments []string) int {
PatternID: outcome.PatternID,
RegistryReason: outcome.RegistryReason,
MatchedRule: outcome.MatchedRule,
Phase: intent.Context.Phase,
RateLimitScope: rateDecision.Scope,
RateLimitKey: rateDecision.Key,
RateLimitUsed: rateDecision.Used,
RateLimitRemaining: rateDecision.Remaining,
DestructiveBudgetScope: destructiveBudgetDecision.Scope,
DestructiveBudgetKey: destructiveBudgetDecision.Key,
DestructiveBudgetUsed: destructiveBudgetDecision.Used,
DestructiveBudgetRemaining: destructiveBudgetDecision.Remaining,
CredentialIssuer: credentialIssuer,
CredentialRef: credentialRefOut,
CredentialEvidencePath: resolvedCredentialEvidencePath,
Expand Down Expand Up @@ -836,6 +862,29 @@ func gatherDelegationTokenPaths(primaryPath, chainCSV string) []string {
return mergeUniqueSorted(nil, paths)
}

func gateIntentTargetCount(intent schemagate.IntentRequest) int {
if intent.Script != nil && len(intent.Script.Steps) > 0 {
total := 0
for _, step := range intent.Script.Steps {
total += len(step.Targets)
}
if total > 0 {
return total
}
}
return len(intent.Targets)
}

func gateIntentOperationCount(intent schemagate.IntentRequest) int {
if intent.Script != nil && len(intent.Script.Steps) > 0 {
return len(intent.Script.Steps)
}
if len(intent.Targets) == 0 {
return 1
}
return len(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
71 changes: 71 additions & 0 deletions cmd/gait/gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"testing"

schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate"
)

func TestGateIntentTargetCountAndOperationCount(t *testing.T) {
intent := schemagate.IntentRequest{
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/fallback"},
},
Script: &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{
ToolName: "tool.read",
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/a"},
},
},
{
ToolName: "tool.write",
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/b"},
{Kind: "path", Value: "/tmp/c"},
},
},
},
},
}

if got := gateIntentTargetCount(intent); got != 3 {
t.Fatalf("gateIntentTargetCount() = %d, want 3", got)
}
if got := gateIntentOperationCount(intent); got != 2 {
t.Fatalf("gateIntentOperationCount() = %d, want 2", got)
}
}

func TestGateIntentTargetCountFallsBackWhenScriptTargetsMissing(t *testing.T) {
intent := schemagate.IntentRequest{
Targets: []schemagate.IntentTarget{
{Kind: "path", Value: "/tmp/fallback-a"},
{Kind: "path", Value: "/tmp/fallback-b"},
},
Script: &schemagate.IntentScript{
Steps: []schemagate.IntentScriptStep{
{ToolName: "tool.read"},
{ToolName: "tool.write"},
},
},
}

if got := gateIntentTargetCount(intent); got != 2 {
t.Fatalf("gateIntentTargetCount() = %d, want 2", got)
}
if got := gateIntentOperationCount(intent); got != 2 {
t.Fatalf("gateIntentOperationCount() = %d, want 2", got)
}
}

func TestGateIntentOperationCountDefaultsToOneWithoutTargets(t *testing.T) {
intent := schemagate.IntentRequest{}
if got := gateIntentTargetCount(intent); got != 0 {
t.Fatalf("gateIntentTargetCount() = %d, want 0", got)
}
if got := gateIntentOperationCount(intent); got != 1 {
t.Fatalf("gateIntentOperationCount() = %d, want 1", got)
}
}
16 changes: 16 additions & 0 deletions cmd/gait/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func runJob(arguments []string) int {
return runJobCheckpoint(arguments[1:])
case "pause":
return runJobPause(arguments[1:])
case "stop":
return runJobStop(arguments[1:])
case "approve":
return runJobApprove(arguments[1:])
case "resume":
Expand Down Expand Up @@ -308,6 +310,12 @@ func runJobCancel(arguments []string) int {
})
}

func runJobStop(arguments []string) int {
return runSimpleJobTransition(arguments, "stop", func(root, jobID, actor string) (jobruntime.JobState, error) {
return jobruntime.EmergencyStop(root, jobID, jobruntime.TransitionOptions{Actor: actor})
})
}

func runSimpleJobTransition(arguments []string, operation string, action func(root, jobID, actor string) (jobruntime.JobState, error)) int {
arguments = reorderInterspersedFlags(arguments, map[string]bool{"id": true, "root": true, "actor": true})
flagSet := flag.NewFlagSet("job-"+operation, flag.ContinueOnError)
Expand All @@ -332,6 +340,8 @@ func runSimpleJobTransition(arguments []string, operation string, action func(ro
switch operation {
case "pause":
printJobPauseUsage()
case "stop":
printJobStopUsage()
case "cancel":
printJobCancelUsage()
}
Expand Down Expand Up @@ -553,6 +563,7 @@ func printJobUsage() {
fmt.Println(" gait job checkpoint list --id <job_id> [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job checkpoint show --id <job_id> --checkpoint <checkpoint_id> [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job pause --id <job_id> [--actor <id>] [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job stop --id <job_id> [--actor <id>] [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job approve --id <job_id> --actor <id> [--reason <text>] [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job resume --id <job_id> [--actor <id>] [--identity <id>] [--reason <text>] [--policy <policy.yaml>|--policy-digest <sha256>] [--policy-ref <ref>] [--identity-revocations <path>|--identity-revoked] [--identity-validation-source <source>] [--env-fingerprint <value>] [--allow-env-mismatch] [--root ./gait-out/jobs] [--json] [--explain]")
fmt.Println(" gait job cancel --id <job_id> [--actor <id>] [--root ./gait-out/jobs] [--json] [--explain]")
Expand Down Expand Up @@ -596,6 +607,11 @@ func printJobPauseUsage() {
fmt.Println(" gait job pause --id <job_id> [--actor <id>] [--root ./gait-out/jobs] [--json] [--explain]")
}

func printJobStopUsage() {
fmt.Println("Usage:")
fmt.Println(" gait job stop --id <job_id> [--actor <id>] [--root ./gait-out/jobs] [--json] [--explain]")
}

func printJobApproveUsage() {
fmt.Println("Usage:")
fmt.Println(" gait job approve --id <job_id> --actor <id> [--reason <text>] [--root ./gait-out/jobs] [--json] [--explain]")
Expand Down
9 changes: 9 additions & 0 deletions cmd/gait/job_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ func TestRunJobLifecycleCommands(t *testing.T) {
t.Fatalf("unexpected pause output: %#v", pauseOut)
}

stopCode, stopOut := runJobJSON(t, []string{"stop", "--id", jobID, "--root", root, "--actor", "alice", "--json"})
if stopCode != exitOK {
t.Fatalf("stop expected %d got %d output=%#v", exitOK, stopCode, stopOut)
}
if stopOut.Job == nil || stopOut.Job.Status != "emergency_stopped" || stopOut.Job.StatusReasonCode != "emergency_stop_preempted" {
t.Fatalf("unexpected stop output: %#v", stopOut)
}

inspectCode, inspectOut := runJobJSON(t, []string{"inspect", "--id", jobID, "--root", root, "--json"})
if inspectCode != exitOK {
t.Fatalf("inspect expected %d got %d output=%#v", exitOK, inspectCode, inspectOut)
Expand Down Expand Up @@ -128,6 +136,7 @@ func TestRunJobHelpAndErrorPaths(t *testing.T) {
{"checkpoint", "list", "--help"},
{"checkpoint", "show", "--help"},
{"pause", "--help"},
{"stop", "--help"},
{"approve", "--help"},
{"resume", "--help"},
{"cancel", "--help"},
Expand Down
Loading
Loading