diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ab5e3..75ee695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - _No unreleased entries yet._ +### Changed + +- Gate intent normalization now treats omitted target `discovery_method` as `unknown` instead of empty so policies can deterministically match unknown/dynamic discovery paths. +- Durable job resume now preserves the originally bound identity and rejects attempts to resume with a different identity. + +### Upgrade Notes + +- Intent digest derivation changed for intents with targets that omit `discovery_method`. +- Existing approval tokens minted before this change will fail verification against newly normalized digests and must be reminted after upgrade. +- Recommended post-upgrade validation: + - `gait gate eval --policy --intent --json` + - `gait approve --intent-digest --policy-digest ...` + ## [1.2.3] - 2026-02-16 ### Added diff --git a/cmd/gait/job_cli_test.go b/cmd/gait/job_cli_test.go index bbe27f3..31de446 100644 --- a/cmd/gait/job_cli_test.go +++ b/cmd/gait/job_cli_test.go @@ -245,6 +245,20 @@ rules: if pauseAgainCode != exitOK || pauseAgainOut.Job == nil || pauseAgainOut.Job.Status != "paused" { t.Fatalf("pause before revoked identity check expected paused: code=%d output=%#v", pauseAgainCode, pauseAgainOut) } + mismatchCode, mismatchOut := runJobJSON(t, []string{ + "resume", + "--id", jobID, + "--root", root, + "--policy", policyB, + "--identity", "agent.bob", + "--json", + }) + if mismatchCode != exitInvalidInput { + t.Fatalf("resume with identity mismatch expected %d got %d output=%#v", exitInvalidInput, mismatchCode, mismatchOut) + } + if !strings.Contains(mismatchOut.Error, "identity binding mismatch") { + t.Fatalf("expected identity binding mismatch error, got %#v", mismatchOut) + } revocationsPath := filepath.Join(workDir, "revoked_identities.txt") if err := os.WriteFile(revocationsPath, []byte("agent.alice\n"), 0o600); err != nil { diff --git a/core/gate/intent.go b/core/gate/intent.go index f66c1b6..95234d2 100644 --- a/core/gate/intent.go +++ b/core/gate/intent.go @@ -424,7 +424,7 @@ func normalizeTargets(toolName string, targets []schemagate.IntentTarget) ([]sch func normalizeDiscoveryMethod(value string) string { method := strings.ToLower(strings.TrimSpace(value)) if method == "" { - return "" + return "unknown" } return strings.NewReplacer("-", "_", " ", "_").Replace(method) } diff --git a/core/gate/intent_test.go b/core/gate/intent_test.go index 8d6e014..2e2e7c9 100644 --- a/core/gate/intent_test.go +++ b/core/gate/intent_test.go @@ -611,6 +611,17 @@ func TestNormalizeTargetsAndProvenanceErrors(t *testing.T) { t.Fatalf("expected unsupported endpoint class to fail") } targets, err := normalizeTargets("tool.demo", []schemagate.IntentTarget{{ + Kind: "path", + Value: "/tmp/in", + Operation: "read", + }}) + if err != nil { + t.Fatalf("normalize targets with omitted discovery method: %v", err) + } + if len(targets) != 1 || targets[0].DiscoveryMethod != "unknown" { + t.Fatalf("expected omitted discovery method to normalize to unknown, got %#v", targets) + } + targets, err = normalizeTargets("tool.demo", []schemagate.IntentTarget{{ Kind: "path", Value: "/tmp/out", Operation: "write", diff --git a/core/gate/policy_test.go b/core/gate/policy_test.go index a1faa74..66bdbc0 100644 --- a/core/gate/policy_test.go +++ b/core/gate/policy_test.go @@ -565,6 +565,41 @@ func TestRuleMatchesCoverage(t *testing.T) { } } +func TestEvaluatePolicyMatchesUnknownDiscoveryMethod(t *testing.T) { + policy, err := ParsePolicyYAML([]byte(` +default_verdict: block +rules: + - name: allow_unknown_discovery + effect: allow + match: + endpoint_class: [fs.read] + discovery_method: [unknown] +`)) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + intent := baseIntent() + intent.ToolName = "tool.read" + intent.Targets = []schemagate.IntentTarget{ + { + Kind: "path", + Value: "/tmp/input.txt", + Operation: "read", + EndpointClass: "fs.read", + }, + } + outcome, err := EvaluatePolicyDetailed(policy, intent, EvalOptions{}) + if err != nil { + t.Fatalf("evaluate policy detailed: %v", err) + } + if outcome.Result.Verdict != "allow" { + t.Fatalf("expected allow verdict, got %#v", outcome.Result) + } + if outcome.MatchedRule != "allow_unknown_discovery" { + t.Fatalf("expected unknown-discovery rule to match, got %q", outcome.MatchedRule) + } +} + func TestShouldFailClosedAndBuildGateResultDefaults(t *testing.T) { if shouldFailClosed(FailClosedPolicy{Enabled: true, RiskClasses: nil}, "high") { t.Fatalf("expected fail-closed to be disabled with empty risk classes") diff --git a/core/jobruntime/runtime.go b/core/jobruntime/runtime.go index 8075de3..2c238b7 100644 --- a/core/jobruntime/runtime.go +++ b/core/jobruntime/runtime.go @@ -59,6 +59,7 @@ var ( ErrPolicyEvaluationRequired = errors.New("policy evaluation required") ErrIdentityValidationMissing = errors.New("identity validation required") ErrIdentityRevoked = errors.New("identity revoked") + ErrIdentityBindingMismatch = errors.New("identity binding mismatch") ) type JobState struct { @@ -401,18 +402,23 @@ func Resume(root string, jobID string, opts ResumeOptions) (JobState, error) { if policyRef != "" { state.PolicyRef = policyRef } - identity := strings.TrimSpace(opts.Identity) + boundIdentity := strings.TrimSpace(state.Identity) + providedIdentity := strings.TrimSpace(opts.Identity) + if boundIdentity != "" && providedIdentity != "" && providedIdentity != boundIdentity { + return JobState{}, Event{}, fmt.Errorf("%w: expected=%s provided=%s", ErrIdentityBindingMismatch, boundIdentity, providedIdentity) + } + identity := boundIdentity if identity == "" { - identity = strings.TrimSpace(state.Identity) + identity = providedIdentity } - identityValidationRequired := opts.RequireIdentityValidation || strings.TrimSpace(state.Identity) != "" + identityValidationRequired := opts.RequireIdentityValidation || boundIdentity != "" if identityValidationRequired && identity == "" { return JobState{}, Event{}, fmt.Errorf("%w: identity is required for resume validation", ErrIdentityValidationMissing) } if identity != "" && opts.IdentityRevoked { return JobState{}, Event{}, fmt.Errorf("%w: %s", ErrIdentityRevoked, identity) } - if identity != "" { + if boundIdentity == "" && identity != "" { state.Identity = identity } reasonCode := "resumed" diff --git a/core/jobruntime/runtime_test.go b/core/jobruntime/runtime_test.go index ff8d22c..5abee89 100644 --- a/core/jobruntime/runtime_test.go +++ b/core/jobruntime/runtime_test.go @@ -245,6 +245,30 @@ func TestResumeIdentityValidationErrors(t *testing.T) { }); !errors.Is(err, ErrIdentityRevoked) { t.Fatalf("expected identity revoked error, got %v", err) } + + if _, err := Submit(root, SubmitOptions{ + JobID: "job-identity-mismatch", + EnvironmentFingerprint: "env:a", + Identity: "agent.alice", + }); err != nil { + t.Fatalf("submit identity-mismatch job: %v", err) + } + if _, err := Pause(root, "job-identity-mismatch", TransitionOptions{}); err != nil { + t.Fatalf("pause identity-mismatch job: %v", err) + } + if _, err := Resume(root, "job-identity-mismatch", ResumeOptions{ + CurrentEnvironmentFingerprint: "env:a", + Identity: "agent.bob", + }); !errors.Is(err, ErrIdentityBindingMismatch) { + t.Fatalf("expected identity binding mismatch error, got %v", err) + } + state, err := Status(root, "job-identity-mismatch") + if err != nil { + t.Fatalf("status identity-mismatch job: %v", err) + } + if state.Identity != "agent.alice" { + t.Fatalf("expected bound identity to remain unchanged, got %#v", state) + } } func TestInvalidPauseTransition(t *testing.T) { diff --git a/docs/approval_runbook.md b/docs/approval_runbook.md index 3ac0dd1..f61466c 100644 --- a/docs/approval_runbook.md +++ b/docs/approval_runbook.md @@ -27,6 +27,12 @@ Runtime performance and reliability expectations for this path are defined in: - Signing keys are provisioned in a secure keystore or HSM-backed secret manager. - Public verification keys are distributed to runtime and audit systems. +Upgrade note (digest compatibility): + +- Current Gate normalization treats omitted target `discovery_method` as `unknown`. +- If your historical intents omitted `discovery_method`, `intent_digest` values will differ from pre-upgrade values. +- Remint approval tokens against the current `intent_digest` and `policy_digest` before resuming production traffic. + ## Step 1: Evaluate Intent ```bash @@ -87,6 +93,8 @@ gait approve \ For multi-party requirements, mint additional tokens (`token_b.json`, ...). +If this is part of an upgrade rollout, always rerun Step 1 and remint from the newly emitted digest pair before Step 3. + ## Step 3: Re-evaluate With Approval Token Chain ```bash diff --git a/internal/integration/testdata/gate_eval_trace_verify.golden.json b/internal/integration/testdata/gate_eval_trace_verify.golden.json index c239507..6bc7838 100644 --- a/internal/integration/testdata/gate_eval_trace_verify.golden.json +++ b/internal/integration/testdata/gate_eval_trace_verify.golden.json @@ -1,10 +1,10 @@ { - "intent_digest": "8d4604103ea4546edb522503436f5452ec22851b69024253017a8420db74e965", + "intent_digest": "c71b632af6a89397df565f076a47dbce7068687ab7df996b349648b9ecf4bd24", "policy_digest": "f4581f55c41a3ee69b316c95f66fbfc293ae7f06e6458491e3ca232dfb26608c", "reason_codes": [ "blocked_external" ], - "trace_id": "d2c128824fb77a2bc4944f46", + "trace_id": "e577ad07bd4f047ab3ac0406", "verdict": "block", "violations": [ "external_target" diff --git a/scenarios/gait/approval-expiry-1s-past/approval-token.json b/scenarios/gait/approval-expiry-1s-past/approval-token.json index d3fc14a..c06c619 100644 --- a/scenarios/gait/approval-expiry-1s-past/approval-token.json +++ b/scenarios/gait/approval-expiry-1s-past/approval-token.json @@ -1,21 +1,21 @@ { "schema_id": "gait.gate.approval_token", "schema_version": "1.0.0", - "created_at": "2026-02-18T19:13:05.165818Z", + "created_at": "2026-02-22T17:42:20.549368Z", "producer_version": "0.0.0-dev", - "token_id": "dfa1d0984e22f984a9c0ca45", + "token_id": "824b539851b82d38ca5fac04", "approver_identity": "human.reviewer", "reason_code": "MANUAL_APPROVAL", - "intent_digest": "350b03d56a4427cdddccb78038cf7ce3eb47d497242749793ddc15d0c809a24b", + "intent_digest": "29114c07f93d042f2092e84a25445986dcfdfa63b3a42f719250f5c0bb596375", "policy_digest": "07a528a879e4f0e0cd5a0e2ca801924746b47dac624862207ef21d4ec266f8f9", "scope": [ "tool:tool.write" ], - "expires_at": "2026-02-18T19:13:06.165818Z", + "expires_at": "2026-02-22T17:42:20.550368Z", "signature": { "alg": "ed25519", - "key_id": "4057fdde7cca116d12910f084ed93107c06bf44ed971c9c7ef72ea172776c4da", - "sig": "e0lnN37/bjusfb5lfIC/tin5NSbZL5zIn/bhOB5kmzQ8asCfVYejple6c86bfam+pWnPqycKAGTb63yfvw7MCQ==", - "signed_digest": "e30643b2f6fbb5d3929b08173f8f61f56c3a2c06b0e7ed3795b2e6ad191e317e" + "key_id": "8393424682c702d59a923d26924b2d4827711e6c585117077766645e22efeb52", + "sig": "x16WXIk4e2QPqqR0NSl9jlaS7ArMCJfUZGtBarSq6q79GYaC5wW9FDumtQl1/YLtcGFtzyZiNonVoIbgpKdYBQ==", + "signed_digest": "96e041b617e6e13835b9f5a5789564228ef20b4578f5299b90eec0fe083dea3c" } } diff --git a/scenarios/gait/approval-expiry-1s-past/approval_public.key b/scenarios/gait/approval-expiry-1s-past/approval_public.key index 50a1ffa..9d1c2d1 100644 --- a/scenarios/gait/approval-expiry-1s-past/approval_public.key +++ b/scenarios/gait/approval-expiry-1s-past/approval_public.key @@ -1 +1 @@ -pqc3MBXcSfGexJz8uZLMOgzHSCvvYOXlkyqRi970UFk= +2452IFcJJprSJUSaASiKpWRN4bzs7KbckliBk2cKGeg= diff --git a/scenarios/gait/approval-token-valid/approval-token.json b/scenarios/gait/approval-token-valid/approval-token.json index b650983..010de69 100644 --- a/scenarios/gait/approval-token-valid/approval-token.json +++ b/scenarios/gait/approval-token-valid/approval-token.json @@ -1,21 +1,21 @@ { "schema_id": "gait.gate.approval_token", "schema_version": "1.0.0", - "created_at": "2026-02-18T19:13:05.081373Z", + "created_at": "2026-02-22T17:42:20.324925Z", "producer_version": "0.0.0-dev", - "token_id": "da3a8c4454aaba580ea21586", + "token_id": "393bcea4466e16430690ad3f", "approver_identity": "human.reviewer", "reason_code": "MANUAL_APPROVAL", - "intent_digest": "0b82a0291b42e868e91897bbeaa6a16bf76694ad21a6c3aa5e8fa5d44b7562b6", + "intent_digest": "51ad46c45717bbc5baec288fa2eec502ea41b701b5cd6ef32c9ceec7f2f9607b", "policy_digest": "07a528a879e4f0e0cd5a0e2ca801924746b47dac624862207ef21d4ec266f8f9", "scope": [ "tool:tool.write" ], - "expires_at": "2036-02-16T19:13:05.081373Z", + "expires_at": "2036-02-20T17:42:20.324925Z", "signature": { "alg": "ed25519", - "key_id": "ff74abb7cb8fcdcfeed002ba39fd6c8b074afaa02e214d626f5677f5a2fcafd2", - "sig": "C2fcpy/QaFHuqpV7fhLInhCwoPBwT1uZ3rvCEUMbAuEz4ueeJPMcsXMVAt53AUrEMWg5+NtCzMRc6iIji5d/Cg==", - "signed_digest": "fc0f39bf6c5ea451f66e1ac97d6ffed0a7f5f67be04cb23f21ae031c3ece6a66" + "key_id": "8393424682c702d59a923d26924b2d4827711e6c585117077766645e22efeb52", + "sig": "owZR6DISeHWFK1i96XgM2likqw9yEBePCRetXg7wz8peGUh97bHcWWdKKy47t4Bi1HqpLlKB4goBMRkm2M9SDQ==", + "signed_digest": "5505a7e20b60c3da039c39c99c67f0ab4c486e1e48febb05e1b715d32124076c" } } diff --git a/scenarios/gait/approval-token-valid/approval_public.key b/scenarios/gait/approval-token-valid/approval_public.key index d30d8a8..9d1c2d1 100644 --- a/scenarios/gait/approval-token-valid/approval_public.key +++ b/scenarios/gait/approval-token-valid/approval_public.key @@ -1 +1 @@ -OHLUcz7bHnDiCJOaRaGqVGNeG+j/NYBNd9FNajegulU= +2452IFcJJprSJUSaASiKpWRN4bzs7KbckliBk2cKGeg=