Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <policy.yaml> --intent <intent.json> --json`
- `gait approve --intent-digest <new_digest> --policy-digest <policy_digest> ...`

## [1.2.3] - 2026-02-16

### Added
Expand Down
14 changes: 14 additions & 0 deletions cmd/gait/job_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion core/gate/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions core/gate/intent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions core/gate/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 10 additions & 4 deletions core/jobruntime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions core/jobruntime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions docs/approval_runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
14 changes: 7 additions & 7 deletions scenarios/gait/approval-expiry-1s-past/approval-token.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pqc3MBXcSfGexJz8uZLMOgzHSCvvYOXlkyqRi970UFk=
2452IFcJJprSJUSaASiKpWRN4bzs7KbckliBk2cKGeg=
14 changes: 7 additions & 7 deletions scenarios/gait/approval-token-valid/approval-token.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion scenarios/gait/approval-token-valid/approval_public.key
Original file line number Diff line number Diff line change
@@ -1 +1 @@
OHLUcz7bHnDiCJOaRaGqVGNeG+j/NYBNd9FNajegulU=
2452IFcJJprSJUSaASiKpWRN4bzs7KbckliBk2cKGeg=
Loading