From 1918c54b397bdc5ab8f846fa66dac53ce8aa30f3 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Thu, 19 Feb 2026 21:21:56 -0500 Subject: [PATCH] gate: implement adhoc control approved-script flow --- .github/workflows/ci.yml | 15 + Makefile | 6 +- README.md | 4 +- cmd/gait/approve_script.go | 186 ++++++++++ cmd/gait/approved_script_test.go | 333 ++++++++++++++++++ cmd/gait/gate.go | 311 +++++++++++++--- cmd/gait/list_scripts.go | 106 ++++++ cmd/gait/main.go | 6 +- cmd/gait/main_test.go | 96 +++++ cmd/gait/mcp_test.go | 127 +++++++ cmd/gait/run_inspect_test.go | 23 ++ cmd/gait/run_session_test.go | 18 + cmd/gait/ui_test.go | 28 +- cmd/gait/verify.go | 2 + cmd/gait/verify_session_chain_test.go | 68 ++++ core/credential/providers_test.go | 13 + core/doctor/doctor.go | 1 + core/gate/approved_scripts.go | 290 +++++++++++++++ core/gate/approved_scripts_test.go | 171 +++++++++ core/gate/context_wrkr.go | 139 ++++++++ core/gate/context_wrkr_test.go | 122 +++++++ core/gate/intent.go | 136 ++++++- core/gate/intent_test.go | 106 ++++++ core/gate/policy.go | 332 ++++++++++++++++- core/gate/policy_test.go | 145 ++++++++ core/gate/trace.go | 17 + core/gate/trace_test.go | 54 +++ core/mcp/interfaces.go | 12 + core/mcp/proxy.go | 45 ++- core/mcp/proxy_test.go | 44 +++ core/projectconfig/config.go | 2 + core/projectconfig/config_test.go | 4 + core/schema/v1/gate/types.go | 90 +++-- core/scout/scout_test.go | 25 ++ docs-site/public/llm/contracts.md | 1 + docs-site/public/llm/faq.md | 4 + docs-site/public/llm/product.md | 2 +- docs-site/public/llm/quickstart.md | 8 + docs-site/public/llm/security.md | 1 + docs-site/public/llms.txt | 3 + docs/contracts/contextspec_v1.md | 26 ++ docs/contracts/primitive_contract.md | 28 ++ docs/integration_checklist.md | 14 + docs/policy_rollout.md | 40 +++ docs/project_defaults.md | 2 + docs/uat_functional_plan.md | 1 + go.mod | 2 +- go.sum | 4 +- .../v1/gate/approved_script_entry.schema.json | 50 +++ schemas/v1/gate/intent_request.schema.json | 69 ++++ schemas/v1/gate/policy.schema.json | 13 + schemas/v1/gate/trace_record.schema.json | 24 ++ scripts/test_contracts.sh | 16 + scripts/test_script_intent_acceptance.sh | 294 ++++++++++++++++ sdk/python/gait/__init__.py | 4 + sdk/python/gait/client.py | 3 + sdk/python/gait/models.py | 80 +++++ sdk/python/tests/test_client.py | 22 ++ sdk/python/tests/test_primitive_fixtures.py | 69 +++- 59 files changed, 3769 insertions(+), 88 deletions(-) create mode 100644 cmd/gait/approve_script.go create mode 100644 cmd/gait/approved_script_test.go create mode 100644 cmd/gait/list_scripts.go create mode 100644 core/gate/approved_scripts.go create mode 100644 core/gate/approved_scripts_test.go create mode 100644 core/gate/context_wrkr.go create mode 100644 core/gate/context_wrkr_test.go create mode 100644 schemas/v1/gate/approved_script_entry.schema.json create mode 100755 scripts/test_script_intent_acceptance.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b290b..bb13410 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,21 @@ jobs: run: | make test-v1-8-acceptance + script-intent-acceptance: + needs: changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Script intent governance acceptance checks + run: | + make test-script-intent-acceptance + release-smoke: needs: changes runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 6834309..a01275e 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ BENCH_REGEX := Benchmark(EvaluatePolicyTypical|VerifyZipTypical|DiffRunpacksTypi BENCH_OUTPUT ?= perf/bench_output.txt BENCH_BASELINE ?= perf/bench_baseline.json -.PHONY: fmt lint lint-fast codeql test test-fast test-scenarios prepush prepush-full github-guardrails github-guardrails-strict test-hardening test-hardening-acceptance test-chaos test-e2e test-acceptance test-v1-6-acceptance test-v1-7-acceptance test-v1-8-acceptance test-v2-3-acceptance test-v2-4-acceptance test-v2-5-acceptance test-v2-6-acceptance test-voice-acceptance test-context-conformance test-context-chaos test-packspec-tck test-ui-acceptance test-ui-unit test-ui-e2e-smoke test-ui-perf test-adoption test-adapter-parity test-ecosystem-automation test-release-smoke test-install test-install-path-versions test-contracts test-intent-receipt-conformance test-ci-regress-template test-ci-portability-templates test-live-connectors test-skill-supply-chain test-runtime-slo test-ent-consumer-contract test-uat-local test-openclaw-skill-install test-beads-bridge test-docs-storyline test-docs-consistency test-demo-recording openclaw-skill-install build bench bench-check bench-budgets context-budgets skills-validate ecosystem-validate ecosystem-release-notes demo-90s demo-hero-gif homebrew-formula wiki-publish tool-allowlist-policy ui-build ui-sync ui-deps-check +.PHONY: fmt lint lint-fast codeql test test-fast test-scenarios prepush prepush-full github-guardrails github-guardrails-strict test-hardening test-hardening-acceptance test-chaos test-e2e test-acceptance test-v1-6-acceptance test-v1-7-acceptance test-v1-8-acceptance test-v2-3-acceptance test-v2-4-acceptance test-v2-5-acceptance test-v2-6-acceptance test-voice-acceptance test-context-conformance test-context-chaos test-packspec-tck test-script-intent-acceptance test-ui-acceptance test-ui-unit test-ui-e2e-smoke test-ui-perf test-adoption test-adapter-parity test-ecosystem-automation test-release-smoke test-install test-install-path-versions test-contracts test-intent-receipt-conformance test-ci-regress-template test-ci-portability-templates test-live-connectors test-skill-supply-chain test-runtime-slo test-ent-consumer-contract test-uat-local test-openclaw-skill-install test-beads-bridge test-docs-storyline test-docs-consistency test-demo-recording openclaw-skill-install build bench bench-check bench-budgets context-budgets skills-validate ecosystem-validate ecosystem-release-notes demo-90s demo-hero-gif homebrew-formula wiki-publish tool-allowlist-policy ui-build ui-sync ui-deps-check .PHONY: hooks .PHONY: docs-site-install docs-site-build docs-site-lint docs-site-check @@ -155,6 +155,10 @@ test-packspec-tck: $(GO) build -o ./gait ./cmd/gait bash scripts/test_packspec_tck.sh ./gait +test-script-intent-acceptance: + $(GO) build -o ./gait ./cmd/gait + bash scripts/test_script_intent_acceptance.sh ./gait + test-ui-acceptance: $(GO) build -o ./gait ./cmd/gait bash scripts/test_ui_acceptance.sh ./gait diff --git a/README.md b/README.md index eb6ec3f..dcfcae3 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ See: [2,880 tool calls gate-checked in 24 hours](docs/blog/openclaw_24h_boundary **Signed packs** — every run and job emits a tamper-evident artifact (Ed25519 + SHA-256 manifest). Verify offline. Attach to PRs, incidents, audits. One artifact is the entire proof. Export OTEL-style JSONL and deterministic PostgreSQL index SQL with `gait pack export`. -**Fail-closed policy enforcement** — `gait gate eval` evaluates a structured tool-call intent against YAML policy before the side effect runs. Non-allow means non-execute. Signed trace proves the decision. +**Fail-closed policy enforcement** — `gait gate eval` evaluates a structured tool-call intent against YAML policy before the side effect runs. Non-allow means non-execute. Signed trace proves the decision. Script mode supports deterministic step rollups, optional Wrkr context enrichment, and signed approved-script fast-path allow. **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. @@ -202,7 +202,9 @@ gait job approve|cancel|inspect Job 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 +gait approve-script Mint signed approved-script registry entries gait approve Mint signed approval tokens +gait list-scripts Inspect approved-script registry status gait delegate mint|verify Delegation token lifecycle gait report top Rank highest-risk actions gait voice token mint|verify Voice commitment gating diff --git a/cmd/gait/approve_script.go b/cmd/gait/approve_script.go new file mode 100644 index 0000000..7f89daf --- /dev/null +++ b/cmd/gait/approve_script.go @@ -0,0 +1,186 @@ +package main + +import ( + "flag" + "fmt" + "io" + "strings" + "time" + + "github.com/Clyra-AI/gait/core/gate" + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" + sign "github.com/Clyra-AI/proof/signing" +) + +type approveScriptOutput struct { + OK bool `json:"ok"` + PatternID string `json:"pattern_id,omitempty"` + PolicyDigest string `json:"policy_digest,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + ToolSequence []string `json:"tool_sequence,omitempty"` + RegistryPath string `json:"registry_path,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` +} + +func runApproveScript(arguments []string) int { + if hasExplainFlag(arguments) { + return writeExplain("Create a signed approved-script registry entry bound to a policy digest and script hash.") + } + flagSet := flag.NewFlagSet("approve-script", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + + var policyPath string + var intentPath string + var registryPath string + var patternID string + var approver string + var ttlRaw string + var scopeCSV string + var keyMode string + var privateKeyPath string + var privateKeyEnv string + var jsonOutput bool + var helpFlag bool + + flagSet.StringVar(&policyPath, "policy", "", "path to policy yaml") + flagSet.StringVar(&intentPath, "intent", "", "path to script intent json") + flagSet.StringVar(®istryPath, "registry", "", "path to approved script registry json") + flagSet.StringVar(&patternID, "pattern-id", "", "approved script pattern id") + flagSet.StringVar(&approver, "approver", "", "approver identity") + flagSet.StringVar(&ttlRaw, "ttl", "168h", "entry validity duration") + flagSet.StringVar(&scopeCSV, "scope", "", "comma-separated scope values") + flagSet.StringVar(&keyMode, "key-mode", "dev", "signing key mode: dev or prod") + flagSet.StringVar(&privateKeyPath, "private-key", "", "path to base64 private signing key") + flagSet.StringVar(&privateKeyEnv, "private-key-env", "", "env var containing base64 private signing key") + flagSet.BoolVar(&jsonOutput, "json", false, "emit JSON output") + flagSet.BoolVar(&helpFlag, "help", false, "show help") + + if err := flagSet.Parse(arguments); err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + if helpFlag { + printApproveScriptUsage() + return exitOK + } + if len(flagSet.Args()) > 0 { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: "unexpected positional arguments"}, exitInvalidInput) + } + if strings.TrimSpace(policyPath) == "" || strings.TrimSpace(intentPath) == "" || strings.TrimSpace(registryPath) == "" || strings.TrimSpace(approver) == "" { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{ + OK: false, + Error: "--policy, --intent, --registry, and --approver are required", + }, exitInvalidInput) + } + + ttl, err := time.ParseDuration(strings.TrimSpace(ttlRaw)) + if err != nil || ttl <= 0 { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: "invalid --ttl duration"}, exitInvalidInput) + } + policy, err := gate.LoadPolicyFile(policyPath) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + policyDigest, err := gate.PolicyDigest(policy) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + intent, err := readIntentRequest(intentPath) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + normalizedIntent, err := gate.NormalizeIntent(intent) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + if normalizedIntent.Script == nil || len(normalizedIntent.Script.Steps) == 0 { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: "intent must include script.steps"}, exitInvalidInput) + } + scriptHash, err := gate.ScriptHash(normalizedIntent) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + toolSequence := make([]string, 0, len(normalizedIntent.Script.Steps)) + for _, step := range normalizedIntent.Script.Steps { + toolSequence = append(toolSequence, step.ToolName) + } + + if strings.TrimSpace(patternID) == "" { + patternID = "pattern_" + scriptHash[:12] + } + nowUTC := time.Now().UTC() + keyPair, _, err := sign.LoadSigningKey(sign.KeyConfig{ + Mode: sign.KeyMode(strings.ToLower(strings.TrimSpace(keyMode))), + PrivateKeyPath: privateKeyPath, + PrivateKeyEnv: privateKeyEnv, + }) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + + entry, err := gate.SignApprovedScriptEntry(schemagate.ApprovedScriptEntry{ + SchemaID: "gait.gate.approved_script_entry", + SchemaVersion: "1.0.0", + CreatedAt: nowUTC, + ProducerVersion: version, + PatternID: strings.TrimSpace(patternID), + PolicyDigest: policyDigest, + ScriptHash: scriptHash, + ToolSequence: toolSequence, + Scope: parseCSV(scopeCSV), + ApproverIdentity: strings.TrimSpace(approver), + ExpiresAt: nowUTC.Add(ttl), + }, keyPair.Private) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + + existing, err := gate.ReadApprovedScriptRegistry(registryPath) + if err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + updated := make([]schemagate.ApprovedScriptEntry, 0, len(existing)+1) + replaced := false + for _, candidate := range existing { + if candidate.PatternID == entry.PatternID { + updated = append(updated, entry) + replaced = true + continue + } + updated = append(updated, candidate) + } + if !replaced { + updated = append(updated, entry) + } + if err := gate.WriteApprovedScriptRegistry(registryPath, updated); err != nil { + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + + return writeApproveScriptOutput(jsonOutput, approveScriptOutput{ + OK: true, + PatternID: entry.PatternID, + PolicyDigest: entry.PolicyDigest, + ScriptHash: entry.ScriptHash, + ToolSequence: entry.ToolSequence, + RegistryPath: strings.TrimSpace(registryPath), + ExpiresAt: entry.ExpiresAt.UTC().Format(time.RFC3339Nano), + }, exitOK) +} + +func writeApproveScriptOutput(jsonOutput bool, output approveScriptOutput, exitCode int) int { + if jsonOutput { + return writeJSONOutput(output, exitCode) + } + if !output.OK { + fmt.Printf("approve-script error: %s\n", output.Error) + return exitCode + } + fmt.Printf("approve-script: pattern=%s registry=%s\n", output.PatternID, output.RegistryPath) + fmt.Printf("policy_digest=%s script_hash=%s\n", output.PolicyDigest, output.ScriptHash) + return exitCode +} + +func printApproveScriptUsage() { + fmt.Println("Usage:") + fmt.Println(" gait approve-script --policy --intent --registry --approver [--pattern-id ] [--ttl 168h] [--scope ] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") +} diff --git a/cmd/gait/approved_script_test.go b/cmd/gait/approved_script_test.go new file mode 100644 index 0000000..555e1b8 --- /dev/null +++ b/cmd/gait/approved_script_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" +) + +func TestApproveScriptAndListScripts(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy.yaml") + intentPath := filepath.Join(workDir, "script_intent.json") + registryPath := filepath.Join(workDir, "approved_scripts.json") + privateKeyPath := filepath.Join(workDir, "approved_script_private.key") + + mustWriteFile(t, policyPath, "default_verdict: allow\n") + writePrivateKey(t, privateKeyPath) + mustWriteScriptIntentFixture(t, intentPath) + + rawApprove := captureStdout(t, func() { + if code := runApproveScript([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--registry", registryPath, + "--approver", "secops", + "--key-mode", "prod", + "--private-key", privateKeyPath, + "--json", + }); code != exitOK { + t.Fatalf("runApproveScript expected %d got %d", exitOK, code) + } + }) + var approveOut approveScriptOutput + if err := json.Unmarshal([]byte(rawApprove), &approveOut); err != nil { + t.Fatalf("decode approve-script output: %v raw=%q", err, rawApprove) + } + if !approveOut.OK || approveOut.PatternID == "" || approveOut.ScriptHash == "" { + t.Fatalf("unexpected approve-script output: %#v", approveOut) + } + + rawList := captureStdout(t, func() { + if code := runListScripts([]string{ + "--registry", registryPath, + "--json", + }); code != exitOK { + t.Fatalf("runListScripts expected %d got %d", exitOK, code) + } + }) + var listOut listScriptsOutput + if err := json.Unmarshal([]byte(rawList), &listOut); err != nil { + t.Fatalf("decode list-scripts output: %v raw=%q", err, rawList) + } + if !listOut.OK || listOut.Count != 1 { + t.Fatalf("unexpected list-scripts output: %#v", listOut) + } +} + +func TestGateEvalApprovedScriptFastPath(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy_require_approval.yaml") + intentPath := filepath.Join(workDir, "script_intent.json") + registryPath := filepath.Join(workDir, "approved_scripts.json") + privateKeyPath := filepath.Join(workDir, "approved_script_private.key") + tracePath := filepath.Join(workDir, "trace.json") + + mustWriteFile(t, policyPath, ` +default_verdict: allow +rules: + - name: require-approval-write + effect: require_approval + match: + tool_names: [tool.write] +`) + mustWriteScriptIntentFixture(t, intentPath) + writePrivateKey(t, privateKeyPath) + + if code := runGateEval([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--trace-out", tracePath, + "--json", + }); code != exitApprovalRequired { + t.Fatalf("runGateEval without registry expected %d got %d", exitApprovalRequired, code) + } + + if code := runApproveScript([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--registry", registryPath, + "--approver", "secops", + "--key-mode", "prod", + "--private-key", privateKeyPath, + "--json", + }); code != exitOK { + t.Fatalf("runApproveScript expected %d got %d", exitOK, code) + } + + rawEval := captureStdout(t, func() { + if code := runGateEval([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--approved-script-registry", registryPath, + "--trace-out", tracePath, + "--json", + }); code != exitOK { + t.Fatalf("runGateEval with registry expected %d got %d", exitOK, code) + } + }) + var evalOut gateEvalOutput + if err := json.Unmarshal([]byte(rawEval), &evalOut); err != nil { + t.Fatalf("decode gate eval output: %v raw=%q", err, rawEval) + } + if evalOut.Verdict != "allow" || !evalOut.PreApproved || evalOut.PatternID == "" { + t.Fatalf("expected pre-approved allow output, got %#v", evalOut) + } +} + +func TestGateEvalApprovedScriptBypassesBlockingRule(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy_block.yaml") + intentPath := filepath.Join(workDir, "script_intent.json") + registryPath := filepath.Join(workDir, "approved_scripts.json") + privateKeyPath := filepath.Join(workDir, "approved_script_private.key") + + mustWriteFile(t, policyPath, ` +default_verdict: allow +rules: + - name: block-write + effect: block + reason_codes: [blocked_by_policy] + match: + tool_names: [tool.write] +`) + mustWriteScriptIntentFixture(t, intentPath) + writePrivateKey(t, privateKeyPath) + + if code := runGateEval([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--json", + }); code != exitPolicyBlocked { + t.Fatalf("runGateEval without registry expected %d got %d", exitPolicyBlocked, code) + } + + if code := runApproveScript([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--registry", registryPath, + "--approver", "secops", + "--key-mode", "prod", + "--private-key", privateKeyPath, + "--json", + }); code != exitOK { + t.Fatalf("runApproveScript expected %d got %d", exitOK, code) + } + + rawEval := captureStdout(t, func() { + if code := runGateEval([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--approved-script-registry", registryPath, + "--json", + }); code != exitOK { + t.Fatalf("runGateEval with registry expected %d got %d", exitOK, code) + } + }) + var evalOut gateEvalOutput + if err := json.Unmarshal([]byte(rawEval), &evalOut); err != nil { + t.Fatalf("decode gate eval output: %v raw=%q", err, rawEval) + } + if evalOut.Verdict != "allow" || !evalOut.PreApproved { + t.Fatalf("expected pre-approved allow output, got %#v", evalOut) + } + if len(evalOut.ReasonCodes) != 1 || evalOut.ReasonCodes[0] != "approved_script_match" { + t.Fatalf("expected fast-path reason only, got %#v", evalOut.ReasonCodes) + } +} + +func TestRunApproveScriptValidation(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy.yaml") + intentPath := filepath.Join(workDir, "script_intent.json") + registryPath := filepath.Join(workDir, "approved_scripts.json") + + mustWriteFile(t, policyPath, "default_verdict: allow\n") + mustWriteScriptIntentFixture(t, intentPath) + + rawMissingRequired := captureStdout(t, func() { + if code := runApproveScript([]string{"--json"}); code != exitInvalidInput { + t.Fatalf("runApproveScript missing required flags expected %d got %d", exitInvalidInput, code) + } + }) + var missingRequiredOut approveScriptOutput + if err := json.Unmarshal([]byte(rawMissingRequired), &missingRequiredOut); err != nil { + t.Fatalf("decode missing-required output: %v raw=%q", err, rawMissingRequired) + } + if missingRequiredOut.OK || !strings.Contains(missingRequiredOut.Error, "are required") { + t.Fatalf("unexpected missing-required output: %#v", missingRequiredOut) + } + + rawInvalidTTL := captureStdout(t, func() { + if code := runApproveScript([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--registry", registryPath, + "--approver", "secops", + "--ttl", "not-a-duration", + "--json", + }); code != exitInvalidInput { + t.Fatalf("runApproveScript invalid ttl expected %d got %d", exitInvalidInput, code) + } + }) + var invalidTTLOut approveScriptOutput + if err := json.Unmarshal([]byte(rawInvalidTTL), &invalidTTLOut); err != nil { + t.Fatalf("decode invalid ttl output: %v raw=%q", err, rawInvalidTTL) + } + if invalidTTLOut.OK || invalidTTLOut.Error != "invalid --ttl duration" { + t.Fatalf("unexpected invalid ttl output: %#v", invalidTTLOut) + } +} + +func TestRunApproveScriptRejectsIntentWithoutScript(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + policyPath := filepath.Join(workDir, "policy.yaml") + intentPath := filepath.Join(workDir, "intent_no_script.json") + registryPath := filepath.Join(workDir, "approved_scripts.json") + + mustWriteFile(t, policyPath, "default_verdict: allow\n") + writeIntentFixture(t, intentPath, "tool.write") + + raw := captureStdout(t, func() { + if code := runApproveScript([]string{ + "--policy", policyPath, + "--intent", intentPath, + "--registry", registryPath, + "--approver", "secops", + "--json", + }); code != exitInvalidInput { + t.Fatalf("runApproveScript intent without script expected %d got %d", exitInvalidInput, code) + } + }) + var out approveScriptOutput + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("decode approve-script output: %v raw=%q", err, raw) + } + if out.OK || out.Error != "intent must include script.steps" { + t.Fatalf("unexpected approve-script output: %#v", out) + } +} + +func TestRunApproveAndListScriptsHelpAndValidation(t *testing.T) { + approveHelp := captureStdout(t, func() { + if code := runApproveScript([]string{"--help"}); code != exitOK { + t.Fatalf("runApproveScript help expected %d got %d", exitOK, code) + } + }) + if !strings.Contains(approveHelp, "gait approve-script") { + t.Fatalf("approve-script help missing usage: %q", approveHelp) + } + + listHelp := captureStdout(t, func() { + if code := runListScripts([]string{"--help"}); code != exitOK { + t.Fatalf("runListScripts help expected %d got %d", exitOK, code) + } + }) + if !strings.Contains(listHelp, "gait list-scripts") { + t.Fatalf("list-scripts help missing usage: %q", listHelp) + } + + rawMissingRegistry := captureStdout(t, func() { + if code := runListScripts([]string{"--json"}); code != exitInvalidInput { + t.Fatalf("runListScripts missing registry expected %d got %d", exitInvalidInput, code) + } + }) + var missingRegistryOut listScriptsOutput + if err := json.Unmarshal([]byte(rawMissingRegistry), &missingRegistryOut); err != nil { + t.Fatalf("decode list-scripts missing-registry output: %v raw=%q", err, rawMissingRegistry) + } + if missingRegistryOut.OK || missingRegistryOut.Error != "--registry is required" { + t.Fatalf("unexpected missing-registry output: %#v", missingRegistryOut) + } +} + +func mustWriteScriptIntentFixture(t *testing.T, path string) { + t.Helper() + intent := schemagate.IntentRequest{ + SchemaID: "gait.gate.intent_request", + SchemaVersion: "1.0.0", + CreatedAt: time.Date(2026, time.February, 5, 0, 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", + }, + Script: &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + { + ToolName: "tool.write", + Args: map[string]any{"path": "/tmp/out.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/out.txt", Operation: "write"}, + }, + }, + }, + }, + } + raw, err := json.MarshalIndent(intent, "", " ") + if err != nil { + t.Fatalf("marshal script intent: %v", err) + } + if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { + t.Fatalf("write script intent fixture: %v", err) + } +} diff --git a/cmd/gait/gate.go b/cmd/gait/gate.go index 656a33d..17b471d 100644 --- a/cmd/gait/gate.go +++ b/cmd/gait/gate.go @@ -20,40 +20,49 @@ import ( ) type gateEvalOutput struct { - OK bool `json:"ok"` - Profile string `json:"profile,omitempty"` - Verdict string `json:"verdict,omitempty"` - ReasonCodes []string `json:"reason_codes,omitempty"` - Violations []string `json:"violations,omitempty"` - ApprovalRef string `json:"approval_ref,omitempty"` - RequiredApprovals int `json:"required_approvals,omitempty"` - ValidApprovals int `json:"valid_approvals,omitempty"` - ApprovalAuditPath string `json:"approval_audit_path,omitempty"` - DelegationRef string `json:"delegation_ref,omitempty"` - DelegationRequired bool `json:"delegation_required,omitempty"` - ValidDelegations int `json:"valid_delegations,omitempty"` - DelegationAuditPath string `json:"delegation_audit_path,omitempty"` - TraceID string `json:"trace_id,omitempty"` - TracePath string `json:"trace_path,omitempty"` - PolicyDigest string `json:"policy_digest,omitempty"` - IntentDigest string `json:"intent_digest,omitempty"` - ContextSetDigest string `json:"context_set_digest,omitempty"` - ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` - ContextRefCount int `json:"context_ref_count,omitempty"` - MatchedRule string `json:"matched_rule,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"` - CredentialIssuer string `json:"credential_issuer,omitempty"` - CredentialRef string `json:"credential_ref,omitempty"` - CredentialEvidencePath string `json:"credential_evidence_path,omitempty"` - SimulateMode bool `json:"simulate_mode,omitempty"` - WouldHaveBlocked bool `json:"would_have_blocked,omitempty"` - SimulatedVerdict string `json:"simulated_verdict,omitempty"` - SimulatedReasonCodes []string `json:"simulated_reason_codes,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Error string `json:"error,omitempty"` + OK bool `json:"ok"` + Profile string `json:"profile,omitempty"` + Verdict string `json:"verdict,omitempty"` + ReasonCodes []string `json:"reason_codes,omitempty"` + Violations []string `json:"violations,omitempty"` + ApprovalRef string `json:"approval_ref,omitempty"` + RequiredApprovals int `json:"required_approvals,omitempty"` + ValidApprovals int `json:"valid_approvals,omitempty"` + ApprovalAuditPath string `json:"approval_audit_path,omitempty"` + DelegationRef string `json:"delegation_ref,omitempty"` + DelegationRequired bool `json:"delegation_required,omitempty"` + ValidDelegations int `json:"valid_delegations,omitempty"` + DelegationAuditPath string `json:"delegation_audit_path,omitempty"` + TraceID string `json:"trace_id,omitempty"` + TracePath string `json:"trace_path,omitempty"` + PolicyDigest string `json:"policy_digest,omitempty"` + IntentDigest string `json:"intent_digest,omitempty"` + ContextSetDigest string `json:"context_set_digest,omitempty"` + ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` + ContextRefCount int `json:"context_ref_count,omitempty"` + ContextSource string `json:"context_source,omitempty"` + Script bool `json:"script,omitempty"` + StepCount int `json:"step_count,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + CompositeRiskClass string `json:"composite_risk_class,omitempty"` + StepVerdicts []schemagate.TraceStepVerdict `json:"step_verdicts,omitempty"` + PreApproved bool `json:"pre_approved,omitempty"` + PatternID string `json:"pattern_id,omitempty"` + RegistryReason string `json:"registry_reason,omitempty"` + MatchedRule string `json:"matched_rule,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"` + CredentialIssuer string `json:"credential_issuer,omitempty"` + CredentialRef string `json:"credential_ref,omitempty"` + CredentialEvidencePath string `json:"credential_evidence_path,omitempty"` + SimulateMode bool `json:"simulate_mode,omitempty"` + WouldHaveBlocked bool `json:"would_have_blocked,omitempty"` + SimulatedVerdict string `json:"simulated_verdict,omitempty"` + SimulatedReasonCodes []string `json:"simulated_reason_codes,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` } type gateEvalProfile string @@ -118,6 +127,10 @@ func runGateEval(arguments []string) int { var credentialCommand string var credentialCommandArgsCSV string var credentialEvidencePath string + var wrkrInventoryPath string + var approvedScriptRegistryPath string + var approvedScriptPublicKeyPath string + var approvedScriptPublicKeyEnv string var configPath string var disableConfig bool var simulate bool @@ -154,6 +167,10 @@ func runGateEval(arguments []string) int { flagSet.StringVar(&credentialCommand, "credential-command", "", "command to execute when --credential-broker=command") flagSet.StringVar(&credentialCommandArgsCSV, "credential-command-args", "", "comma-separated args for --credential-command") flagSet.StringVar(&credentialEvidencePath, "credential-evidence-out", "", "path to emitted broker credential evidence JSON") + flagSet.StringVar(&wrkrInventoryPath, "wrkr-inventory", "", "path to local Wrkr inventory JSON") + flagSet.StringVar(&approvedScriptRegistryPath, "approved-script-registry", "", "path to approved script registry JSON") + flagSet.StringVar(&approvedScriptPublicKeyPath, "approved-script-public-key", "", "path to base64 approved-script verify key") + flagSet.StringVar(&approvedScriptPublicKeyEnv, "approved-script-public-key-env", "", "env var containing base64 approved-script verify key") flagSet.StringVar(&configPath, "config", projectconfig.DefaultPath, "path to project defaults yaml") flagSet.BoolVar(&disableConfig, "no-config", false, "disable project defaults file lookup") flagSet.BoolVar(&simulate, "simulate", false, "non-enforcing simulation mode; report what would have been blocked") @@ -176,7 +193,7 @@ func runGateEval(arguments []string) int { if err != nil { return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: err.Error()}, exitInvalidInput) } - applyGateConfigDefaults(configuration.Gate, &policyPath, &profile, &keyMode, &privateKeyPath, &privateKeyEnv, &approvalPublicKeyPath, &approvalPublicKeyEnv, &approvalPrivateKeyPath, &approvalPrivateKeyEnv, &rateLimitState, &credentialBroker, &credentialEnvPrefix, &credentialRef, &credentialScopesCSV, &credentialCommand, &credentialCommandArgsCSV, &credentialEvidencePath, &tracePath) + applyGateConfigDefaults(configuration.Gate, &policyPath, &profile, &keyMode, &privateKeyPath, &privateKeyEnv, &approvalPublicKeyPath, &approvalPublicKeyEnv, &approvalPrivateKeyPath, &approvalPrivateKeyEnv, &rateLimitState, &credentialBroker, &credentialEnvPrefix, &credentialRef, &credentialScopesCSV, &credentialCommand, &credentialCommandArgsCSV, &credentialEvidencePath, &tracePath, &wrkrInventoryPath) } if profile == "" { profile = string(gateProfileStandard) @@ -222,6 +239,25 @@ func runGateEval(arguments []string) int { if err != nil { return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) } + startupWarnings := []string{} + wrkrInventory := map[string]gate.WrkrToolMetadata{} + wrkrSource := "" + if strings.TrimSpace(wrkrInventoryPath) != "" { + inventory, loadErr := gate.LoadWrkrInventory(wrkrInventoryPath) + if loadErr != nil { + riskClass := strings.ToLower(strings.TrimSpace(intent.Context.RiskClass)) + if resolvedProfile == gateProfileOSSProd || riskClass == "high" || riskClass == "critical" { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{ + OK: false, + Error: "wrkr inventory unavailable in fail-closed mode: " + loadErr.Error(), + }, exitPolicyBlocked) + } + startupWarnings = append(startupWarnings, "wrkr inventory unavailable; continuing without context enrichment") + } else { + wrkrInventory = inventory.Tools + wrkrSource = inventory.Path + } + } resolvedBroker, err := credential.ResolveBroker( credentialBroker, credentialEnvPrefix, @@ -246,10 +282,105 @@ func runGateEval(arguments []string) int { } } + approvedRegistryConfigured := strings.TrimSpace(approvedScriptRegistryPath) != "" + approvedRegistryEntries := []schemagate.ApprovedScriptEntry{} + if approvedRegistryConfigured { + entries, readErr := gate.ReadApprovedScriptRegistry(approvedScriptRegistryPath) + if readErr != nil { + riskClass := strings.ToLower(strings.TrimSpace(intent.Context.RiskClass)) + if resolvedProfile == gateProfileOSSProd || riskClass == "high" || riskClass == "critical" { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{ + OK: false, + Error: "approved script registry unavailable in fail-closed mode: " + readErr.Error(), + }, exitPolicyBlocked) + } + startupWarnings = append(startupWarnings, "approved script registry unavailable; continuing without fast-path pre-approval") + } else { + approvedVerifyConfig := sign.KeyConfig{ + PublicKeyPath: approvedScriptPublicKeyPath, + PublicKeyEnv: approvedScriptPublicKeyEnv, + } + if !hasAnyKeySource(approvedVerifyConfig) { + approvedVerifyConfig = sign.KeyConfig{ + PublicKeyPath: approvalPublicKeyPath, + PublicKeyEnv: approvalPublicKeyEnv, + PrivateKeyPath: approvalPrivateKeyPath, + PrivateKeyEnv: approvalPrivateKeyEnv, + } + } + if resolvedProfile == gateProfileOSSProd && !hasAnyKeySource(approvedVerifyConfig) { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{ + OK: false, + Error: "oss-prod profile requires approved-script verify key when --approved-script-registry is set", + }, exitInvalidInput) + } + if hasAnyKeySource(approvedVerifyConfig) { + verifyKey, verifyErr := sign.LoadVerifyKey(approvedVerifyConfig) + if verifyErr != nil { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: verifyErr.Error()}, exitCodeForError(verifyErr, exitInvalidInput)) + } + nowUTC := time.Now().UTC() + for index, entry := range entries { + if verifyErr := gate.VerifyApprovedScriptEntry(entry, verifyKey, nowUTC); verifyErr != nil { + riskClass := strings.ToLower(strings.TrimSpace(intent.Context.RiskClass)) + if resolvedProfile == gateProfileOSSProd || riskClass == "high" || riskClass == "critical" { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{ + OK: false, + Error: fmt.Sprintf("approved script registry verification failed at entry %d: %v", index, verifyErr), + }, exitPolicyBlocked) + } + startupWarnings = append(startupWarnings, "approved script registry entry failed verification; fast-path disabled") + entries = []schemagate.ApprovedScriptEntry{} + break + } + } + } + approvedRegistryEntries = entries + } + } + evalStart := time.Now() - outcome, err := gate.EvaluatePolicyDetailed(policy, intent, gate.EvalOptions{ProducerVersion: version}) - if err != nil { - return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + outcome := gate.EvalOutcome{} + registryReason := "" + preApprovedFastPath := false + if approvedRegistryConfigured && len(approvedRegistryEntries) > 0 { + policyDigestForRegistry, digestErr := gate.PolicyDigest(policy) + if digestErr != nil { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: digestErr.Error()}, exitCodeForError(digestErr, exitInvalidInput)) + } + match, matchErr := gate.MatchApprovedScript(intent, policyDigestForRegistry, approvedRegistryEntries, time.Now().UTC()) + if matchErr != nil { + riskClass := strings.ToLower(strings.TrimSpace(intent.Context.RiskClass)) + if resolvedProfile == gateProfileOSSProd || riskClass == "high" || riskClass == "critical" { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{ + OK: false, + Error: "approved script fast-path evaluation failed in fail-closed mode: " + matchErr.Error(), + }, exitPolicyBlocked) + } + startupWarnings = append(startupWarnings, "approved script fast-path evaluation failed; continuing with policy evaluation") + } else if match.Matched { + preApprovedOutcome, preApproveErr := buildPreApprovedOutcome(intent, version, match) + if preApproveErr != nil { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: preApproveErr.Error()}, exitCodeForError(preApproveErr, exitInvalidInput)) + } + outcome = preApprovedOutcome + preApprovedFastPath = true + } else { + registryReason = match.Reason + } + } + if !preApprovedFastPath { + outcome, err = gate.EvaluatePolicyDetailed(policy, intent, gate.EvalOptions{ + ProducerVersion: version, + WrkrInventory: wrkrInventory, + WrkrSource: wrkrSource, + }) + if err != nil { + return writeGateEvalOutput(jsonOutput, gateEvalOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + if registryReason != "" { + outcome.RegistryReason = registryReason + } } result := outcome.Result evalLatencyMS := time.Since(evalStart).Seconds() * 1000 @@ -275,7 +406,7 @@ func runGateEval(arguments []string) int { } } - keyPair, warnings, err := sign.LoadSigningKey(sign.KeyConfig{ + keyPair, signingWarnings, err := sign.LoadSigningKey(sign.KeyConfig{ Mode: sign.KeyMode(keyMode), PrivateKeyPath: privateKeyPath, PrivateKeyEnv: privateKeyEnv, @@ -560,6 +691,12 @@ func runGateEval(arguments []string) int { DelegationTokenRef: resolvedDelegationRef, DelegationReasonCodes: mergeUniqueSorted(nil, filterReasonsByPrefix(result.ReasonCodes, "delegation_")), LatencyMS: evalLatencyMS, + ContextSource: outcome.ContextSource, + CompositeRiskClass: outcome.CompositeRiskClass, + StepVerdicts: outcome.StepVerdicts, + PreApproved: outcome.PreApproved, + PatternID: outcome.PatternID, + RegistryReason: outcome.RegistryReason, SigningPrivateKey: keyPair.Private, TracePath: tracePath, }) @@ -656,6 +793,15 @@ func runGateEval(arguments []string) int { ContextSetDigest: intent.Context.ContextSetDigest, ContextEvidenceMode: intent.Context.ContextEvidenceMode, ContextRefCount: len(intent.Context.ContextRefs), + ContextSource: outcome.ContextSource, + Script: outcome.Script, + StepCount: outcome.StepCount, + ScriptHash: outcome.ScriptHash, + CompositeRiskClass: outcome.CompositeRiskClass, + StepVerdicts: outcome.StepVerdicts, + PreApproved: outcome.PreApproved, + PatternID: outcome.PatternID, + RegistryReason: outcome.RegistryReason, MatchedRule: outcome.MatchedRule, RateLimitScope: rateDecision.Scope, RateLimitKey: rateDecision.Key, @@ -668,7 +814,7 @@ func runGateEval(arguments []string) int { WouldHaveBlocked: wouldHaveBlocked, SimulatedVerdict: simulatedVerdict, SimulatedReasonCodes: simulatedReasonCodes, - Warnings: warnings, + Warnings: mergeUniqueSorted(startupWarnings, signingWarnings), }, exitCode) } @@ -690,6 +836,83 @@ func gatherDelegationTokenPaths(primaryPath, chainCSV string) []string { return mergeUniqueSorted(nil, paths) } +func buildPreApprovedOutcome(intent schemagate.IntentRequest, producerVersion string, match gate.ApprovedScriptMatch) (gate.EvalOutcome, error) { + normalizedIntent, err := gate.NormalizeIntent(intent) + if err != nil { + return gate.EvalOutcome{}, fmt.Errorf("normalize intent for approved-script fast-path: %w", err) + } + nowUTC := time.Now().UTC() + outcome := gate.EvalOutcome{ + Result: schemagate.GateResult{ + SchemaID: "gait.gate.result", + SchemaVersion: "1.0.0", + CreatedAt: nowUTC, + ProducerVersion: producerVersion, + Verdict: "allow", + ReasonCodes: []string{match.Reason}, + Violations: []string{}, + }, + PreApproved: true, + PatternID: match.PatternID, + RegistryReason: match.Reason, + } + if normalizedIntent.Script == nil { + return outcome, nil + } + outcome.Script = true + outcome.StepCount = len(normalizedIntent.Script.Steps) + outcome.ScriptHash = normalizedIntent.ScriptHash + stepVerdicts := make([]schemagate.TraceStepVerdict, 0, len(normalizedIntent.Script.Steps)) + riskClasses := make([]string, 0, len(normalizedIntent.Script.Steps)) + for index, step := range normalizedIntent.Script.Steps { + stepVerdicts = append(stepVerdicts, schemagate.TraceStepVerdict{ + Index: index, + ToolName: step.ToolName, + Verdict: "allow", + ReasonCodes: []string{match.Reason}, + Violations: []string{}, + }) + riskClasses = append(riskClasses, classifyPreApprovedStepRisk(step.Targets)) + } + outcome.StepVerdicts = stepVerdicts + outcome.CompositeRiskClass = compositePreApprovedRiskClass(riskClasses) + return outcome, nil +} + +func classifyPreApprovedStepRisk(targets []schemagate.IntentTarget) string { + risk := "low" + for _, target := range targets { + switch target.EndpointClass { + case "fs.delete", "proc.exec": + return "high" + case "fs.write", "net.http", "net.dns": + if risk == "low" { + risk = "medium" + } + } + if target.Destructive { + return "high" + } + } + return risk +} + +func compositePreApprovedRiskClass(riskClasses []string) string { + hasMedium := false + for _, riskClass := range riskClasses { + switch riskClass { + case "high": + return "high" + case "medium": + hasMedium = true + } + } + if hasMedium { + return "medium" + } + return "low" +} + func gateEvalExitCodeForVerdict(verdict string, current int) int { switch strings.ToLower(strings.TrimSpace(verdict)) { case "block": @@ -726,6 +949,7 @@ func applyGateConfigDefaults( credentialCommandArgsCSV *string, credentialEvidencePath *string, tracePath *string, + wrkrInventoryPath *string, ) { if strings.TrimSpace(*policyPath) == "" { *policyPath = defaults.Policy @@ -781,6 +1005,9 @@ func applyGateConfigDefaults( if strings.TrimSpace(*tracePath) == "" { *tracePath = defaults.TracePath } + if strings.TrimSpace(*wrkrInventoryPath) == "" { + *wrkrInventoryPath = defaults.WrkrInventoryPath + } } func readIntentRequest(path string) (schemagate.IntentRequest, error) { @@ -842,7 +1069,7 @@ func writeGateEvalOutput(jsonOutput bool, output gateEvalOutput, exitCode int) i func printGateUsage() { fmt.Println("Usage:") - fmt.Println(" gait gate eval --policy --intent [--config .gait/config.yaml] [--no-config] [--profile standard|oss-prod] [--simulate] [--approval-token ] [--approval-token-chain ] [--delegation-token ] [--delegation-token-chain ] [--approval-audit-out audit.json] [--delegation-audit-out audit.json] [--credential-broker off|stub|env|command] [--credential-command ] [--trace-out trace.json] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") + fmt.Println(" gait gate eval --policy --intent [--config .gait/config.yaml] [--no-config] [--profile standard|oss-prod] [--simulate] [--approval-token ] [--approval-token-chain ] [--delegation-token ] [--delegation-token-chain ] [--approval-audit-out audit.json] [--delegation-audit-out audit.json] [--credential-broker off|stub|env|command] [--credential-command ] [--wrkr-inventory ] [--approved-script-registry ] [--approved-script-public-key |--approved-script-public-key-env ] [--trace-out trace.json] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") fmt.Println("Rollout path:") fmt.Println(" observe: gait gate eval ... --simulate --json") fmt.Println(" enforce: gait gate eval ... --json") @@ -850,7 +1077,7 @@ func printGateUsage() { func printGateEvalUsage() { fmt.Println("Usage:") - fmt.Println(" gait gate eval --policy --intent [--config .gait/config.yaml] [--no-config] [--profile standard|oss-prod] [--simulate] [--approval-token ] [--approval-token-chain ] [--delegation-token ] [--delegation-token-chain ] [--approval-token-ref token] [--approval-public-key |--approval-public-key-env ] [--delegation-public-key |--delegation-public-key-env ] [--approval-audit-out audit.json] [--delegation-audit-out audit.json] [--rate-limit-state state.json] [--credential-broker off|stub|env|command] [--credential-env-prefix GAIT_BROKER_TOKEN_] [--credential-command ] [--credential-command-args csv] [--credential-ref ref] [--credential-scopes csv] [--credential-evidence-out path] [--trace-out trace.json] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") + fmt.Println(" gait gate eval --policy --intent [--config .gait/config.yaml] [--no-config] [--profile standard|oss-prod] [--simulate] [--approval-token ] [--approval-token-chain ] [--delegation-token ] [--delegation-token-chain ] [--approval-token-ref token] [--approval-public-key |--approval-public-key-env ] [--delegation-public-key |--delegation-public-key-env ] [--approval-audit-out audit.json] [--delegation-audit-out audit.json] [--rate-limit-state state.json] [--credential-broker off|stub|env|command] [--credential-env-prefix GAIT_BROKER_TOKEN_] [--credential-command ] [--credential-command-args csv] [--credential-ref ref] [--credential-scopes csv] [--credential-evidence-out path] [--wrkr-inventory ] [--approved-script-registry ] [--approved-script-public-key |--approved-script-public-key-env ] [--trace-out trace.json] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") fmt.Println(" observe first: add --simulate while tuning") fmt.Println(" enforce later: remove --simulate once fixtures are stable") } diff --git a/cmd/gait/list_scripts.go b/cmd/gait/list_scripts.go new file mode 100644 index 0000000..a2ab478 --- /dev/null +++ b/cmd/gait/list_scripts.go @@ -0,0 +1,106 @@ +package main + +import ( + "flag" + "fmt" + "io" + "strings" + "time" + + "github.com/Clyra-AI/gait/core/gate" +) + +type listScriptsEntry struct { + PatternID string `json:"pattern_id"` + ApproverIdentity string `json:"approver_identity"` + PolicyDigest string `json:"policy_digest"` + ScriptHash string `json:"script_hash"` + ToolSequence []string `json:"tool_sequence"` + Scope []string `json:"scope,omitempty"` + ExpiresAt string `json:"expires_at"` + Expired bool `json:"expired"` +} + +type listScriptsOutput struct { + OK bool `json:"ok"` + Registry string `json:"registry,omitempty"` + Count int `json:"count,omitempty"` + Entries []listScriptsEntry `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +func runListScripts(arguments []string) int { + if hasExplainFlag(arguments) { + return writeExplain("List approved-script registry entries with expiry status and deterministic ordering.") + } + flagSet := flag.NewFlagSet("list-scripts", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + var registryPath string + var jsonOutput bool + var helpFlag bool + flagSet.StringVar(®istryPath, "registry", "", "path to approved script registry json") + flagSet.BoolVar(&jsonOutput, "json", false, "emit JSON output") + flagSet.BoolVar(&helpFlag, "help", false, "show help") + if err := flagSet.Parse(arguments); err != nil { + return writeListScriptsOutput(jsonOutput, listScriptsOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + if helpFlag { + printListScriptsUsage() + return exitOK + } + if len(flagSet.Args()) > 0 { + return writeListScriptsOutput(jsonOutput, listScriptsOutput{OK: false, Error: "unexpected positional arguments"}, exitInvalidInput) + } + if strings.TrimSpace(registryPath) == "" { + return writeListScriptsOutput(jsonOutput, listScriptsOutput{OK: false, Error: "--registry is required"}, exitInvalidInput) + } + entries, err := gate.ReadApprovedScriptRegistry(registryPath) + if err != nil { + return writeListScriptsOutput(jsonOutput, listScriptsOutput{OK: false, Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) + } + nowUTC := time.Now().UTC() + outputEntries := make([]listScriptsEntry, 0, len(entries)) + for _, entry := range entries { + expiresAt := entry.ExpiresAt.UTC() + outputEntries = append(outputEntries, listScriptsEntry{ + PatternID: entry.PatternID, + ApproverIdentity: entry.ApproverIdentity, + PolicyDigest: entry.PolicyDigest, + ScriptHash: entry.ScriptHash, + ToolSequence: entry.ToolSequence, + Scope: entry.Scope, + ExpiresAt: expiresAt.Format(time.RFC3339Nano), + Expired: !expiresAt.After(nowUTC), + }) + } + return writeListScriptsOutput(jsonOutput, listScriptsOutput{ + OK: true, + Registry: strings.TrimSpace(registryPath), + Count: len(outputEntries), + Entries: outputEntries, + }, exitOK) +} + +func writeListScriptsOutput(jsonOutput bool, output listScriptsOutput, exitCode int) int { + if jsonOutput { + return writeJSONOutput(output, exitCode) + } + if !output.OK { + fmt.Printf("list-scripts error: %s\n", output.Error) + return exitCode + } + fmt.Printf("list-scripts: %d entries in %s\n", output.Count, output.Registry) + for _, entry := range output.Entries { + status := "active" + if entry.Expired { + status = "expired" + } + fmt.Printf("- %s (%s) approver=%s expires=%s\n", entry.PatternID, status, entry.ApproverIdentity, entry.ExpiresAt) + } + return exitCode +} + +func printListScriptsUsage() { + fmt.Println("Usage:") + fmt.Println(" gait list-scripts --registry [--json] [--explain]") +} diff --git a/cmd/gait/main.go b/cmd/gait/main.go index ccd6ae1..5f9e7b8 100644 --- a/cmd/gait/main.go +++ b/cmd/gait/main.go @@ -65,12 +65,16 @@ func runDispatch(arguments []string) int { switch arguments[1] { case "approve": return runApprove(arguments[2:]) + case "approve-script": + return runApproveScript(arguments[2:]) case "delegate": return runDelegate(arguments[2:]) case "demo": return runDemo(arguments[2:]) case "doctor": return runDoctor(arguments[2:]) + case "list-scripts": + return runListScripts(arguments[2:]) case "gate": return runGate(arguments[2:]) case "policy": @@ -134,7 +138,7 @@ func normalizeAdoptionCommand(arguments []string) string { return "version" case "--explain": return "explain" - case "gate", "policy", "keys", "trace", "regress", "run", "job", "pack", "report", "scout", "guard", "incident", "registry", "mcp", "voice", "doctor", "delegate", "ui": + case "approve-script", "list-scripts", "gate", "policy", "keys", "trace", "regress", "run", "job", "pack", "report", "scout", "guard", "incident", "registry", "mcp", "voice", "doctor", "delegate", "ui": if len(arguments) > 2 { subcommand := strings.TrimSpace(arguments[2]) if subcommand != "" && !strings.HasPrefix(subcommand, "-") { diff --git a/cmd/gait/main_test.go b/cmd/gait/main_test.go index d1e79ad..205b005 100644 --- a/cmd/gait/main_test.go +++ b/cmd/gait/main_test.go @@ -37,6 +37,12 @@ func TestRunDispatch(t *testing.T) { if code := run([]string{"gait", "approve", "--help"}); code != exitOK { t.Fatalf("run approve help: expected %d got %d", exitOK, code) } + if code := run([]string{"gait", "approve-script", "--help"}); code != exitOK { + t.Fatalf("run approve-script help: expected %d got %d", exitOK, code) + } + if code := run([]string{"gait", "list-scripts", "--help"}); code != exitOK { + t.Fatalf("run list-scripts help: expected %d got %d", exitOK, code) + } if code := run([]string{"gait", "delegate", "mint", "--help"}); code != exitOK { t.Fatalf("run delegate mint help: expected %d got %d", exitOK, code) } @@ -1576,6 +1582,41 @@ func TestWriteTourOutputFailureShowsContext(t *testing.T) { } } +func TestWriteTourOutputSuccessShowsGuidance(t *testing.T) { + raw := captureStdout(t, func() { + code := writeTourOutput(false, tourOutput{ + OK: true, + Mode: "activation", + RunID: demoRunID, + VerifyStatus: "ok", + VerifyPath: "./gait-out/runpack_run_demo.zip", + FixtureName: demoRunID, + FixtureDir: "./fixtures/run_demo", + RegressStatus: regressStatusPass, + NextCommands: []string{"gait demo --durable", "gait doctor --summary"}, + BranchHints: []string{"durable branch", "policy branch"}, + MetricsOptIn: demoMetricsOptInCommand, + }, exitOK) + if code != exitOK { + t.Fatalf("writeTourOutput expected %d got %d", exitOK, code) + } + }) + for _, snippet := range []string{ + "tour mode=activation", + "a1_demo=ok run_id=run_demo", + "a2_verify=ok path=./gait-out/runpack_run_demo.zip", + "a3_regress_init=ok fixture=run_demo dir=./fixtures/run_demo", + "a4_regress_run=pass failed=0", + "next=gait demo --durable | gait doctor --summary", + "branch_hints=durable branch | policy branch", + "metrics_opt_in=" + demoMetricsOptInCommand, + } { + if !strings.Contains(raw, snippet) { + t.Fatalf("expected success tour output to contain %q, got %q", snippet, raw) + } + } +} + func TestVerifyJSONIncludesGuidance(t *testing.T) { workDir := t.TempDir() withWorkingDir(t, workDir) @@ -2268,6 +2309,38 @@ func TestOutputWritersAndUsagePrinters(t *testing.T) { if code := writeApproveOutput(false, approveOutput{OK: false, Error: "bad"}, exitInvalidInput); code != exitInvalidInput { t.Fatalf("writeApproveOutput text: expected %d got %d", exitInvalidInput, code) } + if code := writeApproveScriptOutput(true, approveScriptOutput{OK: true, PatternID: "pattern_123"}, exitOK); code != exitOK { + t.Fatalf("writeApproveScriptOutput json: expected %d got %d", exitOK, code) + } + if code := writeApproveScriptOutput(false, approveScriptOutput{OK: false, Error: "bad"}, exitInvalidInput); code != exitInvalidInput { + t.Fatalf("writeApproveScriptOutput text err: expected %d got %d", exitInvalidInput, code) + } + if code := writeApproveScriptOutput(false, approveScriptOutput{ + OK: true, + PatternID: "pattern_123", + RegistryPath: "approved_scripts.json", + PolicyDigest: "sha256:policy", + ScriptHash: "sha256:script", + }, exitOK); code != exitOK { + t.Fatalf("writeApproveScriptOutput text ok: expected %d got %d", exitOK, code) + } + if code := writeListScriptsOutput(true, listScriptsOutput{OK: true, Registry: "approved_scripts.json"}, exitOK); code != exitOK { + t.Fatalf("writeListScriptsOutput json: expected %d got %d", exitOK, code) + } + if code := writeListScriptsOutput(false, listScriptsOutput{OK: false, Error: "bad"}, exitInvalidInput); code != exitInvalidInput { + t.Fatalf("writeListScriptsOutput text err: expected %d got %d", exitInvalidInput, code) + } + if code := writeListScriptsOutput(false, listScriptsOutput{ + OK: true, + Registry: "approved_scripts.json", + Count: 2, + Entries: []listScriptsEntry{ + {PatternID: "pattern_active", ApproverIdentity: "secops", ExpiresAt: "2026-02-22T00:00:00Z", Expired: false}, + {PatternID: "pattern_expired", ApproverIdentity: "secops", ExpiresAt: "2026-02-19T00:00:00Z", Expired: true}, + }, + }, exitOK); code != exitOK { + t.Fatalf("writeListScriptsOutput text ok: expected %d got %d", exitOK, code) + } if code := writeGateEvalOutput(true, gateEvalOutput{OK: true, Verdict: "allow"}, exitOK); code != exitOK { t.Fatalf("writeGateEvalOutput json: expected %d got %d", exitOK, code) @@ -2480,6 +2553,27 @@ func TestOutputWritersAndUsagePrinters(t *testing.T) { }, exitOK); code != exitOK { t.Fatalf("writeDoctorOutput text ok: expected %d got %d", exitOK, code) } + if code := writeDoctorOutput(false, doctorOutput{ + OK: true, + Summary: "doctor summary", + SummaryMode: true, + Status: "pass", + NonFixable: false, + }, exitOK); code != exitOK { + t.Fatalf("writeDoctorOutput summary tips: expected %d got %d", exitOK, code) + } + if code := writeDoctorOutput(false, doctorOutput{ + OK: true, + Summary: "doctor summary", + SummaryMode: true, + Status: "warn", + Checks: []doctor.Check{ + {Name: "check_warn", Status: "warn", Message: "needs action", FixCommand: "gait doctor --summary"}, + {Name: "check_pass", Status: "pass", Message: "ok"}, + }, + }, exitOK); code != exitOK { + t.Fatalf("writeDoctorOutput summary checks: expected %d got %d", exitOK, code) + } if code := writeDoctorOutput(false, doctorOutput{Error: "bad"}, exitInvalidInput); code != exitInvalidInput { t.Fatalf("writeDoctorOutput text error: expected %d got %d", exitInvalidInput, code) } @@ -2582,6 +2676,7 @@ func TestOutputWritersAndUsagePrinters(t *testing.T) { printUsage() printApproveUsage() + printApproveScriptUsage() printDemoUsage() printTourUsage() printDoctorUsage() @@ -2608,6 +2703,7 @@ func TestOutputWritersAndUsagePrinters(t *testing.T) { printRunReceiptUsage() printReplayUsage() printDiffUsage() + printListScriptsUsage() printMigrateUsage() printVerifyUsage() printVerifyChainUsage() diff --git a/cmd/gait/mcp_test.go b/cmd/gait/mcp_test.go index c0afa6d..32280b1 100644 --- a/cmd/gait/mcp_test.go +++ b/cmd/gait/mcp_test.go @@ -349,6 +349,133 @@ func TestRunMCPProxyOSSProdOAuthEvidenceValidation(t *testing.T) { } } +func TestValidateMCPBoundaryOAuthEvidence(t *testing.T) { + validEvidence := &mcp.OAuthEvidence{ + Issuer: "https://auth.example.com", + Audience: []string{"gait-boundary"}, + Subject: "user:alice", + ClientID: "cli-123", + TokenType: "bearer", + Scopes: []string{"tools.read"}, + RedirectURI: "https://app.example.com/callback", + AuthTime: "2026-02-18T00:00:00Z", + EvidenceRef: "oauth:receipt:1", + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{AuthMode: "token"}, + }, gateProfileOSSProd); err != nil { + t.Fatalf("expected token auth mode to bypass OAuth evidence checks, got %v", err) + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{AuthMode: "oauth"}, + }, gateProfileStandard); err != nil { + t.Fatalf("expected standard profile to skip OAuth evidence enforcement, got %v", err) + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{ + AuthMode: "oauth", + OAuthEvidence: validEvidence, + }, + }, gateProfileOSSProd); err != nil { + t.Fatalf("expected valid OAuth evidence to pass in oss-prod, got %v", err) + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{ + AuthMode: "oauth", + AuthContext: map[string]any{ + "oauth_evidence": map[string]any{ + "issuer": validEvidence.Issuer, + "audience": validEvidence.Audience, + "subject": validEvidence.Subject, + "client_id": validEvidence.ClientID, + "token_type": validEvidence.TokenType, + "scopes": validEvidence.Scopes, + "redirect_uri": validEvidence.RedirectURI, + "auth_time": validEvidence.AuthTime, + "evidence_ref": validEvidence.EvidenceRef, + }, + }, + }, + }, gateProfileOSSProd); err != nil { + t.Fatalf("expected auth_context OAuth evidence fallback to pass, got %v", err) + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{AuthMode: "unsupported"}, + }, gateProfileOSSProd); err == nil { + t.Fatalf("expected invalid auth mode validation error") + } + + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{AuthMode: "oauth"}, + }, gateProfileOSSProd); err == nil { + t.Fatalf("expected missing OAuth evidence to fail in oss-prod") + } + + invalidAuthTime := *validEvidence + invalidAuthTime.AuthTime = "not-rfc3339" + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{ + AuthMode: "oauth", + OAuthEvidence: &invalidAuthTime, + }, + }, gateProfileOSSProd); err == nil { + t.Fatalf("expected invalid auth_time to fail validation") + } + + missingDCR := *validEvidence + if err := validateMCPBoundaryOAuthEvidence(mcp.ToolCall{ + Context: mcp.CallContext{ + AuthMode: "oauth_dcr", + OAuthEvidence: &missingDCR, + }, + }, gateProfileOSSProd); err == nil { + t.Fatalf("expected oauth_dcr missing fields to fail validation") + } +} + +func TestOAuthEvidenceFromAuthContext(t *testing.T) { + if evidence := oauthEvidenceFromAuthContext(nil); evidence != nil { + t.Fatalf("expected nil evidence for nil auth context") + } + if evidence := oauthEvidenceFromAuthContext(map[string]any{}); evidence != nil { + t.Fatalf("expected nil evidence for missing oauth_evidence key") + } + if evidence := oauthEvidenceFromAuthContext(map[string]any{ + "oauth_evidence": map[string]any{ + "bad": make(chan int), + }, + }); evidence != nil { + t.Fatalf("expected nil evidence when oauth_evidence cannot marshal") + } + if evidence := oauthEvidenceFromAuthContext(map[string]any{ + "oauth_evidence": "not-an-object", + }); evidence != nil { + t.Fatalf("expected nil evidence when oauth_evidence cannot unmarshal into struct") + } + + evidence := oauthEvidenceFromAuthContext(map[string]any{ + "oauth_evidence": map[string]any{ + "issuer": "https://auth.example.com", + "audience": []string{"gait-boundary"}, + "subject": "user:alice", + "client_id": "cli-123", + "token_type": "bearer", + "scopes": []string{"tools.read"}, + "redirect_uri": "https://app.example.com/callback", + "auth_time": "2026-02-18T00:00:00Z", + "evidence_ref": "oauth:receipt:1", + }, + }) + if evidence == nil || evidence.ClientID != "cli-123" || len(evidence.Scopes) != 1 { + t.Fatalf("unexpected decoded OAuth evidence: %#v", evidence) + } +} + func TestRunMCPProxyAdaptersSupportRunpackAndRegressInit(t *testing.T) { workDir := t.TempDir() withWorkingDir(t, workDir) diff --git a/cmd/gait/run_inspect_test.go b/cmd/gait/run_inspect_test.go index 9f15838..7c3cc8d 100644 --- a/cmd/gait/run_inspect_test.go +++ b/cmd/gait/run_inspect_test.go @@ -201,4 +201,27 @@ func TestRunInspectHelperBranches(t *testing.T) { if !strings.Contains(errorOutput, "inspect error: boom") { t.Fatalf("unexpected error output: %q", errorOutput) } + + sessionChainOutput := captureStdout(t, func() { + code := writeRunInspectOutput(false, runInspectOutput{ + OK: true, + ArtifactType: "session_chain", + SessionID: "sess_demo", + RunID: "run_demo", + Path: "./sessions/run_demo.chain.json", + Checkpoints: []runInspectCheckpoint{ + {CheckpointIndex: 1, RunpackPath: "./gait-out/cp_0001.zip", SequenceStart: 1, SequenceEnd: 2}, + }, + CheckpointCount: 1, + }, exitOK) + if code != exitOK { + t.Fatalf("writeRunInspectOutput session chain expected %d got %d", exitOK, code) + } + }) + if !strings.Contains(sessionChainOutput, "artifact=session_chain session_id=sess_demo run_id=run_demo checkpoints=1") { + t.Fatalf("unexpected session-chain inspect output: %q", sessionChainOutput) + } + if !strings.Contains(sessionChainOutput, "1. runpack=./gait-out/cp_0001.zip seq=1..2") { + t.Fatalf("expected checkpoint line in session-chain inspect output: %q", sessionChainOutput) + } } diff --git a/cmd/gait/run_session_test.go b/cmd/gait/run_session_test.go index 3d27f4a..d9093de 100644 --- a/cmd/gait/run_session_test.go +++ b/cmd/gait/run_session_test.go @@ -133,6 +133,24 @@ func TestRunSessionStatusAndHelpPaths(t *testing.T) { } } +func TestSessionVerdictLabel(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {input: "allow", expected: "allow"}, + {input: " block ", expected: "block"}, + {input: "DRY_RUN", expected: "dry_run"}, + {input: "require_approval", expected: "require_approval"}, + {input: "invalid", expected: "unknown"}, + } + for _, testCase := range cases { + if got := sessionVerdictLabel(testCase.input); got != testCase.expected { + t.Fatalf("sessionVerdictLabel(%q) got %q expected %q", testCase.input, got, testCase.expected) + } + } +} + func TestWriteRunSessionOutputTextModes(t *testing.T) { now := time.Date(2026, time.February, 11, 3, 0, 0, 0, time.UTC) text := captureStdout(t, func() { diff --git a/cmd/gait/ui_test.go b/cmd/gait/ui_test.go index 05effee..ec3f15d 100644 --- a/cmd/gait/ui_test.go +++ b/cmd/gait/ui_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestRunUIValidationAndHelp(t *testing.T) { if code := runUI([]string{"--help"}); code != exitOK { @@ -18,6 +21,29 @@ func TestRunUIInputValidation(t *testing.T) { if code := runUI([]string{"--listen", "127.0.0.1:7980", "extra"}); code != exitInvalidInput { t.Fatalf("run ui unexpected args: expected %d got %d", exitInvalidInput, code) } + if code := runUI([]string{"--listen", "localhost"}); code != exitInvalidInput { + t.Fatalf("run ui invalid listen address: expected %d got %d", exitInvalidInput, code) + } +} + +func TestRunUIExplainAndOutputWriter(t *testing.T) { + if code := runUI([]string{"--explain"}); code != exitOK { + t.Fatalf("run ui explain: expected %d got %d", exitOK, code) + } + if code := writeUIOutput(true, uiOutput{OK: true, URL: "http://127.0.0.1:7980"}, exitOK); code != exitOK { + t.Fatalf("writeUIOutput json: expected %d got %d", exitOK, code) + } + if code := writeUIOutput(false, uiOutput{OK: true}, exitOK); code != exitOK { + t.Fatalf("writeUIOutput text ok: expected %d got %d", exitOK, code) + } + raw := captureStdout(t, func() { + if code := writeUIOutput(false, uiOutput{OK: false, Error: "bad"}, exitInvalidInput); code != exitInvalidInput { + t.Fatalf("writeUIOutput text err: expected %d got %d", exitInvalidInput, code) + } + }) + if !strings.Contains(raw, "ui error: bad") { + t.Fatalf("expected ui error output, got %q", raw) + } } func TestOpenInBrowserValidation(t *testing.T) { diff --git a/cmd/gait/verify.go b/cmd/gait/verify.go index 11706cb..4a2e63c 100644 --- a/cmd/gait/verify.go +++ b/cmd/gait/verify.go @@ -638,12 +638,14 @@ func writeVerifySessionChainOutput(jsonOutput bool, output verifySessionChainOut func printUsage() { fmt.Println("Usage:") fmt.Println(" gait approve --intent-digest --policy-digest --ttl --scope --approver --reason-code [--json] [--explain]") + fmt.Println(" gait approve-script --policy --intent --registry --approver [--pattern-id ] [--ttl ] [--key-mode dev|prod] [--private-key |--private-key-env ] [--json] [--explain]") fmt.Println(" gait delegate mint --delegator --delegate --scope --ttl [--scope-class ] [--intent-digest ] [--policy-digest ] [--json] [--explain]") fmt.Println(" gait delegate verify --token [--delegator ] [--delegate ] [--scope ] [--intent-digest ] [--policy-digest ] [--json] [--explain]") fmt.Println(" gait demo [--durable|--policy] [--json] [--explain]") fmt.Println(" gait tour [--json] [--explain]") fmt.Println(" gait doctor [--production-readiness] [--json] [--explain]") fmt.Println(" gait doctor adoption --from [--json] [--explain]") + fmt.Println(" gait list-scripts --registry [--json] [--explain]") fmt.Println(" gait gate eval --policy --intent [--profile standard|oss-prod] [--simulate] [--approval-token ] [--approval-token-chain ] [--credential-broker off|stub|env|command] [--json] [--explain]") fmt.Println(" gait policy init [--out gait.policy.yaml] [--force] [--json] [--explain]") fmt.Println(" gait policy validate [--json] [--explain]") diff --git a/cmd/gait/verify_session_chain_test.go b/cmd/gait/verify_session_chain_test.go index 7597e08..0be344e 100644 --- a/cmd/gait/verify_session_chain_test.go +++ b/cmd/gait/verify_session_chain_test.go @@ -159,3 +159,71 @@ func TestParseArtifactVerifyProfile(t *testing.T) { t.Fatalf("expected invalid profile parse error") } } + +func TestSignatureStatusNoteAndNextCommands(t *testing.T) { + tests := []struct { + name string + status string + requireSignature bool + expected string + }{ + { + name: "missing_non_strict", + status: "missing", + requireSignature: false, + expected: "unsigned local/dev artifacts are expected by default; use --require-signature for strict verification", + }, + { + name: "missing_strict", + status: "missing", + requireSignature: true, + expected: "signatures are required in this mode; provide signing keys and re-run verify", + }, + { + name: "skipped_non_strict", + status: "skipped", + requireSignature: false, + expected: "signature checks were skipped because no verify key was provided", + }, + { + name: "skipped_strict", + status: "skipped", + requireSignature: true, + expected: "signature checks were expected but skipped; provide a public key or private key source", + }, + { + name: "verified", + status: "verified", + requireSignature: true, + expected: "signatures verified", + }, + { + name: "failed", + status: "failed", + requireSignature: true, + expected: "signature verification failed; inspect signature_errors and re-run with the correct key", + }, + { + name: "unknown", + status: "unknown", + requireSignature: true, + expected: "", + }, + } + for _, testCase := range tests { + if got := signatureStatusNote(testCase.status, testCase.requireSignature); got != testCase.expected { + t.Fatalf("%s: signatureStatusNote mismatch: got=%q expected=%q", testCase.name, got, testCase.expected) + } + } + + if commands := verifyNextCommands(""); commands != nil { + t.Fatalf("expected nil commands for blank run id, got %#v", commands) + } + commands := verifyNextCommands("run_demo") + if len(commands) != 3 { + t.Fatalf("expected 3 follow-up commands, got %#v", commands) + } + if !strings.Contains(commands[0], "run_demo") { + t.Fatalf("expected run id in first follow-up command, got %#v", commands) + } +} diff --git a/core/credential/providers_test.go b/core/credential/providers_test.go index 443a1bc..b44c9a4 100644 --- a/core/credential/providers_test.go +++ b/core/credential/providers_test.go @@ -195,6 +195,19 @@ func TestNormalizeRequestValidation(t *testing.T) { } } +func TestParseInt64(t *testing.T) { + value, err := parseInt64(" 42 ") + if err != nil || value != 42 { + t.Fatalf("parseInt64 expected 42, got value=%d err=%v", value, err) + } + if _, err := parseInt64(""); err == nil { + t.Fatalf("expected parseInt64 to reject empty input") + } + if _, err := parseInt64("invalid"); err == nil { + t.Fatalf("expected parseInt64 to reject invalid integer") + } +} + func TestCommandBrokerIssuePlainTextAndTimeout(t *testing.T) { executable, err := os.Executable() if err != nil { diff --git a/core/doctor/doctor.go b/core/doctor/doctor.go index 72345b0..ac9368e 100644 --- a/core/doctor/doctor.go +++ b/core/doctor/doctor.go @@ -62,6 +62,7 @@ var requiredSchemaPaths = []string{ "schemas/v1/gate/approval_token.schema.json", "schemas/v1/gate/approval_audit_record.schema.json", "schemas/v1/gate/broker_credential_record.schema.json", + "schemas/v1/gate/approved_script_entry.schema.json", "schemas/v1/context/envelope.schema.json", "schemas/v1/context/reference_record.schema.json", "schemas/v1/context/budget_report.schema.json", diff --git a/core/gate/approved_scripts.go b/core/gate/approved_scripts.go new file mode 100644 index 0000000..64b4a1c --- /dev/null +++ b/core/gate/approved_scripts.go @@ -0,0 +1,290 @@ +package gate + +import ( + "crypto/ed25519" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Clyra-AI/gait/core/fsx" + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" + jcs "github.com/Clyra-AI/proof/canon" + sign "github.com/Clyra-AI/proof/signing" +) + +const ( + approvedScriptSchemaID = "gait.gate.approved_script_entry" + approvedScriptSchemaV1 = "1.0.0" +) + +type ApprovedScriptMatch struct { + Matched bool + PatternID string + Reason string +} + +func NormalizeApprovedScriptEntry(input schemagate.ApprovedScriptEntry) (schemagate.ApprovedScriptEntry, error) { + output := input + if strings.TrimSpace(output.SchemaID) == "" { + output.SchemaID = approvedScriptSchemaID + } + if strings.TrimSpace(output.SchemaID) != approvedScriptSchemaID { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("unsupported approved script schema_id: %s", output.SchemaID) + } + if strings.TrimSpace(output.SchemaVersion) == "" { + output.SchemaVersion = approvedScriptSchemaV1 + } + if strings.TrimSpace(output.SchemaVersion) != approvedScriptSchemaV1 { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("unsupported approved script schema_version: %s", output.SchemaVersion) + } + output.PatternID = strings.TrimSpace(output.PatternID) + output.PolicyDigest = strings.ToLower(strings.TrimSpace(output.PolicyDigest)) + output.ScriptHash = strings.ToLower(strings.TrimSpace(output.ScriptHash)) + output.ApproverIdentity = strings.TrimSpace(output.ApproverIdentity) + output.ToolSequence = normalizeStringListLower(output.ToolSequence) + output.Scope = normalizeStringList(output.Scope) + output.CreatedAt = output.CreatedAt.UTC() + output.ExpiresAt = output.ExpiresAt.UTC() + if output.CreatedAt.IsZero() { + output.CreatedAt = time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC) + } + if output.PatternID == "" { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("pattern_id is required") + } + if !hexDigestPattern.MatchString(output.PolicyDigest) { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("policy_digest must be sha256 hex") + } + if !hexDigestPattern.MatchString(output.ScriptHash) { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("script_hash must be sha256 hex") + } + if len(output.ToolSequence) == 0 { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("tool_sequence is required") + } + if output.ApproverIdentity == "" { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("approver_identity is required") + } + if output.ExpiresAt.IsZero() || !output.ExpiresAt.After(output.CreatedAt) { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("expires_at must be after created_at") + } + return output, nil +} + +func ApprovedScriptDigest(input schemagate.ApprovedScriptEntry) (string, error) { + normalized, err := NormalizeApprovedScriptEntry(input) + if err != nil { + return "", err + } + signable := normalized + signable.Signature = nil + raw, err := json.Marshal(signable) + if err != nil { + return "", fmt.Errorf("marshal approved script signable payload: %w", err) + } + digest, err := jcs.DigestJCS(raw) + if err != nil { + return "", fmt.Errorf("digest approved script payload: %w", err) + } + return digest, nil +} + +func SignApprovedScriptEntry(input schemagate.ApprovedScriptEntry, privateKey ed25519.PrivateKey) (schemagate.ApprovedScriptEntry, error) { + if len(privateKey) == 0 { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("signing private key is required") + } + normalized, err := NormalizeApprovedScriptEntry(input) + if err != nil { + return schemagate.ApprovedScriptEntry{}, err + } + signable := normalized + signable.Signature = nil + raw, err := json.Marshal(signable) + if err != nil { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("marshal approved script entry: %w", err) + } + signature, err := sign.SignTraceRecordJSON(privateKey, raw) + if err != nil { + return schemagate.ApprovedScriptEntry{}, fmt.Errorf("sign approved script entry: %w", err) + } + normalized.Signature = &schemagate.Signature{ + Alg: signature.Alg, + KeyID: signature.KeyID, + Sig: signature.Sig, + SignedDigest: signature.SignedDigest, + } + return normalized, nil +} + +func VerifyApprovedScriptEntry(input schemagate.ApprovedScriptEntry, publicKey ed25519.PublicKey, now time.Time) error { + normalized, err := NormalizeApprovedScriptEntry(input) + if err != nil { + return err + } + if normalized.Signature == nil { + return fmt.Errorf("signature is required") + } + nowUTC := now.UTC() + if nowUTC.IsZero() { + nowUTC = time.Now().UTC() + } + if !normalized.ExpiresAt.After(nowUTC) { + return fmt.Errorf("approved script entry is expired") + } + signable := normalized + signable.Signature = nil + raw, err := json.Marshal(signable) + if err != nil { + return fmt.Errorf("marshal signable approved script entry: %w", err) + } + if len(publicKey) == 0 { + return fmt.Errorf("verify key is required") + } + ok, err := sign.VerifyTraceRecordJSON(publicKey, sign.Signature{ + Alg: normalized.Signature.Alg, + KeyID: normalized.Signature.KeyID, + Sig: normalized.Signature.Sig, + SignedDigest: normalized.Signature.SignedDigest, + }, raw) + if err != nil { + return fmt.Errorf("verify approved script entry signature: %w", err) + } + if !ok { + return fmt.Errorf("approved script signature did not verify") + } + return nil +} + +func ReadApprovedScriptRegistry(path string) ([]schemagate.ApprovedScriptEntry, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil, fmt.Errorf("approved script registry path is required") + } + // #nosec G304 -- explicit local path. + content, err := os.ReadFile(trimmed) + if err != nil { + if os.IsNotExist(err) { + return []schemagate.ApprovedScriptEntry{}, nil + } + return nil, fmt.Errorf("read approved script registry: %w", err) + } + if len(strings.TrimSpace(string(content))) == 0 { + return []schemagate.ApprovedScriptEntry{}, nil + } + + 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 entries []schemagate.ApprovedScriptEntry + if err := json.Unmarshal(content, &entries); err != nil { + return nil, fmt.Errorf("parse approved script registry: %w", err) + } + return normalizeApprovedScriptEntries(entries) +} + +func WriteApprovedScriptRegistry(path string, entries []schemagate.ApprovedScriptEntry) error { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return fmt.Errorf("approved script registry path is required") + } + normalizedEntries, err := normalizeApprovedScriptEntries(entries) + if err != nil { + return err + } + sort.Slice(normalizedEntries, func(i, j int) bool { + if normalizedEntries[i].PatternID != normalizedEntries[j].PatternID { + return normalizedEntries[i].PatternID < normalizedEntries[j].PatternID + } + return normalizedEntries[i].CreatedAt.Before(normalizedEntries[j].CreatedAt) + }) + encoded, err := json.MarshalIndent(struct { + Entries []schemagate.ApprovedScriptEntry `json:"entries"` + }{Entries: normalizedEntries}, "", " ") + if err != nil { + return fmt.Errorf("marshal approved script registry: %w", err) + } + encoded = append(encoded, '\n') + dir := filepath.Dir(trimmed) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create approved script registry directory: %w", err) + } + } + if err := fsx.WriteFileAtomic(trimmed, encoded, 0o600); err != nil { + return fmt.Errorf("write approved script registry: %w", err) + } + return nil +} + +func MatchApprovedScript(intent schemagate.IntentRequest, policyDigest string, entries []schemagate.ApprovedScriptEntry, now time.Time) (ApprovedScriptMatch, error) { + normalized, err := NormalizeIntent(intent) + if err != nil { + return ApprovedScriptMatch{}, err + } + if normalized.Script == nil { + return ApprovedScriptMatch{Matched: false, Reason: "not_script"}, nil + } + sequence := make([]string, 0, len(normalized.Script.Steps)) + for _, step := range normalized.Script.Steps { + sequence = append(sequence, step.ToolName) + } + normalizedPolicyDigest := strings.ToLower(strings.TrimSpace(policyDigest)) + nowUTC := now.UTC() + if nowUTC.IsZero() { + nowUTC = time.Now().UTC() + } + for _, entry := range entries { + if strings.ToLower(strings.TrimSpace(entry.PolicyDigest)) != normalizedPolicyDigest { + continue + } + if strings.ToLower(strings.TrimSpace(entry.ScriptHash)) != normalized.ScriptHash { + continue + } + if !entry.ExpiresAt.UTC().After(nowUTC) { + continue + } + if !stringSliceEqual(normalizeStringListLower(entry.ToolSequence), sequence) { + continue + } + return ApprovedScriptMatch{ + Matched: true, + PatternID: entry.PatternID, + Reason: "approved_script_match", + }, nil + } + return ApprovedScriptMatch{ + Matched: false, + Reason: "approved_script_not_found", + }, nil +} + +func normalizeApprovedScriptEntries(entries []schemagate.ApprovedScriptEntry) ([]schemagate.ApprovedScriptEntry, error) { + output := make([]schemagate.ApprovedScriptEntry, 0, len(entries)) + for index, entry := range entries { + normalized, err := NormalizeApprovedScriptEntry(entry) + if err != nil { + return nil, fmt.Errorf("approved script entries[%d]: %w", index, err) + } + output = append(output, normalized) + } + return output, nil +} + +func stringSliceEqual(left []string, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/core/gate/approved_scripts_test.go b/core/gate/approved_scripts_test.go new file mode 100644 index 0000000..ebacd7e --- /dev/null +++ b/core/gate/approved_scripts_test.go @@ -0,0 +1,171 @@ +package gate + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" + sign "github.com/Clyra-AI/proof/signing" +) + +func TestApprovedScriptRegistryRoundTripAndMatch(t *testing.T) { + keyPair, err := sign.GenerateKeyPair() + if err != nil { + t.Fatalf("generate key pair: %v", err) + } + nowUTC := time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC) + entry, err := SignApprovedScriptEntry(schemagate.ApprovedScriptEntry{ + SchemaID: "gait.gate.approved_script_entry", + SchemaVersion: "1.0.0", + CreatedAt: nowUTC, + ProducerVersion: "test", + PatternID: "pattern_demo", + PolicyDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ScriptHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ToolSequence: []string{"tool.read", "tool.write"}, + ApproverIdentity: "security-team", + ExpiresAt: nowUTC.Add(24 * time.Hour), + }, keyPair.Private) + if err != nil { + t.Fatalf("sign approved script entry: %v", err) + } + if err := VerifyApprovedScriptEntry(entry, keyPair.Public, nowUTC); err != nil { + t.Fatalf("verify approved script entry: %v", err) + } + + registryPath := filepath.Join(t.TempDir(), "approved_scripts.json") + if err := WriteApprovedScriptRegistry(registryPath, []schemagate.ApprovedScriptEntry{entry}); err != nil { + t.Fatalf("write registry: %v", err) + } + readEntries, err := ReadApprovedScriptRegistry(registryPath) + if err != nil { + t.Fatalf("read registry: %v", err) + } + if len(readEntries) != 1 || readEntries[0].PatternID != "pattern_demo" { + t.Fatalf("unexpected registry entries: %#v", readEntries) + } + + intent := baseIntent() + intent.ToolName = "script" + intent.Script = &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + {ToolName: "tool.read", Args: map[string]any{"path": "/tmp/in.txt"}}, + {ToolName: "tool.write", Args: map[string]any{"path": "/tmp/out.txt"}}, + }, + } + normalized, err := NormalizeIntent(intent) + if err != nil { + t.Fatalf("normalize script intent: %v", err) + } + entry.ScriptHash = normalized.ScriptHash + if err := WriteApprovedScriptRegistry(registryPath, []schemagate.ApprovedScriptEntry{entry}); err != nil { + t.Fatalf("rewrite registry with matching script hash: %v", err) + } + readEntries, err = ReadApprovedScriptRegistry(registryPath) + if err != nil { + t.Fatalf("read registry: %v", err) + } + match, err := MatchApprovedScript(normalized, entry.PolicyDigest, readEntries, nowUTC) + if err != nil { + t.Fatalf("match approved script: %v", err) + } + if !match.Matched || match.PatternID != "pattern_demo" { + t.Fatalf("expected match for approved script entry, got %#v", match) + } +} + +func TestApprovedScriptDigestIgnoresSignature(t *testing.T) { + nowUTC := time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC) + entry := schemagate.ApprovedScriptEntry{ + SchemaID: "gait.gate.approved_script_entry", + SchemaVersion: "1.0.0", + CreatedAt: nowUTC, + ProducerVersion: "test", + PatternID: "pattern_digest", + PolicyDigest: strings.Repeat("a", 64), + ScriptHash: strings.Repeat("b", 64), + ToolSequence: []string{"tool.read", "tool.write"}, + ApproverIdentity: "security-team", + ExpiresAt: nowUTC.Add(24 * time.Hour), + } + + firstDigest, err := ApprovedScriptDigest(entry) + if err != nil { + t.Fatalf("digest approved script entry: %v", err) + } + entry.Signature = &schemagate.Signature{ + Alg: "ed25519", + KeyID: "key-1", + Sig: "sig", + SignedDigest: strings.Repeat("c", 64), + } + secondDigest, err := ApprovedScriptDigest(entry) + if err != nil { + t.Fatalf("digest approved script entry with signature: %v", err) + } + if firstDigest != secondDigest { + t.Fatalf("expected signature-free digest stability: first=%q second=%q", firstDigest, secondDigest) + } + + if _, err := ApprovedScriptDigest(schemagate.ApprovedScriptEntry{}); err == nil { + t.Fatalf("expected digest failure for invalid approved script entry") + } +} + +func TestReadApprovedScriptRegistryVariants(t *testing.T) { + if _, err := ReadApprovedScriptRegistry(" "); err == nil { + t.Fatalf("expected error for empty approved script registry path") + } + + missingPath := filepath.Join(t.TempDir(), "missing.json") + entries, err := ReadApprovedScriptRegistry(missingPath) + if err != nil { + t.Fatalf("read missing approved script registry: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected empty entries for missing registry, got %#v", entries) + } + + emptyPath := filepath.Join(t.TempDir(), "empty.json") + if err := os.WriteFile(emptyPath, nil, 0o600); err != nil { + t.Fatalf("write empty registry fixture: %v", err) + } + entries, err = ReadApprovedScriptRegistry(emptyPath) + if err != nil { + t.Fatalf("read empty approved script registry: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected empty entries for blank registry file, 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(`[ + { + "schema_id":"gait.gate.approved_script_entry", + "schema_version":"1.0.0", + "created_at":"2026-02-05T00:00:00Z", + "producer_version":"test", + "pattern_id":"pattern_legacy", + "policy_digest":"` + strings.Repeat("a", 64) + `", + "script_hash":"` + strings.Repeat("b", 64) + `", + "tool_sequence":["tool.read"], + "approver_identity":"secops", + "expires_at":"2026-02-06T00:00:00Z" + } + ] +`) + if err := os.WriteFile(legacyPath, legacyJSON, 0o600); err != nil { + t.Fatalf("write legacy registry fixture: %v", err) + } + entries, err = ReadApprovedScriptRegistry(legacyPath) + if err != nil { + t.Fatalf("read legacy approved script registry: %v", err) + } + if len(entries) != 1 || entries[0].PatternID != "pattern_legacy" || !entries[0].ExpiresAt.After(nowUTC) { + t.Fatalf("unexpected legacy registry entries: %#v", entries) + } +} diff --git a/core/gate/context_wrkr.go b/core/gate/context_wrkr.go new file mode 100644 index 0000000..01eb8d1 --- /dev/null +++ b/core/gate/context_wrkr.go @@ -0,0 +1,139 @@ +package gate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" +) + +type WrkrToolMetadata struct { + ToolName string + DataClass string + EndpointClass string + AutonomyLevel string +} + +type WrkrInventory struct { + Path string + ModTime time.Time + Tools map[string]WrkrToolMetadata +} + +type wrkrInventoryCacheEntry struct { + modTime time.Time + inventory WrkrInventory +} + +var wrkrInventoryCache = struct { + sync.Mutex + entries map[string]wrkrInventoryCacheEntry +}{ + entries: map[string]wrkrInventoryCacheEntry{}, +} + +func LoadWrkrInventory(path string) (WrkrInventory, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return WrkrInventory{}, fmt.Errorf("wrkr inventory path is required") + } + cleanPath := filepath.Clean(trimmed) + // #nosec G304 -- explicit local path from CLI. + info, err := os.Stat(cleanPath) + if err != nil { + return WrkrInventory{}, fmt.Errorf("stat wrkr inventory: %w", err) + } + modTime := info.ModTime().UTC() + + wrkrInventoryCache.Lock() + if cached, ok := wrkrInventoryCache.entries[cleanPath]; ok && cached.modTime.Equal(modTime) { + wrkrInventoryCache.Unlock() + return cached.inventory, nil + } + wrkrInventoryCache.Unlock() + + // #nosec G304 -- explicit local path from CLI. + content, err := os.ReadFile(cleanPath) + if err != nil { + return WrkrInventory{}, fmt.Errorf("read wrkr inventory: %w", err) + } + tools, err := parseWrkrInventory(content) + if err != nil { + return WrkrInventory{}, err + } + inventory := WrkrInventory{ + Path: cleanPath, + ModTime: modTime, + Tools: tools, + } + + wrkrInventoryCache.Lock() + wrkrInventoryCache.entries[cleanPath] = wrkrInventoryCacheEntry{ + modTime: modTime, + inventory: inventory, + } + wrkrInventoryCache.Unlock() + + return inventory, nil +} + +func parseWrkrInventory(content []byte) (map[string]WrkrToolMetadata, error) { + type item struct { + ToolName string `json:"tool_name"` + DataClass string `json:"data_class"` + EndpointClass string `json:"endpoint_class"` + AutonomyLevel string `json:"autonomy_level"` + } + type envelope struct { + Items []item `json:"items"` + } + + entries := []item{} + var wrapped envelope + if err := json.Unmarshal(content, &wrapped); err == nil && len(wrapped.Items) > 0 { + entries = wrapped.Items + } else { + if err := json.Unmarshal(content, &entries); err != nil { + return nil, fmt.Errorf("parse wrkr inventory: %w", err) + } + } + + tools := map[string]WrkrToolMetadata{} + for _, entry := range entries { + toolName := strings.ToLower(strings.TrimSpace(entry.ToolName)) + if toolName == "" { + continue + } + tools[toolName] = WrkrToolMetadata{ + ToolName: toolName, + DataClass: strings.ToLower(strings.TrimSpace(entry.DataClass)), + EndpointClass: strings.ToLower(strings.TrimSpace(entry.EndpointClass)), + AutonomyLevel: strings.ToLower(strings.TrimSpace(entry.AutonomyLevel)), + } + } + return tools, nil +} + +func ApplyWrkrContext(intent *schemagate.IntentRequest, toolName string, inventory map[string]WrkrToolMetadata) bool { + if intent == nil || len(inventory) == 0 { + return false + } + key := strings.ToLower(strings.TrimSpace(toolName)) + metadata, ok := inventory[key] + if !ok { + return false + } + if intent.Context.AuthContext == nil { + intent.Context.AuthContext = map[string]any{} + } + intent.Context.AuthContext[wrkrContextToolNameKey] = metadata.ToolName + intent.Context.AuthContext[wrkrContextDataClassKey] = metadata.DataClass + intent.Context.AuthContext[wrkrContextEndpointClassKey] = metadata.EndpointClass + intent.Context.AuthContext[wrkrContextAutonomyLevelKey] = metadata.AutonomyLevel + return true +} diff --git a/core/gate/context_wrkr_test.go b/core/gate/context_wrkr_test.go new file mode 100644 index 0000000..9042f8c --- /dev/null +++ b/core/gate/context_wrkr_test.go @@ -0,0 +1,122 @@ +package gate + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" +) + +func TestLoadWrkrInventoryParsesAndNormalizes(t *testing.T) { + workDir := t.TempDir() + path := filepath.Join(workDir, "wrkr_inventory.json") + mustWriteWrkrInventoryFile(t, path, `{ + "items": [ + { + "tool_name": " TOOL.Read ", + "data_class": " PII ", + "endpoint_class": " FS.Read ", + "autonomy_level": " Assist " + }, + { + "tool_name": "tool.write", + "data_class": "secret", + "endpoint_class": "fs.write", + "autonomy_level": "auto" + } + ] +}`) + + inventory, err := LoadWrkrInventory(path) + if err != nil { + t.Fatalf("LoadWrkrInventory returned error: %v", err) + } + if inventory.Path != path { + t.Fatalf("unexpected inventory path: %q", inventory.Path) + } + readMetadata, ok := inventory.Tools["tool.read"] + if !ok { + t.Fatalf("expected normalized tool key tool.read, got %#v", inventory.Tools) + } + if readMetadata.DataClass != "pii" || readMetadata.EndpointClass != "fs.read" || readMetadata.AutonomyLevel != "assist" { + t.Fatalf("unexpected normalized tool.read metadata: %#v", readMetadata) + } +} + +func TestLoadWrkrInventoryRefreshesOnModTimeChange(t *testing.T) { + workDir := t.TempDir() + path := filepath.Join(workDir, "wrkr_inventory.json") + mustWriteWrkrInventoryFile(t, path, `[{"tool_name":"tool.write","data_class":"pii","endpoint_class":"fs.write","autonomy_level":"assist"}]`) + + initial, err := LoadWrkrInventory(path) + if err != nil { + t.Fatalf("initial LoadWrkrInventory returned error: %v", err) + } + if initial.Tools["tool.write"].DataClass != "pii" { + t.Fatalf("unexpected initial data class: %#v", initial.Tools["tool.write"]) + } + + mustWriteWrkrInventoryFile(t, path, `[{"tool_name":"tool.write","data_class":"secret","endpoint_class":"fs.write","autonomy_level":"assist"}]`) + nextModTime := time.Now().UTC().Add(2 * time.Second) + if err := os.Chtimes(path, nextModTime, nextModTime); err != nil { + t.Fatalf("set wrkr inventory modtime: %v", err) + } + + refreshed, err := LoadWrkrInventory(path) + if err != nil { + t.Fatalf("refreshed LoadWrkrInventory returned error: %v", err) + } + if refreshed.Tools["tool.write"].DataClass != "secret" { + t.Fatalf("expected refreshed data class secret, got %#v", refreshed.Tools["tool.write"]) + } +} + +func TestLoadWrkrInventoryRejectsInvalidPayload(t *testing.T) { + workDir := t.TempDir() + path := filepath.Join(workDir, "wrkr_inventory.json") + mustWriteWrkrInventoryFile(t, path, `{not-json}`) + + _, err := LoadWrkrInventory(path) + if err == nil { + t.Fatalf("expected invalid wrkr inventory payload to fail") + } + if !strings.Contains(err.Error(), "parse wrkr inventory") { + t.Fatalf("expected parse wrkr inventory error, got: %v", err) + } +} + +func TestApplyWrkrContextAddsMetadata(t *testing.T) { + intent := schemagate.IntentRequest{ + Context: schemagate.IntentContext{}, + } + applied := ApplyWrkrContext(&intent, "tool.write", map[string]WrkrToolMetadata{ + "tool.write": { + ToolName: "tool.write", + DataClass: "secret", + EndpointClass: "fs.write", + AutonomyLevel: "assist", + }, + }) + if !applied { + t.Fatalf("expected wrkr context to be applied") + } + if intent.Context.AuthContext == nil { + t.Fatalf("expected auth_context to be initialized") + } + if intent.Context.AuthContext[wrkrContextToolNameKey] != "tool.write" { + t.Fatalf("unexpected wrkr.tool_name value: %#v", intent.Context.AuthContext) + } + if intent.Context.AuthContext[wrkrContextDataClassKey] != "secret" { + t.Fatalf("unexpected wrkr.data_class value: %#v", intent.Context.AuthContext) + } +} + +func mustWriteWrkrInventoryFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write wrkr inventory file: %v", err) + } +} diff --git a/core/gate/intent.go b/core/gate/intent.go index dd955f4..aaa9acc 100644 --- a/core/gate/intent.go +++ b/core/gate/intent.go @@ -17,6 +17,7 @@ import ( const ( intentRequestSchemaID = "gait.gate.intent_request" intentRequestSchemaV1 = "1.0.0" + maxScriptSteps = 64 ) var ( @@ -55,6 +56,8 @@ var ( type normalizedIntent struct { ToolName string `json:"tool_name"` Args map[string]any `json:"args"` + ScriptHash string `json:"script_hash,omitempty"` + Script *normalizedScript `json:"script,omitempty"` Targets []schemagate.IntentTarget `json:"targets"` ArgProvenance []schemagate.IntentArgProvenance `json:"arg_provenance,omitempty"` SkillProvenance *schemagate.SkillProvenance `json:"skill_provenance,omitempty"` @@ -62,6 +65,17 @@ type normalizedIntent struct { Context schemagate.IntentContext `json:"context"` } +type normalizedScript struct { + Steps []normalizedScriptStep `json:"steps"` +} + +type normalizedScriptStep struct { + ToolName string `json:"tool_name"` + Args map[string]any `json:"args"` + Targets []schemagate.IntentTarget `json:"targets,omitempty"` + ArgProvenance []schemagate.IntentArgProvenance `json:"arg_provenance,omitempty"` +} + func NormalizeIntent(input schemagate.IntentRequest) (schemagate.IntentRequest, error) { normalized, err := normalizeIntent(input) if err != nil { @@ -88,6 +102,10 @@ func NormalizeIntent(input schemagate.IntentRequest) (schemagate.IntentRequest, output.Args = normalized.Args output.ArgsDigest = argsDigest output.IntentDigest = intentDigest + output.ScriptHash = normalized.ScriptHash + if normalized.Script != nil { + output.Script = toSchemaIntentScript(normalized.Script) + } output.Targets = normalized.Targets output.ArgProvenance = normalized.ArgProvenance output.SkillProvenance = normalized.SkillProvenance @@ -116,6 +134,20 @@ func IntentDigest(input schemagate.IntentRequest) (string, error) { return digestNormalizedIntent(normalized) } +func ScriptHash(input schemagate.IntentRequest) (string, error) { + normalized, err := normalizeIntent(input) + if err != nil { + return "", err + } + if normalized.Script == nil { + return "", fmt.Errorf("script is required") + } + if normalized.ScriptHash == "" { + return "", fmt.Errorf("script hash missing after normalization") + } + return normalized.ScriptHash, nil +} + func ArgsDigest(args map[string]any) (string, error) { normalizedValue, err := normalizeJSONValue(args) if err != nil { @@ -129,7 +161,15 @@ func ArgsDigest(args map[string]any) (string, error) { } func normalizeIntent(input schemagate.IntentRequest) (normalizedIntent, error) { + script, err := normalizeScript(input.Script) + if err != nil { + return normalizedIntent{}, err + } + toolName := strings.ToLower(strings.TrimSpace(input.ToolName)) + if script != nil { + toolName = "script" + } if toolName == "" { return normalizedIntent{}, fmt.Errorf("tool_name is required") } @@ -143,11 +183,23 @@ func normalizeIntent(input schemagate.IntentRequest) (normalizedIntent, error) { return normalizedIntent{}, fmt.Errorf("args must be a JSON object") } - targets, err := normalizeTargets(toolName, input.Targets) + targetsInput := input.Targets + if script != nil && len(targetsInput) == 0 { + for _, step := range script.Steps { + targetsInput = append(targetsInput, step.Targets...) + } + } + targets, err := normalizeTargets(toolName, targetsInput) if err != nil { return normalizedIntent{}, err } - provenance, err := normalizeArgProvenance(input.ArgProvenance) + provenanceInput := input.ArgProvenance + if script != nil && len(provenanceInput) == 0 { + for _, step := range script.Steps { + provenanceInput = append(provenanceInput, step.ArgProvenance...) + } + } + provenance, err := normalizeArgProvenance(provenanceInput) if err != nil { return normalizedIntent{}, err } @@ -164,9 +216,19 @@ func normalizeIntent(input schemagate.IntentRequest) (normalizedIntent, error) { return normalizedIntent{}, err } + scriptHash := "" + if script != nil { + scriptHash, err = digestNormalizedScript(*script) + if err != nil { + return normalizedIntent{}, err + } + } + return normalizedIntent{ ToolName: toolName, Args: args, + ScriptHash: scriptHash, + Script: script, Targets: targets, ArgProvenance: provenance, SkillProvenance: skillProvenance, @@ -175,6 +237,48 @@ func normalizeIntent(input schemagate.IntentRequest) (normalizedIntent, error) { }, nil } +func normalizeScript(input *schemagate.IntentScript) (*normalizedScript, error) { + if input == nil { + return nil, nil + } + if len(input.Steps) == 0 { + return nil, fmt.Errorf("script.steps must not be empty") + } + if len(input.Steps) > maxScriptSteps { + return nil, fmt.Errorf("script.steps exceeds max supported steps (%d)", maxScriptSteps) + } + steps := make([]normalizedScriptStep, 0, len(input.Steps)) + for index, step := range input.Steps { + toolName := strings.ToLower(strings.TrimSpace(step.ToolName)) + if toolName == "" { + return nil, fmt.Errorf("script.steps[%d].tool_name is required", index) + } + normalizedValue, err := normalizeJSONValue(step.Args) + if err != nil { + return nil, fmt.Errorf("normalize script.steps[%d].args: %w", index, err) + } + args, ok := normalizedValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("script.steps[%d].args must be a JSON object", index) + } + targets, err := normalizeTargets(toolName, step.Targets) + if err != nil { + return nil, fmt.Errorf("normalize script.steps[%d].targets: %w", index, err) + } + provenance, err := normalizeArgProvenance(step.ArgProvenance) + if err != nil { + return nil, fmt.Errorf("normalize script.steps[%d].arg_provenance: %w", index, err) + } + steps = append(steps, normalizedScriptStep{ + ToolName: toolName, + Args: args, + Targets: targets, + ArgProvenance: provenance, + }) + } + return &normalizedScript{Steps: steps}, nil +} + func normalizeTargets(toolName string, targets []schemagate.IntentTarget) ([]schemagate.IntentTarget, error) { if len(targets) == 0 { return []schemagate.IntentTarget{}, nil @@ -675,3 +779,31 @@ func digestNormalizedIntent(intent normalizedIntent) (string, error) { } return digest, nil } + +func digestNormalizedScript(script normalizedScript) (string, error) { + raw, err := json.Marshal(script) + if err != nil { + return "", fmt.Errorf("marshal normalized script: %w", err) + } + digest, err := jcs.DigestJCS(raw) + if err != nil { + return "", fmt.Errorf("digest normalized script: %w", err) + } + return digest, nil +} + +func toSchemaIntentScript(input *normalizedScript) *schemagate.IntentScript { + if input == nil { + return nil + } + steps := make([]schemagate.IntentScriptStep, 0, len(input.Steps)) + for _, step := range input.Steps { + steps = append(steps, schemagate.IntentScriptStep{ + ToolName: step.ToolName, + Args: step.Args, + Targets: step.Targets, + ArgProvenance: step.ArgProvenance, + }) + } + return &schemagate.IntentScript{Steps: steps} +} diff --git a/core/gate/intent_test.go b/core/gate/intent_test.go index 66fff38..f320437 100644 --- a/core/gate/intent_test.go +++ b/core/gate/intent_test.go @@ -295,6 +295,112 @@ func TestNormalizeIntentDelegationDigesting(t *testing.T) { } } +func TestNormalizeIntentScriptDigestDeterminism(t *testing.T) { + intentA := schemagate.IntentRequest{ + ToolName: "script", + Args: map[string]any{}, + Context: schemagate.IntentContext{Identity: "alice", Workspace: "/repo/gait", RiskClass: "high"}, + Script: &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + { + ToolName: "tool.read", + Args: map[string]any{"path": "/tmp/in.txt", "opts": map[string]any{"follow": true}}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/in.txt", Operation: "read"}, + {Kind: "path", Value: "/tmp/in.txt", Operation: "read"}, + }, + }, + { + ToolName: "tool.write", + Args: map[string]any{"path": "/tmp/out.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/out.txt", Operation: "write"}, + }, + }, + }, + }, + } + intentB := schemagate.IntentRequest{ + ToolName: " script ", + Args: map[string]any{}, + Context: schemagate.IntentContext{Identity: "alice", Workspace: "/repo/gait", RiskClass: "high"}, + Script: &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + { + ToolName: " tool.read ", + Args: map[string]any{"opts": map[string]any{"follow": true}, "path": "/tmp/in.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: " path ", Value: " /tmp/in.txt ", Operation: "read"}, + }, + }, + { + ToolName: "tool.write", + Args: map[string]any{"path": "/tmp/out.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/out.txt", Operation: "write"}, + }, + }, + }, + }, + } + normalizedA, err := NormalizeIntent(intentA) + if err != nil { + t.Fatalf("normalize script intent A: %v", err) + } + normalizedB, err := NormalizeIntent(intentB) + if err != nil { + t.Fatalf("normalize script intent B: %v", err) + } + if normalizedA.ScriptHash == "" { + t.Fatalf("expected script hash to be populated") + } + if normalizedA.ScriptHash != normalizedB.ScriptHash { + t.Fatalf("expected equivalent scripts to produce the same script hash: %q != %q", normalizedA.ScriptHash, normalizedB.ScriptHash) + } + if normalizedA.IntentDigest != normalizedB.IntentDigest { + t.Fatalf("expected equivalent scripts to produce same intent digest") + } +} + +func TestNormalizeIntentScriptValidation(t *testing.T) { + _, err := NormalizeIntent(schemagate.IntentRequest{ + ToolName: "script", + Args: map[string]any{}, + Context: schemagate.IntentContext{Identity: "alice", Workspace: "/repo/gait", RiskClass: "high"}, + Script: &schemagate.IntentScript{Steps: []schemagate.IntentScriptStep{}}, + }) + if err == nil { + t.Fatalf("expected empty script.steps to fail normalization") + } +} + +func TestScriptHash(t *testing.T) { + intent := schemagate.IntentRequest{ + ToolName: "script", + Args: map[string]any{}, + Context: schemagate.IntentContext{Identity: "alice", Workspace: "/repo/gait", RiskClass: "high"}, + Script: &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + { + ToolName: "tool.read", + Args: map[string]any{"path": "/tmp/in.txt"}, + }, + }, + }, + } + hash, err := ScriptHash(intent) + if err != nil { + t.Fatalf("script hash: %v", err) + } + if len(hash) != 64 { + t.Fatalf("expected 64-char script hash, got %q", hash) + } + + if _, err := ScriptHash(baseIntent()); err == nil { + t.Fatalf("expected script hash to fail when script payload is missing") + } +} + func TestNormalizeIntentValidationErrors(t *testing.T) { tests := []struct { name string diff --git a/core/gate/policy.go b/core/gate/policy.go index 80b1b5d..f9148b1 100644 --- a/core/gate/policy.go +++ b/core/gate/policy.go @@ -23,6 +23,11 @@ const ( gateSchemaID = "gait.gate.result" gateSchemaV1 = "1.0.0" maxInt64Uint64 = ^uint64(0) >> 1 + + wrkrContextToolNameKey = "wrkr.tool_name" + wrkrContextDataClassKey = "wrkr.data_class" + wrkrContextEndpointClassKey = "wrkr.endpoint_class" + wrkrContextAutonomyLevelKey = "wrkr.autonomy_level" ) var ( @@ -58,10 +63,17 @@ type Policy struct { SchemaID string `yaml:"schema_id"` SchemaVersion string `yaml:"schema_version"` DefaultVerdict string `yaml:"default_verdict"` + Scripts ScriptPolicy `yaml:"scripts"` FailClosed FailClosedPolicy `yaml:"fail_closed"` Rules []PolicyRule `yaml:"rules"` } +type ScriptPolicy struct { + MaxSteps int `yaml:"max_steps"` + RequireApprovalAbove int `yaml:"require_approval_above"` + BlockMixedRisk bool `yaml:"block_mixed_risk"` +} + type FailClosedPolicy struct { Enabled bool `yaml:"enabled"` RiskClasses []string `yaml:"risk_classes"` @@ -133,6 +145,10 @@ type PolicyMatch struct { ProvenanceSources []string `yaml:"provenance_sources"` Identities []string `yaml:"identities"` WorkspacePrefixes []string `yaml:"workspace_prefixes"` + ContextToolNames []string `yaml:"context_tool_names"` + ContextDataClasses []string `yaml:"context_data_classes"` + ContextEndpointClasses []string `yaml:"context_endpoint_classes"` + ContextAutonomyLevels []string `yaml:"context_autonomy_levels"` RequireDelegation bool `yaml:"require_delegation"` AllowedDelegatorIdentities []string `yaml:"allowed_delegator_identities"` AllowedDelegateIdentities []string `yaml:"allowed_delegate_identities"` @@ -142,6 +158,8 @@ type PolicyMatch struct { type EvalOptions struct { ProducerVersion string + WrkrInventory map[string]WrkrToolMetadata + WrkrSource string } type EvalOutcome struct { @@ -155,6 +173,15 @@ type EvalOutcome struct { BrokerScopes []string RateLimit RateLimitPolicy DataflowTriggered bool + Script bool + StepCount int + ScriptHash string + CompositeRiskClass string + StepVerdicts []schemagate.TraceStepVerdict + ContextSource string + PreApproved bool + PatternID string + RegistryReason string } func LoadPolicyFile(path string) (Policy, error) { @@ -246,8 +273,24 @@ func EvaluatePolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts } } - for _, rule := range normalizedPolicy.Rules { - if !ruleMatches(rule.Match, normalizedIntent) { + if normalizedIntent.Script != nil { + return evaluateScriptPolicyDetailed(normalizedPolicy, normalizedIntent, opts) + } + enrichedIntent := normalizedIntent + contextApplied := ApplyWrkrContext(&enrichedIntent, enrichedIntent.ToolName, opts.WrkrInventory) + outcome, err := evaluateSingleIntent(normalizedPolicy, enrichedIntent, opts) + if err != nil { + return EvalOutcome{}, err + } + if contextApplied { + outcome.ContextSource = resolveWrkrSource(opts.WrkrSource) + } + return outcome, nil +} + +func evaluateSingleIntent(policy Policy, intent schemagate.IntentRequest, opts EvalOptions) (EvalOutcome, error) { + for _, rule := range policy.Rules { + if !ruleMatches(rule.Match, intent) { continue } effect := rule.Effect @@ -256,19 +299,19 @@ func EvaluatePolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts if len(reasons) == 0 { reasons = []string{"matched_rule_" + sanitizeName(rule.Name)} } - dataflowTriggered, dataflowEffect, dataflowReasons, dataflowViolations := evaluateDataflowConstraint(rule.Dataflow, normalizedIntent) + dataflowTriggered, dataflowEffect, dataflowReasons, dataflowViolations := evaluateDataflowConstraint(rule.Dataflow, intent) if dataflowTriggered { effect = dataflowEffect reasons = mergeUniqueSorted(reasons, dataflowReasons) violations = mergeUniqueSorted(violations, dataflowViolations) } - endpointTriggered, endpointEffect, endpointReasons, endpointViolations := evaluateEndpointConstraint(rule.Endpoint, normalizedIntent) + endpointTriggered, endpointEffect, endpointReasons, endpointViolations := evaluateEndpointConstraint(rule.Endpoint, intent) if endpointTriggered { effect = mostRestrictiveVerdict(effect, endpointEffect) reasons = mergeUniqueSorted(reasons, endpointReasons) violations = mergeUniqueSorted(violations, endpointViolations) } - contextTriggered, contextEffect, contextReasons, contextViolations := evaluateContextConstraint(rule, normalizedIntent) + contextTriggered, contextEffect, contextReasons, contextViolations := evaluateContextConstraint(rule, intent) if contextTriggered { effect = mostRestrictiveVerdict(effect, contextEffect) reasons = mergeUniqueSorted(reasons, contextReasons) @@ -279,7 +322,7 @@ func EvaluatePolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts minApprovals = 1 } return EvalOutcome{ - Result: buildGateResult(normalizedPolicy, normalizedIntent, opts, effect, reasons, violations), + Result: buildGateResult(policy, intent, opts, effect, reasons, violations), MatchedRule: rule.Name, MinApprovals: minApprovals, RequireDistinctApprovers: rule.RequireDistinctApprovers, @@ -293,22 +336,210 @@ func EvaluatePolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts } minApprovals := 0 - if normalizedPolicy.DefaultVerdict == "require_approval" { + if policy.DefaultVerdict == "require_approval" { minApprovals = 1 } return EvalOutcome{ Result: buildGateResult( - normalizedPolicy, - normalizedIntent, + policy, + intent, opts, - normalizedPolicy.DefaultVerdict, - []string{"default_" + normalizedPolicy.DefaultVerdict}, + policy.DefaultVerdict, + []string{"default_" + policy.DefaultVerdict}, []string{}, ), MinApprovals: minApprovals, }, nil } +func evaluateScriptPolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts EvalOptions) (EvalOutcome, error) { + if intent.Script == nil || len(intent.Script.Steps) == 0 { + return EvalOutcome{}, fmt.Errorf("script intent requires at least one step") + } + stepVerdicts := make([]schemagate.TraceStepVerdict, 0, len(intent.Script.Steps)) + reasons := []string{} + violations := []string{} + matchedRules := []string{} + verdict := "allow" + minApprovals := 0 + requireDistinctApprovers := false + requireBrokerCredential := false + requireDelegation := false + brokerScopes := []string{} + brokerReference := "" + dataflowTriggered := false + riskClasses := []string{} + aggregatedRateLimit := RateLimitPolicy{} + contextSource := "" + + for index, step := range intent.Script.Steps { + stepIntent := intent + stepIntent.Script = nil + stepIntent.ScriptHash = "" + stepIntent.ToolName = step.ToolName + stepIntent.Args = step.Args + stepIntent.Targets = step.Targets + stepIntent.ArgProvenance = step.ArgProvenance + contextApplied := ApplyWrkrContext(&stepIntent, step.ToolName, opts.WrkrInventory) + + stepOutcome, err := evaluateSingleIntent(policy, stepIntent, opts) + if err != nil { + return EvalOutcome{}, err + } + stepVerdicts = append(stepVerdicts, schemagate.TraceStepVerdict{ + Index: index, + ToolName: step.ToolName, + Verdict: stepOutcome.Result.Verdict, + ReasonCodes: mergeUniqueSorted(nil, stepOutcome.Result.ReasonCodes), + Violations: mergeUniqueSorted(nil, stepOutcome.Result.Violations), + MatchedRule: stepOutcome.MatchedRule, + }) + + verdict = mostRestrictiveVerdict(verdict, stepOutcome.Result.Verdict) + reasons = mergeUniqueSorted(reasons, stepOutcome.Result.ReasonCodes) + violations = mergeUniqueSorted(violations, stepOutcome.Result.Violations) + if stepOutcome.MatchedRule != "" { + matchedRules = append(matchedRules, stepOutcome.MatchedRule) + } + if stepOutcome.MinApprovals > minApprovals { + minApprovals = stepOutcome.MinApprovals + } + if stepOutcome.RequireDistinctApprovers { + requireDistinctApprovers = true + } + if stepOutcome.RequireBrokerCredential { + requireBrokerCredential = true + } + if stepOutcome.RequireDelegation { + requireDelegation = true + } + if brokerReference == "" { + brokerReference = stepOutcome.BrokerReference + } + brokerScopes = mergeUniqueSorted(brokerScopes, stepOutcome.BrokerScopes) + aggregatedRateLimit = mergeRateLimitPolicy(aggregatedRateLimit, stepOutcome.RateLimit) + if stepOutcome.DataflowTriggered { + dataflowTriggered = true + } + riskClasses = mergeUniqueSorted(riskClasses, []string{classifyScriptStepRisk(step.Targets)}) + if contextApplied { + contextSource = resolveWrkrSource(opts.WrkrSource) + } + } + + maxSteps := policy.Scripts.MaxSteps + if maxSteps <= 0 { + maxSteps = maxScriptSteps + } + if len(intent.Script.Steps) > maxSteps { + verdict = "block" + reasons = mergeUniqueSorted(reasons, []string{"script_max_steps_exceeded"}) + violations = mergeUniqueSorted(violations, []string{"script_max_steps_exceeded"}) + } + if policy.Scripts.RequireApprovalAbove > 0 && len(intent.Script.Steps) > policy.Scripts.RequireApprovalAbove { + verdict = mostRestrictiveVerdict(verdict, "require_approval") + reasons = mergeUniqueSorted(reasons, []string{"script_step_threshold_approval"}) + if minApprovals == 0 { + minApprovals = 1 + } + } + if policy.Scripts.BlockMixedRisk && len(riskClasses) > 1 { + verdict = "block" + reasons = mergeUniqueSorted(reasons, []string{"script_mixed_risk_blocked"}) + violations = mergeUniqueSorted(violations, []string{"script_mixed_risk"}) + } + + return EvalOutcome{ + Result: buildGateResult( + policy, + intent, + opts, + verdict, + reasons, + violations, + ), + MatchedRule: strings.Join(uniqueSorted(matchedRules), ","), + MinApprovals: minApprovals, + RequireDistinctApprovers: requireDistinctApprovers, + RequireBrokerCredential: requireBrokerCredential, + RequireDelegation: requireDelegation, + BrokerReference: brokerReference, + BrokerScopes: uniqueSorted(brokerScopes), + RateLimit: aggregatedRateLimit, + DataflowTriggered: dataflowTriggered, + Script: true, + StepCount: len(intent.Script.Steps), + ScriptHash: intent.ScriptHash, + CompositeRiskClass: compositeRiskClass(riskClasses), + StepVerdicts: stepVerdicts, + ContextSource: contextSource, + }, nil +} + +func mergeRateLimitPolicy(current RateLimitPolicy, candidate RateLimitPolicy) RateLimitPolicy { + if candidate.Requests <= 0 { + return current + } + if current.Requests <= 0 { + return candidate + } + if candidate.Requests < current.Requests { + return candidate + } + if candidate.Requests > current.Requests { + return current + } + if normalizedWindowPriority(candidate.Window) < normalizedWindowPriority(current.Window) { + return candidate + } + if normalizedWindowPriority(candidate.Window) > normalizedWindowPriority(current.Window) { + return current + } + if strings.ToLower(strings.TrimSpace(candidate.Scope)) < strings.ToLower(strings.TrimSpace(current.Scope)) { + return candidate + } + return current +} + +func normalizedWindowPriority(window string) int { + switch strings.ToLower(strings.TrimSpace(window)) { + case "minute": + return 0 + case "hour": + return 1 + default: + return 2 + } +} + +func classifyScriptStepRisk(targets []schemagate.IntentTarget) string { + risk := "low" + for _, target := range targets { + switch target.EndpointClass { + case "fs.delete", "proc.exec": + return "high" + case "fs.write", "net.http", "net.dns": + if risk == "low" { + risk = "medium" + } + } + if target.Destructive { + return "high" + } + } + return risk +} + +func compositeRiskClass(riskClasses []string) string { + if contains(riskClasses, "high") { + return "high" + } + if contains(riskClasses, "medium") { + return "medium" + } + return "low" +} + func PolicyDigest(policy Policy) (string, error) { normalized, err := normalizePolicy(policy) if err != nil { @@ -358,6 +589,18 @@ func policyDigestPayload(policy Policy) map[string]any { if len(rule.Match.DestinationOps) > 0 { matchPayload["DestinationOps"] = rule.Match.DestinationOps } + if len(rule.Match.ContextToolNames) > 0 { + matchPayload["ContextToolNames"] = rule.Match.ContextToolNames + } + if len(rule.Match.ContextDataClasses) > 0 { + matchPayload["ContextDataClasses"] = rule.Match.ContextDataClasses + } + if len(rule.Match.ContextEndpointClasses) > 0 { + matchPayload["ContextEndpointClasses"] = rule.Match.ContextEndpointClasses + } + if len(rule.Match.ContextAutonomyLevels) > 0 { + matchPayload["ContextAutonomyLevels"] = rule.Match.ContextAutonomyLevels + } if rule.Match.RequireDelegation { matchPayload["RequireDelegation"] = true } @@ -450,7 +693,7 @@ func policyDigestPayload(policy Policy) map[string]any { rules = append(rules, rulePayload) } - return map[string]any{ + payload := map[string]any{ "SchemaID": policy.SchemaID, "SchemaVersion": policy.SchemaVersion, "DefaultVerdict": policy.DefaultVerdict, @@ -461,6 +704,14 @@ func policyDigestPayload(policy Policy) map[string]any { }, "Rules": rules, } + if policy.Scripts.MaxSteps > 0 || policy.Scripts.RequireApprovalAbove > 0 || policy.Scripts.BlockMixedRisk { + payload["Scripts"] = map[string]any{ + "MaxSteps": policy.Scripts.MaxSteps, + "RequireApprovalAbove": policy.Scripts.RequireApprovalAbove, + "BlockMixedRisk": policy.Scripts.BlockMixedRisk, + } + } + return payload } func isHighRiskActionRule(rule PolicyRule) bool { @@ -508,6 +759,12 @@ func normalizePolicy(input Policy) (Policy, error) { return Policy{}, fmt.Errorf("unsupported fail_closed required_field: %s", field) } } + if output.Scripts.MaxSteps < 0 { + return Policy{}, fmt.Errorf("scripts.max_steps must be >= 0") + } + if output.Scripts.RequireApprovalAbove < 0 { + return Policy{}, fmt.Errorf("scripts.require_approval_above must be >= 0") + } output.Rules = append([]PolicyRule(nil), output.Rules...) for index := range output.Rules { @@ -544,6 +801,10 @@ func normalizePolicy(input Policy) (Policy, error) { rule.Match.ProvenanceSources = normalizeStringListLower(rule.Match.ProvenanceSources) rule.Match.Identities = normalizeStringList(rule.Match.Identities) rule.Match.WorkspacePrefixes = normalizeStringList(rule.Match.WorkspacePrefixes) + rule.Match.ContextToolNames = normalizeStringListLower(rule.Match.ContextToolNames) + rule.Match.ContextDataClasses = normalizeStringListLower(rule.Match.ContextDataClasses) + rule.Match.ContextEndpointClasses = normalizeStringListLower(rule.Match.ContextEndpointClasses) + rule.Match.ContextAutonomyLevels = normalizeStringListLower(rule.Match.ContextAutonomyLevels) rule.Match.AllowedDelegatorIdentities = normalizeStringList(rule.Match.AllowedDelegatorIdentities) rule.Match.AllowedDelegateIdentities = normalizeStringList(rule.Match.AllowedDelegateIdentities) rule.Match.DelegationScopes = normalizeStringListLower(rule.Match.DelegationScopes) @@ -694,6 +955,30 @@ func ruleMatches(match PolicyMatch, intent schemagate.IntentRequest) bool { return false } } + if len(match.ContextToolNames) > 0 { + value := contextString(intent.Context.AuthContext, wrkrContextToolNameKey) + if value == "" || !contains(match.ContextToolNames, value) { + return false + } + } + if len(match.ContextDataClasses) > 0 { + value := contextString(intent.Context.AuthContext, wrkrContextDataClassKey) + if value == "" || !contains(match.ContextDataClasses, value) { + return false + } + } + if len(match.ContextEndpointClasses) > 0 { + value := contextString(intent.Context.AuthContext, wrkrContextEndpointClassKey) + if value == "" || !contains(match.ContextEndpointClasses, value) { + return false + } + } + if len(match.ContextAutonomyLevels) > 0 { + value := contextString(intent.Context.AuthContext, wrkrContextAutonomyLevelKey) + if value == "" || !contains(match.ContextAutonomyLevels, value) { + return false + } + } if len(match.TargetKinds) > 0 { targetKindMatched := false for _, target := range intent.Targets { @@ -1317,6 +1602,29 @@ func contains(values []string, wanted string) bool { return false } +func contextString(values map[string]any, key string) string { + if len(values) == 0 { + return "" + } + raw, ok := values[key] + if !ok { + return "" + } + text, ok := raw.(string) + if !ok { + return "" + } + return strings.ToLower(strings.TrimSpace(text)) +} + +func resolveWrkrSource(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "wrkr_inventory" + } + return trimmed +} + func sanitizeName(value string) string { if value == "" { return "rule" diff --git a/core/gate/policy_test.go b/core/gate/policy_test.go index 3a816d5..33fa1ff 100644 --- a/core/gate/policy_test.go +++ b/core/gate/policy_test.go @@ -1297,6 +1297,151 @@ func TestEvaluateFailClosedRequiredFieldsContextEvidence(t *testing.T) { } } +func TestEvaluateScriptIntentRollup(t *testing.T) { + policy, err := ParsePolicyYAML([]byte(` +default_verdict: allow +scripts: + max_steps: 8 + require_approval_above: 1 +rules: + - name: allow-read + effect: allow + match: + tool_names: [tool.read] + - name: approval-write + effect: require_approval + min_approvals: 2 + match: + tool_names: [tool.write] +`)) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + intent := baseIntent() + intent.ToolName = "script" + intent.Script = &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + { + ToolName: "tool.read", + Args: map[string]any{"path": "/tmp/in.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/in.txt", Operation: "read"}, + }, + }, + { + ToolName: "tool.write", + Args: map[string]any{"path": "/tmp/out.txt"}, + Targets: []schemagate.IntentTarget{ + {Kind: "path", Value: "/tmp/out.txt", Operation: "write"}, + }, + }, + }, + } + outcome, err := EvaluatePolicyDetailed(policy, intent, EvalOptions{ProducerVersion: "test"}) + if err != nil { + t.Fatalf("evaluate script policy: %v", err) + } + if outcome.Result.Verdict != "require_approval" { + t.Fatalf("unexpected script verdict: %#v", outcome.Result) + } + if !outcome.Script || outcome.StepCount != 2 { + t.Fatalf("expected script metadata in outcome: %#v", outcome) + } + if outcome.ScriptHash == "" { + t.Fatalf("expected script hash in outcome") + } + if outcome.MinApprovals != 2 { + t.Fatalf("expected max min_approvals rollup=2, got %d", outcome.MinApprovals) + } + if len(outcome.StepVerdicts) != 2 { + t.Fatalf("expected per-step verdicts, got %#v", outcome.StepVerdicts) + } + if outcome.StepVerdicts[0].ToolName != "tool.read" || outcome.StepVerdicts[1].ToolName != "tool.write" { + t.Fatalf("unexpected step verdict ordering: %#v", outcome.StepVerdicts) + } +} + +func TestRuleMatchesWrkrContextFields(t *testing.T) { + policy, err := ParsePolicyYAML([]byte(` +default_verdict: allow +rules: + - name: block-wrkr-data-class + effect: block + match: + tool_names: [tool.read] + context_tool_names: [tool.read] + context_data_classes: [pii] +`)) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + intent := baseIntent() + intent.ToolName = "tool.read" + intent.Context.AuthContext = map[string]any{ + "wrkr.tool_name": "tool.read", + "wrkr.data_class": "pii", + } + outcome, err := EvaluatePolicyDetailed(policy, intent, EvalOptions{ProducerVersion: "test"}) + if err != nil { + t.Fatalf("evaluate policy: %v", err) + } + if outcome.Result.Verdict != "block" { + t.Fatalf("expected block verdict from wrkr context match, got %#v", outcome.Result) + } +} + +func TestMergeRateLimitPolicy(t *testing.T) { + if merged := mergeRateLimitPolicy(RateLimitPolicy{Requests: 10, Window: "hour", Scope: "identity"}, RateLimitPolicy{}); merged.Requests != 10 { + t.Fatalf("expected empty candidate to keep current policy, got %#v", merged) + } + if merged := mergeRateLimitPolicy(RateLimitPolicy{}, RateLimitPolicy{Requests: 5, Window: "hour", Scope: "identity"}); merged.Requests != 5 { + t.Fatalf("expected empty current policy to adopt candidate, got %#v", merged) + } + if merged := mergeRateLimitPolicy( + RateLimitPolicy{Requests: 10, Window: "hour", Scope: "workspace"}, + RateLimitPolicy{Requests: 5, Window: "hour", Scope: "identity"}, + ); merged.Requests != 5 { + t.Fatalf("expected lower request budget to win, got %#v", merged) + } + if merged := mergeRateLimitPolicy( + RateLimitPolicy{Requests: 5, Window: "hour", Scope: "identity"}, + RateLimitPolicy{Requests: 10, Window: "minute", Scope: "global"}, + ); merged.Requests != 5 || merged.Window != "hour" { + t.Fatalf("expected stricter current request budget to remain, got %#v", merged) + } + if merged := mergeRateLimitPolicy( + RateLimitPolicy{Requests: 5, Window: "hour", Scope: "workspace"}, + RateLimitPolicy{Requests: 5, Window: "minute", Scope: "workspace"}, + ); merged.Window != "minute" { + t.Fatalf("expected tighter minute window to win ties, got %#v", merged) + } + if merged := mergeRateLimitPolicy( + RateLimitPolicy{Requests: 5, Window: "minute", Scope: "workspace"}, + RateLimitPolicy{Requests: 5, Window: "minute", Scope: "global"}, + ); merged.Scope != "global" { + t.Fatalf("expected lexicographically smaller scope to break ties, got %#v", merged) + } +} + +func TestWindowPriorityAndWrkrSourceHelpers(t *testing.T) { + if got := normalizedWindowPriority("minute"); got != 0 { + t.Fatalf("expected minute priority 0, got %d", got) + } + if got := normalizedWindowPriority(" hour "); got != 1 { + t.Fatalf("expected hour priority 1, got %d", got) + } + if got := normalizedWindowPriority("day"); got != 2 { + t.Fatalf("expected unknown window priority 2, got %d", got) + } + + if source := resolveWrkrSource(""); source != "wrkr_inventory" { + t.Fatalf("expected default wrkr source, got %q", source) + } + if source := resolveWrkrSource(" wrkr_api "); source != "wrkr_api" { + t.Fatalf("expected trimmed wrkr source, got %q", source) + } +} + func baseIntent() schemagate.IntentRequest { return schemagate.IntentRequest{ SchemaID: "gait.gate.intent_request", diff --git a/core/gate/trace.go b/core/gate/trace.go index f9c9d57..e804e80 100644 --- a/core/gate/trace.go +++ b/core/gate/trace.go @@ -24,6 +24,12 @@ type EmitTraceOptions struct { DelegationTokenRef string DelegationReasonCodes []string LatencyMS float64 + ContextSource string + CompositeRiskClass string + StepVerdicts []schemagate.TraceStepVerdict + PreApproved bool + PatternID string + RegistryReason string SigningPrivateKey ed25519.PrivateKey TracePath string } @@ -87,11 +93,22 @@ func EmitSignedTrace(policy Policy, intent schemagate.IntentRequest, gateResult ContextSetDigest: normalizedIntent.Context.ContextSetDigest, ContextEvidenceMode: normalizedIntent.Context.ContextEvidenceMode, ContextRefCount: len(normalizedIntent.Context.ContextRefs), + ContextSource: strings.TrimSpace(opts.ContextSource), + Script: normalizedIntent.Script != nil, + ScriptHash: normalizedIntent.ScriptHash, + CompositeRiskClass: strings.ToLower(strings.TrimSpace(opts.CompositeRiskClass)), + StepVerdicts: append([]schemagate.TraceStepVerdict(nil), opts.StepVerdicts...), + PreApproved: opts.PreApproved, + PatternID: strings.TrimSpace(opts.PatternID), + RegistryReason: strings.TrimSpace(opts.RegistryReason), Violations: uniqueSorted(gateResult.Violations), LatencyMS: clampLatency(opts.LatencyMS), ApprovalTokenRef: strings.TrimSpace(opts.ApprovalTokenRef), SkillProvenance: normalizedIntent.SkillProvenance, } + if normalizedIntent.Script != nil { + trace.StepCount = len(normalizedIntent.Script.Steps) + } trace.EventID = computeTraceEventID(trace.TraceID, trace.ObservedAt) if normalizedIntent.Delegation != nil { delegationTokenRef := strings.TrimSpace(opts.DelegationTokenRef) diff --git a/core/gate/trace_test.go b/core/gate/trace_test.go index 0578d90..38ab5a0 100644 --- a/core/gate/trace_test.go +++ b/core/gate/trace_test.go @@ -92,6 +92,60 @@ rules: } } +func TestEmitSignedTraceIncludesScriptMetadata(t *testing.T) { + keyPair, err := sign.GenerateKeyPair() + if err != nil { + t.Fatalf("generate key pair: %v", err) + } + policy, err := ParsePolicyYAML([]byte(`default_verdict: allow`)) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + intent := baseIntent() + intent.ToolName = "script" + intent.Script = &schemagate.IntentScript{ + Steps: []schemagate.IntentScriptStep{ + {ToolName: "tool.read", Args: map[string]any{"path": "/tmp/in.txt"}}, + {ToolName: "tool.write", Args: map[string]any{"path": "/tmp/out.txt"}}, + }, + } + result := schemagate.GateResult{ + SchemaID: "gait.gate.result", + SchemaVersion: "1.0.0", + CreatedAt: time.Date(2026, time.February, 5, 0, 0, 0, 0, time.UTC), + ProducerVersion: "test", + Verdict: "allow", + ReasonCodes: []string{"approved_script_match"}, + Violations: []string{}, + } + emitted, err := EmitSignedTrace(policy, intent, result, EmitTraceOptions{ + ProducerVersion: "test", + SigningPrivateKey: keyPair.Private, + TracePath: filepath.Join(t.TempDir(), "trace_script.json"), + ContextSource: "wrkr_inventory", + CompositeRiskClass: "medium", + StepVerdicts: []schemagate.TraceStepVerdict{ + {Index: 0, ToolName: "tool.read", Verdict: "allow"}, + {Index: 1, ToolName: "tool.write", Verdict: "allow"}, + }, + PreApproved: true, + PatternID: "pattern_demo", + RegistryReason: "approved_script_match", + }) + if err != nil { + t.Fatalf("emit script trace: %v", err) + } + if !emitted.Trace.Script || emitted.Trace.StepCount != 2 { + t.Fatalf("expected script metadata in trace: %#v", emitted.Trace) + } + if emitted.Trace.ContextSource != "wrkr_inventory" || emitted.Trace.PatternID != "pattern_demo" || !emitted.Trace.PreApproved { + t.Fatalf("unexpected trace metadata: %#v", emitted.Trace) + } + if emitted.Trace.ScriptHash == "" { + t.Fatalf("expected script hash in trace output") + } +} + func TestVerifyTraceRecordTamperDetection(t *testing.T) { keyPair, err := sign.GenerateKeyPair() if err != nil { diff --git a/core/mcp/interfaces.go b/core/mcp/interfaces.go index b7fcbac..c22fe6f 100644 --- a/core/mcp/interfaces.go +++ b/core/mcp/interfaces.go @@ -8,6 +8,7 @@ import ( type ToolCall struct { Name string `json:"name"` Args map[string]any `json:"args,omitempty"` + Script *ScriptCall `json:"script,omitempty"` Target string `json:"target,omitempty"` Targets []Target `json:"targets,omitempty"` ArgProvenance []ArgProvenance `json:"arg_provenance,omitempty"` @@ -16,6 +17,17 @@ type ToolCall struct { CreatedAt time.Time `json:"created_at,omitempty"` } +type ScriptCall struct { + Steps []ScriptStep `json:"steps"` +} + +type ScriptStep struct { + Name string `json:"name"` + Args map[string]any `json:"args,omitempty"` + Targets []Target `json:"targets,omitempty"` + ArgProvenance []ArgProvenance `json:"arg_provenance,omitempty"` +} + type Target struct { Kind string `json:"kind"` Value string `json:"value"` diff --git a/core/mcp/proxy.go b/core/mcp/proxy.go index 29fb58e..8a8e30a 100644 --- a/core/mcp/proxy.go +++ b/core/mcp/proxy.go @@ -56,7 +56,8 @@ func ToIntentRequest(call ToolCall) (schemagate.IntentRequest, error) { func ToIntentRequestWithOptions(call ToolCall, opts IntentOptions) (schemagate.IntentRequest, error) { name := strings.TrimSpace(call.Name) - if name == "" { + hasScript := call.Script != nil && len(call.Script.Steps) > 0 + if name == "" && !hasScript { return schemagate.IntentRequest{}, fmt.Errorf("tool call name is required") } @@ -89,6 +90,47 @@ func ToIntentRequestWithOptions(call ToolCall, opts IntentOptions) (schemagate.I if args == nil { args = map[string]any{} } + var script *schemagate.IntentScript + if hasScript { + steps := make([]schemagate.IntentScriptStep, 0, len(call.Script.Steps)) + for index, step := range call.Script.Steps { + stepName := strings.TrimSpace(step.Name) + if stepName == "" { + return schemagate.IntentRequest{}, fmt.Errorf("script.steps[%d].name is required", index) + } + stepTargets := make([]schemagate.IntentTarget, 0, len(step.Targets)) + for _, target := range step.Targets { + stepTargets = append(stepTargets, schemagate.IntentTarget{ + Kind: strings.TrimSpace(target.Kind), + Value: strings.TrimSpace(target.Value), + Operation: strings.TrimSpace(target.Operation), + Sensitivity: strings.TrimSpace(target.Sensitivity), + }) + } + stepProvenance := make([]schemagate.IntentArgProvenance, 0, len(step.ArgProvenance)) + for _, entry := range step.ArgProvenance { + stepProvenance = append(stepProvenance, schemagate.IntentArgProvenance{ + ArgPath: strings.TrimSpace(entry.ArgPath), + Source: strings.TrimSpace(entry.Source), + SourceRef: strings.TrimSpace(entry.SourceRef), + IntegrityDigest: strings.TrimSpace(entry.IntegrityDigest), + }) + } + stepArgs := step.Args + if stepArgs == nil { + stepArgs = map[string]any{} + } + steps = append(steps, schemagate.IntentScriptStep{ + ToolName: stepName, + Args: stepArgs, + Targets: stepTargets, + ArgProvenance: stepProvenance, + }) + } + script = &schemagate.IntentScript{Steps: steps} + name = "script" + args = map[string]any{} + } createdAt := call.CreatedAt.UTC() if createdAt.IsZero() { @@ -151,6 +193,7 @@ func ToIntentRequestWithOptions(call ToolCall, opts IntentOptions) (schemagate.I ProducerVersion: "0.0.0-dev", ToolName: name, Args: args, + Script: script, Targets: intentTargets, ArgProvenance: provenance, Delegation: delegation, diff --git a/core/mcp/proxy_test.go b/core/mcp/proxy_test.go index 17d278f..63cbae3 100644 --- a/core/mcp/proxy_test.go +++ b/core/mcp/proxy_test.go @@ -320,6 +320,50 @@ func TestToIntentRequestWrapper(t *testing.T) { } } +func TestToIntentRequestScriptPayload(t *testing.T) { + intent, err := ToIntentRequest(ToolCall{ + Script: &ScriptCall{ + Steps: []ScriptStep{ + { + Name: "tool.read", + Args: map[string]any{"path": "/tmp/input.txt"}, + Targets: []Target{{ + Kind: "path", + Value: "/tmp/input.txt", + Operation: "read", + }}, + }, + { + Name: "tool.write", + Args: map[string]any{"path": "/tmp/output.txt"}, + Targets: []Target{{ + Kind: "path", + Value: "/tmp/output.txt", + Operation: "write", + }}, + }, + }, + }, + Context: CallContext{ + Identity: "alice", + Workspace: "/repo/gait", + RiskClass: "high", + }, + }) + if err != nil { + t.Fatalf("ToIntentRequest script payload failed: %v", err) + } + if intent.Script == nil || len(intent.Script.Steps) != 2 { + t.Fatalf("expected script steps in intent conversion: %#v", intent.Script) + } + if intent.ToolName != "script" { + t.Fatalf("expected script tool name for script payload, got %q", intent.ToolName) + } + if intent.Script.Steps[0].ToolName != "tool.read" || intent.Script.Steps[1].ToolName != "tool.write" { + t.Fatalf("unexpected converted script steps: %#v", intent.Script.Steps) + } +} + func TestExportersWriteJSONL(t *testing.T) { workDir := t.TempDir() logPath := filepath.Join(workDir, "mcp.log.jsonl") diff --git a/core/projectconfig/config.go b/core/projectconfig/config.go index 046d07c..376995f 100644 --- a/core/projectconfig/config.go +++ b/core/projectconfig/config.go @@ -35,6 +35,7 @@ type GateDefaults struct { CredentialCommandArgs string `yaml:"credential_command_args"` CredentialEvidencePath string `yaml:"credential_evidence_path"` TracePath string `yaml:"trace_path"` + WrkrInventoryPath string `yaml:"wrkr_inventory_path"` } type MCPServeDefaults struct { @@ -98,6 +99,7 @@ func (configuration *Config) normalize() { configuration.Gate.CredentialCommandArgs = strings.TrimSpace(configuration.Gate.CredentialCommandArgs) configuration.Gate.CredentialEvidencePath = strings.TrimSpace(configuration.Gate.CredentialEvidencePath) configuration.Gate.TracePath = strings.TrimSpace(configuration.Gate.TracePath) + configuration.Gate.WrkrInventoryPath = strings.TrimSpace(configuration.Gate.WrkrInventoryPath) configuration.MCPServe.Listen = strings.TrimSpace(configuration.MCPServe.Listen) configuration.MCPServe.AuthMode = strings.ToLower(strings.TrimSpace(configuration.MCPServe.AuthMode)) configuration.MCPServe.AuthTokenEnv = strings.TrimSpace(configuration.MCPServe.AuthTokenEnv) diff --git a/core/projectconfig/config_test.go b/core/projectconfig/config_test.go index 0454884..3cc3a1f 100644 --- a/core/projectconfig/config_test.go +++ b/core/projectconfig/config_test.go @@ -38,6 +38,7 @@ gate: key_mode: " prod " private_key: " examples/scenarios/keys/approval_private.key " credential_broker: " stub " + wrkr_inventory_path: " ./.gait/wrkr_inventory.json " mcp_serve: enabled: true listen: " 0.0.0.0:8787 " @@ -70,6 +71,9 @@ retention: if configuration.Gate.CredentialBroker != "stub" { t.Fatalf("unexpected credential_broker %q", configuration.Gate.CredentialBroker) } + if configuration.Gate.WrkrInventoryPath != "./.gait/wrkr_inventory.json" { + t.Fatalf("unexpected wrkr_inventory_path %q", configuration.Gate.WrkrInventoryPath) + } if !configuration.MCPServe.Enabled { t.Fatalf("expected mcp_serve enabled=true") } diff --git a/core/schema/v1/gate/types.go b/core/schema/v1/gate/types.go index 6d96ace..2989031 100644 --- a/core/schema/v1/gate/types.go +++ b/core/schema/v1/gate/types.go @@ -3,28 +3,46 @@ package gate import "time" type TraceRecord struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ObservedAt time.Time `json:"observed_at,omitempty"` - ProducerVersion string `json:"producer_version"` - TraceID string `json:"trace_id"` - EventID string `json:"event_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - ToolName string `json:"tool_name"` - ArgsDigest string `json:"args_digest"` - IntentDigest string `json:"intent_digest"` - PolicyDigest string `json:"policy_digest"` - Verdict string `json:"verdict"` - ContextSetDigest string `json:"context_set_digest,omitempty"` - ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` - ContextRefCount int `json:"context_ref_count,omitempty"` - Violations []string `json:"violations,omitempty"` - LatencyMS float64 `json:"latency_ms,omitempty"` - ApprovalTokenRef string `json:"approval_token_ref,omitempty"` - DelegationRef *DelegationRef `json:"delegation_ref,omitempty"` - SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` - Signature *Signature `json:"signature,omitempty"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ObservedAt time.Time `json:"observed_at,omitempty"` + ProducerVersion string `json:"producer_version"` + TraceID string `json:"trace_id"` + EventID string `json:"event_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + ToolName string `json:"tool_name"` + ArgsDigest string `json:"args_digest"` + IntentDigest string `json:"intent_digest"` + PolicyDigest string `json:"policy_digest"` + Verdict string `json:"verdict"` + ContextSetDigest string `json:"context_set_digest,omitempty"` + ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` + ContextRefCount int `json:"context_ref_count,omitempty"` + ContextSource string `json:"context_source,omitempty"` + Script bool `json:"script,omitempty"` + StepCount int `json:"step_count,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + CompositeRiskClass string `json:"composite_risk_class,omitempty"` + StepVerdicts []TraceStepVerdict `json:"step_verdicts,omitempty"` + PreApproved bool `json:"pre_approved,omitempty"` + PatternID string `json:"pattern_id,omitempty"` + RegistryReason string `json:"registry_reason,omitempty"` + Violations []string `json:"violations,omitempty"` + LatencyMS float64 `json:"latency_ms,omitempty"` + ApprovalTokenRef string `json:"approval_token_ref,omitempty"` + DelegationRef *DelegationRef `json:"delegation_ref,omitempty"` + SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` + Signature *Signature `json:"signature,omitempty"` +} + +type TraceStepVerdict struct { + Index int `json:"index"` + ToolName string `json:"tool_name"` + Verdict string `json:"verdict"` + ReasonCodes []string `json:"reason_codes,omitempty"` + Violations []string `json:"violations,omitempty"` + MatchedRule string `json:"matched_rule,omitempty"` } type Signature struct { @@ -43,6 +61,8 @@ type IntentRequest struct { Args map[string]any `json:"args"` ArgsDigest string `json:"args_digest,omitempty"` IntentDigest string `json:"intent_digest,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + Script *IntentScript `json:"script,omitempty"` Targets []IntentTarget `json:"targets"` ArgProvenance []IntentArgProvenance `json:"arg_provenance,omitempty"` SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` @@ -50,6 +70,17 @@ type IntentRequest struct { Context IntentContext `json:"context"` } +type IntentScript struct { + Steps []IntentScriptStep `json:"steps"` +} + +type IntentScriptStep struct { + ToolName string `json:"tool_name"` + Args map[string]any `json:"args"` + Targets []IntentTarget `json:"targets,omitempty"` + ArgProvenance []IntentArgProvenance `json:"arg_provenance,omitempty"` +} + type IntentTarget struct { Kind string `json:"kind"` Value string `json:"value"` @@ -227,3 +258,18 @@ type BrokerCredentialRecord struct { ExpiresAt time.Time `json:"expires_at,omitempty"` TTLSeconds int64 `json:"ttl_seconds,omitempty"` } + +type ApprovedScriptEntry struct { + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + PatternID string `json:"pattern_id"` + PolicyDigest string `json:"policy_digest"` + ScriptHash string `json:"script_hash"` + ToolSequence []string `json:"tool_sequence"` + Scope []string `json:"scope,omitempty"` + ApproverIdentity string `json:"approver_identity"` + ExpiresAt time.Time `json:"expires_at"` + Signature *Signature `json:"signature,omitempty"` +} diff --git a/core/scout/scout_test.go b/core/scout/scout_test.go index 34f764c..eadd23e 100644 --- a/core/scout/scout_test.go +++ b/core/scout/scout_test.go @@ -245,3 +245,28 @@ func TestSnapshotDiffAddedAndRemoved(t *testing.T) { t.Fatalf("unexpected diff added/removed entries: %#v", diff) } } + +func TestSortInventoryItemsDeterministicOrdering(t *testing.T) { + items := []schemascout.InventoryItem{ + {ID: "b", Kind: "tool", Name: "beta", Locator: "z.py"}, + {ID: "a", Kind: "tool", Name: "zeta", Locator: "b.py"}, + {ID: "a", Kind: "adapter", Name: "zeta", Locator: "a.py"}, + {ID: "a", Kind: "adapter", Name: "alpha", Locator: "c.py"}, + } + sortInventoryItems(items) + + expected := []schemascout.InventoryItem{ + {ID: "a", Kind: "adapter", Name: "alpha", Locator: "c.py"}, + {ID: "a", Kind: "adapter", Name: "zeta", Locator: "a.py"}, + {ID: "a", Kind: "tool", Name: "zeta", Locator: "b.py"}, + {ID: "b", Kind: "tool", Name: "beta", Locator: "z.py"}, + } + for index := range expected { + if items[index].ID != expected[index].ID || + items[index].Kind != expected[index].Kind || + items[index].Name != expected[index].Name || + items[index].Locator != expected[index].Locator { + t.Fatalf("unexpected sorted order at index %d: got=%#v expected=%#v", index, items[index], expected[index]) + } + } +} diff --git a/docs-site/public/llm/contracts.md b/docs-site/public/llm/contracts.md index ea02905..1185ed6 100644 --- a/docs-site/public/llm/contracts.md +++ b/docs-site/public/llm/contracts.md @@ -6,6 +6,7 @@ Stable OSS contracts include: - includes first-class export surfaces: `gait pack export --otel-out ...` and `--postgres-sql-out ...` for observability and metadata indexing. - **ContextSpec v1**: Deterministic context evidence envelopes with privacy-aware modes and fail-closed enforcement. - **Primitive Contract**: Four deterministic primitives — capture, enforce, regress, diagnose. +- **Script Governance Contract**: Script intent steps, deterministic `script_hash`, Wrkr-derived context matching fields, and signed approved-script registry entries. - **Intent+Receipt Spec**: Structured tool-call intent with deterministic receipt generation. - **Endpoint Action Model**: Maps tool-call intent to policy-evaluated action outcomes. - Artifact schemas (`schemas/v1/*`) diff --git a/docs-site/public/llm/faq.md b/docs-site/public/llm/faq.md index e021b6e..9b4d94d 100644 --- a/docs-site/public/llm/faq.md +++ b/docs-site/public/llm/faq.md @@ -40,6 +40,10 @@ Yes. `gait run replay` uses recorded results as deterministic stubs so you can d Gait provides three integration modes: wrapper/sidecar pattern, Python SDK, and MCP server (`gait mcp serve`). The integration checklist covers the path from first demo to production enforcement. +## Can Gait pre-approve known multi-step scripts? + +Yes. Use `gait approve-script` to mint signed registry entries bound to policy digest and script hash, then evaluate with `gait gate eval --approved-script-registry ...`. Invalid or tampered registry state fails closed in high-risk paths. + ## How should teams start? Run `gait tour` for a guided walkthrough, then `gait demo` to create and verify a signed pack. Wire one integration path from the integration checklist to move toward production enforcement. diff --git a/docs-site/public/llm/product.md b/docs-site/public/llm/product.md index bc561df..e4c31e0 100644 --- a/docs-site/public/llm/product.md +++ b/docs-site/public/llm/product.md @@ -6,7 +6,7 @@ It provides seven OSS primitives: 1. **Jobs**: Dispatch multi-step, multi-hour agent work with checkpoints, pause/resume/cancel, approval gates, and deterministic stop reasons. 2. **Packs**: Unified portable artifact envelope (PackSpec v1) for run, job, and call evidence with Ed25519 signatures and SHA-256 manifest. -3. **Gate**: Evaluate structured tool-call intent against YAML policy with fail-closed enforcement. Non-allow outcomes do not execute side effects. +3. **Gate**: Evaluate structured tool-call intent against YAML policy with fail-closed enforcement. Supports multi-step script rollups, Wrkr context enrichment, and signed approved-script fast-path allow. 4. **Regress**: Convert any incident or failed run into a deterministic CI regression fixture with JUnit output and stable exit codes. 5. **Voice**: Gate high-stakes spoken commitments (refunds, quotes, eligibility) before they are uttered. Signed SayToken capability tokens and callpack artifacts for voice boundaries. 6. **Context Evidence**: Deterministic proof of what context the model was working from at decision time. Privacy-aware envelopes with fail-closed enforcement when evidence is missing. diff --git a/docs-site/public/llm/quickstart.md b/docs-site/public/llm/quickstart.md index a7f087c..759a4a3 100644 --- a/docs-site/public/llm/quickstart.md +++ b/docs-site/public/llm/quickstart.md @@ -34,3 +34,11 @@ Then continue with: - production integration checklist: `/docs/integration_checklist/` Use `gait policy test` and `gait gate eval --simulate` before enforce rollout on high-risk tool-call boundaries. + +For script automation boundaries, add: + +```bash +gait approve-script --policy ./policy.yaml --intent ./script_intent.json --registry ./approved_scripts.json --approver secops --json +gait list-scripts --registry ./approved_scripts.json --json +gait gate eval --policy ./policy.yaml --intent ./script_intent.json --approved-script-registry ./approved_scripts.json --json +``` diff --git a/docs-site/public/llm/security.md b/docs-site/public/llm/security.md index 64a4723..1ac467d 100644 --- a/docs-site/public/llm/security.md +++ b/docs-site/public/llm/security.md @@ -5,6 +5,7 @@ - Deterministic and offline verification for all artifact types (runpacks, jobpacks, callpacks). - Ed25519 signatures and SHA-256 manifest integrity in PackSpec v1. - Signed traces and explicit reason codes for blocked actions. +- Approved-script registry entries are signature-verified and policy-digest bound; tampered or missing state fails closed in high-risk enforcement. - SayToken capability tokens for voice agent commitment gating — gated speech cannot execute without a valid token. - Context evidence envelopes with fail-closed enforcement when evidence is missing for high-risk actions. - Durable jobs with deterministic stop reasons and checkpoint integrity. diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index 622cc20..e59c889 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -20,6 +20,9 @@ - gait regress run --json --junit - gait policy test - gait gate eval --policy --intent +- gait gate eval --policy --intent --wrkr-inventory +- gait approve-script --policy --intent --registry --approver +- gait list-scripts --registry - gait job submit --id - gait job status --id - gait job checkpoint add --id --type --summary diff --git a/docs/contracts/contextspec_v1.md b/docs/contracts/contextspec_v1.md index 39168a9..1b5101b 100644 --- a/docs/contracts/contextspec_v1.md +++ b/docs/contracts/contextspec_v1.md @@ -84,6 +84,32 @@ Signed trace records may include: Tampering with context linkage fields MUST fail signature verification. +## Wrkr Context Enrichment Contract + +Wrkr enrichment is an optional local-file context source for gate matching. + +Accepted inventory keys per entry: + +- `tool_name` +- `data_class` +- `endpoint_class` +- `autonomy_level` + +Normalization and matching rules: + +- tool names and class values are normalized to lowercase during load. +- inventory is local-file only and does not require network. +- policy matching keys are explicit: + - `context_tool_names` + - `context_data_classes` + - `context_endpoint_classes` + - `context_autonomy_levels` + +Fail-closed expectations: + +- missing/invalid inventory blocks in high-risk contexts (`risk_class` high/critical) and oss-prod profile. +- lower-risk contexts may continue without enrichment and should emit an explicit warning. + ## CLI Contract Examples Capture with required context evidence: diff --git a/docs/contracts/primitive_contract.md b/docs/contracts/primitive_contract.md index 47902d0..b45bd8e 100644 --- a/docs/contracts/primitive_contract.md +++ b/docs/contracts/primitive_contract.md @@ -57,6 +57,9 @@ Producer obligations: - `context.context_evidence_mode` (`best_effort|required`) - `context.context_refs` (string array of reference ids) - SHOULD provide `args_digest` and `intent_digest` when available. +- MAY include script intent fields for compound workflows: + - `script.steps[]` (ordered step list with per-step `tool_name`, `args`, optional `targets`, optional `arg_provenance`) + - `script_hash` (sha256 hex over canonical script payload) - SHOULD provide `skill_provenance` when execution originates from a packaged skill. - SHOULD provide `delegation` when tool execution is delegated across agents: - `requester_identity` @@ -69,6 +72,12 @@ Consumer obligations: - MUST fail closed for high-risk paths when intent cannot be evaluated. - MUST NOT execute side effects on non-`allow` outcomes. +Script mode semantics: + +- Producers MUST keep `script.steps[]` order stable because evaluation and digesting are order-sensitive. +- Producers MUST keep step payloads canonicalizable (objects/arrays only, no non-JSON values). +- Consumers MUST evaluate script mode deterministically and preserve stable reason-code ordering. + ## GateResult (`gait.gate.result`, `1.0.0`) Purpose: deterministic policy decision output for one `IntentRequest`. @@ -125,6 +134,12 @@ Producer obligations: - SHOULD carry `delegation_ref` when delegated execution evidence is present. - SHOULD include `observed_at` for runtime wall-clock incident reconstruction. - SHOULD include `event_id` as a per-emission runtime identity. +- SHOULD include script-governance metadata when script mode is evaluated: + - `script`, `step_count`, `script_hash` + - `composite_risk_class` + - `step_verdicts[]` + - `pre_approved`, `pattern_id`, `registry_reason` + - `context_source` when Wrkr enrichment is applied - SHOULD carry context-proof linkage when present in intent: - `context_set_digest` - `context_evidence_mode` @@ -228,6 +243,19 @@ Consumer obligations: - `gait pack diff --json`: - emits `context_drift_classification`, `context_changed`, `context_runtime_only_changes` when applicable +## Command Contract Additions (Script Governance) + +- `gait gate eval`: + - supports script-intent evaluation with deterministic step rollup metadata in JSON output + - supports Wrkr enrichment via `--wrkr-inventory ` + - supports signed pre-approved fast-path via: + - `--approved-script-registry ` + - `--approved-script-public-key ` or `--approved-script-public-key-env ` +- `gait approve-script`: + - creates signed approved-script entries bound to policy digest + script hash +- `gait list-scripts`: + - lists registry entries with expiry/active status + ## Session Chain Artifacts (`gait.runpack.session_*`, `1.0.0`) Purpose: append-only, crash-tolerant long-running capture with verifiable checkpoints. diff --git a/docs/integration_checklist.md b/docs/integration_checklist.md index af04615..4b3de72 100644 --- a/docs/integration_checklist.md +++ b/docs/integration_checklist.md @@ -76,6 +76,7 @@ Run these first. Stop if expected output is missing. 3. Policy decision shape: - `gait gate eval ... --json` - expect deterministic `verdict`, `reason_codes`, `intent_digest`, `policy_digest`, `trace_path` +- for script payloads also expect stable `script_hash`, `step_count`, `step_verdicts` 4. Wrapper allow path: - run wrapper quickstart allow scenario - expect `executed=true` @@ -97,6 +98,10 @@ Run these first. Stop if expected output is missing. 10. Observe->enforce rollout baseline: - observe: `gait gate eval ... --simulate --json` - enforce: `gait gate eval ... --json` +11. Approved script registry path (if script automation is used): +- mint entry: `gait approve-script --policy --intent --registry --approver --key-mode prod --private-key --json` +- inspect entry: `gait list-scripts --registry --json` +- enforce with registry: `gait gate eval ... --approved-script-registry --approved-script-public-key --json` ## Advanced Track (Hardening and Scale) @@ -122,6 +127,15 @@ gait run record \ - `context_evidence_missing` - `context_set_digest_missing` +Wrkr inventory enrichment (optional, local-file only): + +- add `--wrkr-inventory ` on gate eval +- map inventory metadata into explicit policy match keys: + - `context_tool_names` + - `context_data_classes` + - `context_endpoint_classes` + - `context_autonomy_levels` + 3. Trace signature verification: ```bash diff --git a/docs/policy_rollout.md b/docs/policy_rollout.md index f3653f0..f0ea369 100644 --- a/docs/policy_rollout.md +++ b/docs/policy_rollout.md @@ -92,6 +92,46 @@ Rollout gate: - runtime must block execution until approval token flow completes - CI should treat `require_approval` as a blocked promotion signal unless an approved path is part of release criteria +## Stage 3D: Script Governance, Wrkr Context, and Approved Registry + +For multi-step scripts, evaluate script payloads directly and wire deterministic context enrichment: + +```bash +gait gate eval \ + --policy ./policy.yaml \ + --intent ./script_intent.json \ + --wrkr-inventory ./wrkr_inventory.json \ + --json +``` + +For explicitly approved script patterns, mint signed entries and verify fast-path allow behavior: + +```bash +gait approve-script \ + --policy ./policy.yaml \ + --intent ./script_intent.json \ + --registry ./approved_scripts.json \ + --approver secops \ + --key-mode prod \ + --private-key ./approval_private.key \ + --json + +gait list-scripts --registry ./approved_scripts.json --json + +gait gate eval \ + --policy ./policy.yaml \ + --intent ./script_intent.json \ + --approved-script-registry ./approved_scripts.json \ + --approved-script-public-key ./approval_public.key \ + --json +``` + +Rollout gate: + +- approved-script entries must be policy-digest bound and signature verified. +- missing/invalid registry state must fail closed in high-risk/oss-prod paths. +- monitor `pre_approved`, `pattern_id`, and `registry_reason` in gate JSON and trace artifacts. + ## Stage 3B: Skill Trust Guardrails When skills initiate tool calls, add trust conditions: diff --git a/docs/project_defaults.md b/docs/project_defaults.md index c66d1a4..e52cade 100644 --- a/docs/project_defaults.md +++ b/docs/project_defaults.md @@ -19,6 +19,7 @@ gate: profile: oss-prod key_mode: prod private_key: examples/scenarios/keys/approval_private.key + wrkr_inventory_path: ./.gait/wrkr_inventory.json credential_broker: stub ``` @@ -48,6 +49,7 @@ gait gate eval --intent examples/policy/intents/intent_delete.json --json - `credential_command_args` - `credential_evidence_path` - `trace_path` +- `wrkr_inventory_path` ## Guardrails diff --git a/docs/uat_functional_plan.md b/docs/uat_functional_plan.md index 900ebbc..6129343 100644 --- a/docs/uat_functional_plan.md +++ b/docs/uat_functional_plan.md @@ -29,6 +29,7 @@ The UAT script refreshes Homebrew taps before reinstall to avoid stale formula r - `scripts/test_v1_6_acceptance.sh` (v1.6 wedge/flow checks) - `scripts/test_v1_7_acceptance.sh` (v1.7 endpoint/provenance/fail-closed checks) - `scripts/test_v1_8_acceptance.sh` (v1.8 interception/ecosystem checks) +- `scripts/test_script_intent_acceptance.sh` (script-intent governance + Wrkr enrichment + approved-script registry fail-closed checks) - `scripts/test_v2_3_acceptance.sh` (v2.3 adoption/conformance/distribution gate + metrics snapshot) - `scripts/test_v2_4_acceptance.sh` (v2.4 job/pack/signing/replay/credential-ttl acceptance gate) - `scripts/test_mcp_canonical_demo.sh` (canonical MCP allow/block/require-approval boundary demo with per-call pack evidence) diff --git a/go.mod b/go.mod index 6c16e7e..f21d929 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Clyra-AI/gait go 1.25.7 require ( - github.com/Clyra-AI/proof v0.4.0 + github.com/Clyra-AI/proof v0.4.3 github.com/goccy/go-yaml v1.19.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 6812cf8..2c74959 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Clyra-AI/proof v0.4.0 h1:tcto9gVZeIA96eCQunnY5LICqg1bl+IkMR4i1k9Eg3o= -github.com/Clyra-AI/proof v0.4.0/go.mod h1:EDff6buidj222E+EYyqQXXj1rtPgSFlYOxl2JFfWKFs= +github.com/Clyra-AI/proof v0.4.3 h1:8JzOvB95mLDZofM6nv6stDvhGrJKZJwfASdf6O9goYs= +github.com/Clyra-AI/proof v0.4.3/go.mod h1:EDff6buidj222E+EYyqQXXj1rtPgSFlYOxl2JFfWKFs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/schemas/v1/gate/approved_script_entry.schema.json b/schemas/v1/gate/approved_script_entry.schema.json new file mode 100644 index 0000000..442d448 --- /dev/null +++ b/schemas/v1/gate/approved_script_entry.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gait.dev/schemas/v1/gate/approved_script_entry.schema.json", + "title": "Approved Script Entry", + "type": "object", + "required": [ + "schema_id", + "schema_version", + "created_at", + "producer_version", + "pattern_id", + "policy_digest", + "script_hash", + "tool_sequence", + "approver_identity", + "expires_at" + ], + "properties": { + "schema_id": { "type": "string", "const": "gait.gate.approved_script_entry" }, + "schema_version": { "type": "string", "pattern": "^1\\.0\\.0$" }, + "created_at": { "type": "string", "format": "date-time" }, + "producer_version": { "type": "string" }, + "pattern_id": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "script_hash": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "tool_sequence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "scope": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "approver_identity": { "type": "string", "minLength": 1 }, + "expires_at": { "type": "string", "format": "date-time" }, + "signature": { + "type": "object", + "required": ["alg", "key_id", "sig"], + "properties": { + "alg": { "type": "string" }, + "key_id": { "type": "string" }, + "sig": { "type": "string" }, + "signed_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/v1/gate/intent_request.schema.json b/schemas/v1/gate/intent_request.schema.json index 5c737c0..fc3554e 100644 --- a/schemas/v1/gate/intent_request.schema.json +++ b/schemas/v1/gate/intent_request.schema.json @@ -22,6 +22,75 @@ "args": { "type": "object" }, "args_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "intent_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "script_hash": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "script": { + "type": "object", + "required": ["steps"], + "properties": { + "steps": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["tool_name", "args"], + "properties": { + "tool_name": { "type": "string", "minLength": 1 }, + "args": { "type": "object" }, + "targets": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "value"], + "properties": { + "kind": { + "type": "string", + "enum": ["path", "url", "host", "repo", "bucket", "table", "queue", "topic", "other"] + }, + "value": { "type": "string", "minLength": 1 }, + "operation": { "type": "string" }, + "sensitivity": { "type": "string" }, + "endpoint_class": { + "type": "string", + "enum": [ + "fs.read", + "fs.write", + "fs.delete", + "proc.exec", + "net.http", + "net.dns", + "ui.click", + "ui.type", + "ui.navigate", + "other" + ] + }, + "endpoint_domain": { "type": "string" }, + "destructive": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "arg_provenance": { + "type": "array", + "items": { + "type": "object", + "required": ["arg_path", "source"], + "properties": { + "arg_path": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "enum": ["user", "tool_output", "external", "system"] }, + "source_ref": { "type": "string" }, + "integrity_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "targets": { "type": "array", "items": { diff --git a/schemas/v1/gate/policy.schema.json b/schemas/v1/gate/policy.schema.json index 8db2730..9aa378b 100644 --- a/schemas/v1/gate/policy.schema.json +++ b/schemas/v1/gate/policy.schema.json @@ -10,6 +10,15 @@ "type": "string", "enum": ["allow", "block", "dry_run", "require_approval"] }, + "scripts": { + "type": "object", + "properties": { + "max_steps": { "type": "integer", "minimum": 1 }, + "require_approval_above": { "type": "integer", "minimum": 0 }, + "block_mixed_risk": { "type": "boolean" } + }, + "additionalProperties": false + }, "fail_closed": { "type": "object", "properties": { @@ -57,6 +66,10 @@ "provenance_sources": { "type": "array", "items": { "type": "string" } }, "identities": { "type": "array", "items": { "type": "string" } }, "workspace_prefixes": { "type": "array", "items": { "type": "string" } }, + "context_tool_names": { "type": "array", "items": { "type": "string" } }, + "context_data_classes": { "type": "array", "items": { "type": "string" } }, + "context_endpoint_classes": { "type": "array", "items": { "type": "string" } }, + "context_autonomy_levels": { "type": "array", "items": { "type": "string" } }, "require_delegation": { "type": "boolean" }, "allowed_delegator_identities": { "type": "array", "items": { "type": "string" } }, "allowed_delegate_identities": { "type": "array", "items": { "type": "string" } }, diff --git a/schemas/v1/gate/trace_record.schema.json b/schemas/v1/gate/trace_record.schema.json index dac10be..925f7cd 100644 --- a/schemas/v1/gate/trace_record.schema.json +++ b/schemas/v1/gate/trace_record.schema.json @@ -32,6 +32,30 @@ "context_set_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "context_evidence_mode": { "type": "string", "enum": ["best_effort", "required"] }, "context_ref_count": { "type": "integer", "minimum": 0 }, + "context_source": { "type": "string" }, + "script": { "type": "boolean" }, + "step_count": { "type": "integer", "minimum": 0 }, + "script_hash": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "composite_risk_class": { "type": "string" }, + "step_verdicts": { + "type": "array", + "items": { + "type": "object", + "required": ["index", "tool_name", "verdict"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "tool_name": { "type": "string" }, + "verdict": { "type": "string", "enum": ["allow", "block", "dry_run", "require_approval"] }, + "reason_codes": { "type": "array", "items": { "type": "string" } }, + "violations": { "type": "array", "items": { "type": "string" } }, + "matched_rule": { "type": "string" } + }, + "additionalProperties": false + } + }, + "pre_approved": { "type": "boolean" }, + "pattern_id": { "type": "string" }, + "registry_reason": { "type": "string" }, "violations": { "type": "array", "items": { "type": "string" }, "default": [] }, "latency_ms": { "type": "number" }, "approval_token_ref": { "type": "string" }, diff --git a/scripts/test_contracts.sh b/scripts/test_contracts.sh index c2923f3..8534891 100644 --- a/scripts/test_contracts.sh +++ b/scripts/test_contracts.sh @@ -201,6 +201,22 @@ expected = { ], "verdict_enum": ["allow", "block", "dry_run", "require_approval"], }, + "schemas/v1/gate/approved_script_entry.schema.json": { + "schema_id": "gait.gate.approved_script_entry", + "schema_version_pattern": r"^1\.0\.0$", + "required": [ + "schema_id", + "schema_version", + "created_at", + "producer_version", + "pattern_id", + "policy_digest", + "script_hash", + "tool_sequence", + "approver_identity", + "expires_at", + ], + }, "schemas/v1/runpack/manifest.schema.json": { "schema_id": "gait.runpack.manifest", "schema_version_pattern": r"^1\.0\.0$", diff --git a/scripts/test_script_intent_acceptance.sh b/scripts/test_script_intent_acceptance.sh new file mode 100755 index 0000000..cfc8870 --- /dev/null +++ b/scripts/test_script_intent_acceptance.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ $# -gt 1 ]]; then + echo "usage: $0 [path-to-gait-binary]" >&2 + exit 2 +fi + +if [[ $# -eq 1 ]]; then + if [[ "$1" = /* ]]; then + BIN_PATH="$1" + else + BIN_PATH="$(pwd)/$1" + fi +else + BIN_PATH="$REPO_ROOT/gait" + go build -o "$BIN_PATH" ./cmd/gait +fi + +if [[ ! -x "$BIN_PATH" ]]; then + echo "binary is not executable: $BIN_PATH" >&2 + exit 2 +fi + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/gait-script-intent-XXXXXX")" +trap 'rm -rf "${TMP_DIR}"' EXIT + +WRKR_POLICY_PATH="${TMP_DIR}/policy_wrkr.yaml" +APPROVAL_POLICY_PATH="${TMP_DIR}/policy_approval.yaml" +SCRIPT_INTENT_PATH="${TMP_DIR}/intent_script.json" +WRKR_INVENTORY_PATH="${TMP_DIR}/wrkr_inventory.json" +REGISTRY_PATH="${TMP_DIR}/approved_scripts.json" +TAMPERED_REGISTRY_PATH="${TMP_DIR}/approved_scripts_tampered.json" +PRIVATE_KEY_PATH="${REPO_ROOT}/examples/scenarios/keys/approval_private.key" +PUBLIC_KEY_PATH="${REPO_ROOT}/examples/scenarios/keys/approval_public.key" + +run_eval() { + local output_path="$1" + shift + set +e + "$BIN_PATH" gate eval "$@" --json >"${output_path}" + local exit_code=$? + set -e + echo "${exit_code}" +} + +cat >"${WRKR_POLICY_PATH}" <<'YAML' +schema_id: gait.gate.policy +schema_version: 1.0.0 +default_verdict: allow +scripts: + max_steps: 8 +rules: + - name: block_wrkr_secret_write + priority: 1 + effect: block + match: + tool_names: [tool.write] + context_data_classes: [secret] + reason_codes: [wrkr_secret_write_blocked] + violations: [wrkr_secret_write_blocked] + - name: require_write_approval + priority: 10 + effect: require_approval + match: + tool_names: [tool.write] + reason_codes: [approval_required_write] +YAML + +cat >"${APPROVAL_POLICY_PATH}" <<'YAML' +schema_id: gait.gate.policy +schema_version: 1.0.0 +default_verdict: allow +rules: + - name: require_write_approval + priority: 10 + effect: require_approval + match: + tool_names: [tool.write] + reason_codes: [approval_required_write] +YAML + +cat >"${SCRIPT_INTENT_PATH}" <<'JSON' +{ + "schema_id": "gait.gate.intent_request", + "schema_version": "1.0.0", + "created_at": "2026-02-20T00:00:00Z", + "producer_version": "test-script-acceptance", + "tool_name": "script", + "args": {}, + "targets": [], + "context": { + "identity": "alice", + "workspace": "/repo/gait", + "risk_class": "high" + }, + "script": { + "steps": [ + { + "tool_name": "tool.read", + "args": { + "path": "/tmp/input.txt" + }, + "targets": [ + { + "kind": "path", + "value": "/tmp/input.txt", + "operation": "read" + } + ] + }, + { + "tool_name": "tool.write", + "args": { + "path": "/tmp/output.txt" + }, + "targets": [ + { + "kind": "path", + "value": "/tmp/output.txt", + "operation": "write", + "endpoint_class": "fs.write" + } + ] + } + ] + } +} +JSON + +cat >"${WRKR_INVENTORY_PATH}" <<'JSON' +[ + { + "tool_name": "tool.read", + "data_class": "public", + "endpoint_class": "fs.read", + "autonomy_level": "assist" + }, + { + "tool_name": "tool.write", + "data_class": "secret", + "endpoint_class": "fs.write", + "autonomy_level": "assist" + } +] +JSON + +echo "==> script determinism and baseline require_approval" +EVAL_A_EXIT="$(run_eval "${TMP_DIR}/eval_a.json" --policy "${WRKR_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}")" +EVAL_B_EXIT="$(run_eval "${TMP_DIR}/eval_b.json" --policy "${WRKR_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}")" +if [[ "${EVAL_A_EXIT}" -ne 4 || "${EVAL_B_EXIT}" -ne 4 ]]; then + echo "expected deterministic require_approval exits (4), got ${EVAL_A_EXIT} and ${EVAL_B_EXIT}" >&2 + exit 1 +fi +python3 - <<'PY' "${TMP_DIR}/eval_a.json" "${TMP_DIR}/eval_b.json" +from __future__ import annotations + +import json +import pathlib +import sys + +first = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +second = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) + +for payload in (first, second): + assert payload.get("ok") is True, payload + assert payload.get("script") is True, payload + assert payload.get("step_count") == 2, payload + assert payload.get("verdict") == "require_approval", payload + assert "approval_required_write" in payload.get("reason_codes", []), payload + assert payload.get("script_hash"), payload + assert len(payload.get("step_verdicts", [])) == 2, payload + +assert first["script_hash"] == second["script_hash"], (first, second) +assert first["reason_codes"] == second["reason_codes"], (first, second) +assert first["step_verdicts"] == second["step_verdicts"], (first, second) +PY + +echo "==> wrkr context enrichment policy match" +EVAL_WRKR_EXIT="$(run_eval "${TMP_DIR}/eval_wrkr.json" --policy "${WRKR_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}" --wrkr-inventory "${WRKR_INVENTORY_PATH}")" +if [[ "${EVAL_WRKR_EXIT}" -ne 3 ]]; then + echo "expected wrkr-enriched block exit (3), got ${EVAL_WRKR_EXIT}" >&2 + exit 1 +fi +python3 - <<'PY' "${TMP_DIR}/eval_wrkr.json" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert payload.get("ok") is True, payload +assert payload.get("verdict") == "block", payload +assert "wrkr_secret_write_blocked" in payload.get("reason_codes", []), payload +assert payload.get("context_source"), payload +PY + +echo "==> wrkr fail-closed behavior when inventory is missing" +EVAL_WRKR_MISSING_EXIT="$(run_eval "${TMP_DIR}/eval_wrkr_missing.json" --policy "${WRKR_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}" --wrkr-inventory "${TMP_DIR}/missing_wrkr.json")" +if [[ "${EVAL_WRKR_MISSING_EXIT}" -ne 3 ]]; then + echo "expected wrkr fail-closed exit (3), got ${EVAL_WRKR_MISSING_EXIT}" >&2 + exit 1 +fi +python3 - <<'PY' "${TMP_DIR}/eval_wrkr_missing.json" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert payload.get("ok") is False, payload +assert "wrkr inventory unavailable in fail-closed mode" in str(payload.get("error", "")), payload +PY + +echo "==> approved-script fast-path allow" +"${BIN_PATH}" approve-script \ + --policy "${APPROVAL_POLICY_PATH}" \ + --intent "${SCRIPT_INTENT_PATH}" \ + --registry "${REGISTRY_PATH}" \ + --approver "secops" \ + --key-mode prod \ + --private-key "${PRIVATE_KEY_PATH}" \ + --json >"${TMP_DIR}/approve_script.json" +python3 - <<'PY' "${TMP_DIR}/approve_script.json" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert payload.get("ok") is True, payload +assert payload.get("pattern_id"), payload +assert payload.get("script_hash"), payload +PY + +EVAL_APPROVED_EXIT="$(run_eval "${TMP_DIR}/eval_approved.json" --policy "${APPROVAL_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}" --approved-script-registry "${REGISTRY_PATH}" --approved-script-public-key "${PUBLIC_KEY_PATH}")" +if [[ "${EVAL_APPROVED_EXIT}" -ne 0 ]]; then + echo "expected approved-script fast-path allow exit (0), got ${EVAL_APPROVED_EXIT}" >&2 + exit 1 +fi +python3 - <<'PY' "${TMP_DIR}/eval_approved.json" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert payload.get("ok") is True, payload +assert payload.get("verdict") == "allow", payload +assert payload.get("pre_approved") is True, payload +assert payload.get("pattern_id"), payload +assert payload.get("registry_reason") == "approved_script_match", payload +PY + +echo "==> approved-script fail-closed on signature mismatch" +python3 - <<'PY' "${REGISTRY_PATH}" "${TAMPERED_REGISTRY_PATH}" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +entries = payload.get("entries", []) +assert entries, payload +entries[0]["script_hash"] = "f" * 64 +pathlib.Path(sys.argv[2]).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY + +EVAL_TAMPERED_EXIT="$(run_eval "${TMP_DIR}/eval_tampered.json" --policy "${APPROVAL_POLICY_PATH}" --intent "${SCRIPT_INTENT_PATH}" --approved-script-registry "${TAMPERED_REGISTRY_PATH}" --approved-script-public-key "${PUBLIC_KEY_PATH}")" +if [[ "${EVAL_TAMPERED_EXIT}" -ne 3 ]]; then + echo "expected tampered registry fail-closed exit (3), got ${EVAL_TAMPERED_EXIT}" >&2 + exit 1 +fi +python3 - <<'PY' "${TMP_DIR}/eval_tampered.json" +from __future__ import annotations + +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +assert payload.get("ok") is False, payload +assert "approved script registry verification failed" in str(payload.get("error", "")), payload +PY + +echo "script intent acceptance: pass" diff --git a/sdk/python/gait/__init__.py b/sdk/python/gait/__init__.py index 66fb75d..f4a6edf 100644 --- a/sdk/python/gait/__init__.py +++ b/sdk/python/gait/__init__.py @@ -18,6 +18,8 @@ IntentContext, IntentDelegation, IntentRequest, + IntentScript, + IntentScriptStep, IntentTarget, RegressInitResult, RunRecordCapture, @@ -38,6 +40,8 @@ "IntentContext", "IntentDelegation", "IntentRequest", + "IntentScript", + "IntentScriptStep", "IntentTarget", "RegressInitResult", "RunAttempt", diff --git a/sdk/python/gait/client.py b/sdk/python/gait/client.py index ffe2545..9cb2229 100644 --- a/sdk/python/gait/client.py +++ b/sdk/python/gait/client.py @@ -15,6 +15,7 @@ IntentArgProvenance, IntentContext, IntentRequest, + IntentScript, IntentTarget, RegressInitResult, RunRecordCapture, @@ -56,6 +57,7 @@ def capture_intent( context: IntentContext, targets: Sequence[IntentTarget] | None = None, arg_provenance: Sequence[IntentArgProvenance] | None = None, + script: IntentScript | None = None, created_at: datetime | None = None, producer_version: str = "0.0.0-dev", ) -> IntentRequest: @@ -65,6 +67,7 @@ def capture_intent( context=context, targets=list(targets or []), arg_provenance=list(arg_provenance or []), + script=script, created_at=created_at or datetime.now(UTC), producer_version=producer_version, ) diff --git a/sdk/python/gait/models.py b/sdk/python/gait/models.py index cc2b396..a672da1 100644 --- a/sdk/python/gait/models.py +++ b/sdk/python/gait/models.py @@ -131,6 +131,30 @@ def to_dict(self) -> dict[str, Any]: return output +@dataclass(slots=True, frozen=True) +class IntentScriptStep: + tool_name: str + args: dict[str, Any] + targets: list[IntentTarget] = field(default_factory=list) + arg_provenance: list[IntentArgProvenance] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + output: dict[str, Any] = {"tool_name": self.tool_name, "args": self.args} + if self.targets: + output["targets"] = [target.to_dict() for target in self.targets] + if self.arg_provenance: + output["arg_provenance"] = [entry.to_dict() for entry in self.arg_provenance] + return output + + +@dataclass(slots=True, frozen=True) +class IntentScript: + steps: list[IntentScriptStep] + + def to_dict(self) -> dict[str, Any]: + return {"steps": [step.to_dict() for step in self.steps]} + + @dataclass(slots=True) class IntentRequest: tool_name: str @@ -139,12 +163,14 @@ class IntentRequest: targets: list[IntentTarget] = field(default_factory=list) arg_provenance: list[IntentArgProvenance] = field(default_factory=list) delegation: IntentDelegation | None = None + script: IntentScript | None = None created_at: datetime = field(default_factory=_utc_now) producer_version: str = "0.0.0-dev" schema_id: str = "gait.gate.intent_request" schema_version: str = "1.0.0" args_digest: str | None = None intent_digest: str | None = None + script_hash: str | None = None def to_dict(self) -> dict[str, Any]: output: dict[str, Any] = { @@ -161,10 +187,14 @@ def to_dict(self) -> dict[str, Any]: output["arg_provenance"] = [entry.to_dict() for entry in self.arg_provenance] if self.delegation is not None: output["delegation"] = self.delegation.to_dict() + if self.script is not None: + output["script"] = self.script.to_dict() if self.args_digest: output["args_digest"] = self.args_digest if self.intent_digest: output["intent_digest"] = self.intent_digest + if self.script_hash: + output["script_hash"] = self.script_hash return output @classmethod @@ -178,6 +208,7 @@ def from_dict(cls, payload: dict[str, Any]) -> "IntentRequest": args=dict(payload.get("args", {})), args_digest=payload.get("args_digest"), intent_digest=payload.get("intent_digest"), + script_hash=payload.get("script_hash"), targets=[ IntentTarget( kind=str(target["kind"]), @@ -231,6 +262,37 @@ def from_dict(cls, payload: dict[str, Any]) -> "IntentRequest": if "delegation" in payload and isinstance(payload.get("delegation"), dict) else None ), + script=( + IntentScript( + steps=[ + IntentScriptStep( + tool_name=str(step["tool_name"]), + args=dict(step.get("args", {})), + targets=[ + IntentTarget( + kind=str(target["kind"]), + value=str(target["value"]), + operation=target.get("operation"), + sensitivity=target.get("sensitivity"), + ) + for target in step.get("targets", []) + ], + arg_provenance=[ + IntentArgProvenance( + arg_path=str(entry["arg_path"]), + source=str(entry["source"]), + source_ref=entry.get("source_ref"), + integrity_digest=entry.get("integrity_digest"), + ) + for entry in step.get("arg_provenance", []) + ], + ) + for step in payload["script"].get("steps", []) + ] + ) + if "script" in payload and isinstance(payload.get("script"), dict) + else None + ), ) @@ -246,6 +308,14 @@ class GateEvalResult: trace_path: str | None = None policy_digest: str | None = None intent_digest: str | None = None + script: bool = False + step_count: int = 0 + script_hash: str | None = None + composite_risk_class: str | None = None + pre_approved: bool = False + pattern_id: str | None = None + registry_reason: str | None = None + step_verdicts: list[dict[str, Any]] = field(default_factory=list) warnings: list[str] = field(default_factory=list) error: str | None = None @@ -262,6 +332,16 @@ def from_dict(cls, payload: dict[str, Any], exit_code: int) -> "GateEvalResult": trace_path=payload.get("trace_path"), policy_digest=payload.get("policy_digest"), intent_digest=payload.get("intent_digest"), + script=bool(payload.get("script", False)), + step_count=int(payload.get("step_count", 0)), + script_hash=payload.get("script_hash"), + composite_risk_class=payload.get("composite_risk_class"), + pre_approved=bool(payload.get("pre_approved", False)), + pattern_id=payload.get("pattern_id"), + registry_reason=payload.get("registry_reason"), + step_verdicts=[ + dict(value) for value in payload.get("step_verdicts", []) if isinstance(value, dict) + ], warnings=[str(value) for value in payload.get("warnings", [])], error=payload.get("error"), ) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 3b10948..81d2a74 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -11,6 +11,8 @@ from gait import ( IntentContext, IntentRequest, + IntentScript, + IntentScriptStep, IntentTarget, capture_demo_runpack, capture_intent, @@ -70,6 +72,26 @@ def test_evaluate_gate_require_approval_exit_code(tmp_path: Path) -> None: assert result.reason_codes == ["approval_required"] +def test_capture_intent_script_payload() -> None: + intent = capture_intent( + tool_name="script", + args={}, + context=IntentContext(identity="alice", workspace="/repo/gait", risk_class="high"), + script=IntentScript( + steps=[ + IntentScriptStep(tool_name="tool.read", args={"path": "/tmp/input.txt"}), + IntentScriptStep(tool_name="tool.write", args={"path": "/tmp/output.txt"}), + ] + ), + ) + + payload = intent.to_dict() + assert intent.script is not None + assert payload["tool_name"] == "script" + assert payload["script"]["steps"][0]["tool_name"] == "tool.read" + assert payload["script"]["steps"][1]["tool_name"] == "tool.write" + + def test_write_trace_copies_source_record(tmp_path: Path) -> None: source = tmp_path / "trace.json" trace_payload = { diff --git a/sdk/python/tests/test_primitive_fixtures.py b/sdk/python/tests/test_primitive_fixtures.py index 232507c..1b0eb1d 100644 --- a/sdk/python/tests/test_primitive_fixtures.py +++ b/sdk/python/tests/test_primitive_fixtures.py @@ -3,7 +3,15 @@ import json from pathlib import Path -from gait import GateEvalResult, IntentRequest, TraceRecord +from gait import ( + GateEvalResult, + IntentContext, + IntentRequest, + IntentScript, + IntentScriptStep, + IntentTarget, + TraceRecord, +) def _repo_root() -> Path: @@ -45,3 +53,62 @@ def test_trace_record_fixture_parses_with_sdk_model() -> None: assert trace_record.trace_id == "trace_1" assert trace_record.verdict == "allow" assert trace_record.policy_digest == "3" * 64 + + +def test_intent_request_script_round_trip() -> None: + intent = IntentRequest( + tool_name="script", + args={}, + context=IntentContext(identity="alice", workspace="/repo/gait", risk_class="high"), + script=IntentScript( + steps=[ + IntentScriptStep( + tool_name="tool.read", + args={"path": "/tmp/in.txt"}, + targets=[IntentTarget(kind="path", value="/tmp/in.txt", operation="read")], + ), + IntentScriptStep( + tool_name="tool.write", + args={"path": "/tmp/out.txt"}, + targets=[IntentTarget(kind="path", value="/tmp/out.txt", operation="write")], + ), + ] + ), + ) + payload = intent.to_dict() + restored = IntentRequest.from_dict(payload) + + assert restored.script is not None + assert len(restored.script.steps) == 2 + assert restored.script.steps[0].tool_name == "tool.read" + assert restored.script.steps[1].tool_name == "tool.write" + + +def test_gate_result_script_fields_parse() -> None: + gate_result = GateEvalResult.from_dict( + { + "ok": True, + "verdict": "allow", + "reason_codes": ["approved_script_match"], + "script": True, + "step_count": 2, + "script_hash": "a" * 64, + "composite_risk_class": "high", + "pre_approved": True, + "pattern_id": "pattern_test", + "registry_reason": "approved_script_match", + "step_verdicts": [ + {"index": 0, "tool_name": "tool.read", "verdict": "allow"}, + {"index": 1, "tool_name": "tool.write", "verdict": "require_approval"}, + ], + }, + exit_code=0, + ) + + assert gate_result.ok is True + assert gate_result.script is True + assert gate_result.step_count == 2 + assert gate_result.script_hash == "a" * 64 + assert gate_result.pre_approved is True + assert gate_result.pattern_id == "pattern_test" + assert len(gate_result.step_verdicts) == 2