From 14011c645f7a59b6d40a33896a1a87404dcd7041 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Thu, 19 Feb 2026 20:31:22 -0500 Subject: [PATCH] Add compiled_action records and governance event promotion --- README.md | 29 ++- compiled_action_test.go | 114 ++++++++++ core/framework/eu-ai-act.yaml | 4 +- core/framework/pci-dss.yaml | 2 +- core/framework/soc2.yaml | 7 +- core/schema/schema.go | 1 + core/schema/schema_test.go | 8 + .../schema/v1/governance-event-v1.schema.json | 35 +++ .../v1/types/compiled-action.schema.json | 50 +++++ docs/governance-events.md | 46 ++++ docs/record-context-keys.md | 17 ++ frameworks/eu-ai-act.yaml | 4 +- frameworks/pci-dss.yaml | 2 +- frameworks/soc2.yaml | 7 +- governance.go | 128 +++++++++++ governance_test.go | 209 ++++++++++++++++++ internal/scenarios/scenario_test.go | 8 + scenarios/CHANGELOG.md | 4 + .../README.md | 3 + .../expected.yaml | 3 + .../input-records.jsonl | 1 + schemas/v1/governance-event-v1.schema.json | 35 +++ schemas/v1/types/compiled-action.schema.json | 50 +++++ .../governance_events/approval_request.json | 1 + .../guardrail_activation.json | 1 + .../invalid-missing-required.json | 1 + testdata/governance_events/minimal.json | 1 + .../governance_events/permission_check.json | 1 + .../governance_events/policy_evaluation.json | 1 + .../governance_events/script_evaluation.json | 1 + testdata/governance_events/tool_gate.json | 1 + testdata/records/compiled_action_full.json | 44 ++++ testdata/records/compiled_action_minimal.json | 24 ++ 33 files changed, 828 insertions(+), 15 deletions(-) create mode 100644 compiled_action_test.go create mode 100644 core/schema/v1/governance-event-v1.schema.json create mode 100644 core/schema/v1/types/compiled-action.schema.json create mode 100644 docs/governance-events.md create mode 100644 docs/record-context-keys.md create mode 100644 governance.go create mode 100644 governance_test.go create mode 100644 scenarios/proof/compiled-action-chain-round-trip/README.md create mode 100644 scenarios/proof/compiled-action-chain-round-trip/expected.yaml create mode 100644 scenarios/proof/compiled-action-chain-round-trip/input-records.jsonl create mode 100644 schemas/v1/governance-event-v1.schema.json create mode 100644 schemas/v1/types/compiled-action.schema.json create mode 100644 testdata/governance_events/approval_request.json create mode 100644 testdata/governance_events/guardrail_activation.json create mode 100644 testdata/governance_events/invalid-missing-required.json create mode 100644 testdata/governance_events/minimal.json create mode 100644 testdata/governance_events/permission_check.json create mode 100644 testdata/governance_events/policy_evaluation.json create mode 100644 testdata/governance_events/script_evaluation.json create mode 100644 testdata/governance_events/tool_gate.json create mode 100644 testdata/records/compiled_action_full.json create mode 100644 testdata/records/compiled_action_minimal.json diff --git a/README.md b/README.md index 3fee48b..f5d1351 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ An open-source Go module and verification CLI. Four operations: PROOF_VERSION="$(gh release view --repo Clyra-AI/proof --json tagName -q .tagName 2>/dev/null || curl -fsSL https://api.github.com/repos/Clyra-AI/proof/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"tag_name\"])')" go install github.com/Clyra-AI/proof/cmd/proof@"${PROOF_VERSION}" -proof types list # 15 built-in record types -proof frameworks list # 8 built-in starter frameworks (11 controls) +proof types list # 16 built-in record types +proof frameworks list # 8 built-in starter frameworks (12 controls) proof verify ./artifact # Verify any proof artifact offline ``` @@ -74,6 +74,19 @@ _ = proof.RegisterCustomTypeSchema("vendor.custom_event", "./custom.schema.json" Custom types validate against the base proof record schema plus your type-specific schema. They chain and sign identically to built-in types. +### Governance Events + +```go +record, _ := proof.NewRecordFromEvent(proof.GovernanceEvent{ + EventID: "evt-1", + Timestamp: "2026-02-20T12:00:00Z", + EventType: "tool_gate", + Detail: map[string]any{"verdict": "allow"}, +}, "axym") +``` + +Use `schemas/v1/governance-event-v1.schema.json` for event validation. See `docs/governance-events.md` and `docs/record-context-keys.md`. + ### Bundle Signing and Verification ```go @@ -120,7 +133,7 @@ Records are immutable, deterministic, and JSON-native — readable by any langua ## Built-in Record Types -15 types covering the full AI governance surface, each with its own JSON Schema: +16 types covering the full AI governance surface, each with its own JSON Schema: | Type | Description | |---|---| @@ -139,6 +152,7 @@ Records are immutable, deterministic, and JSON-native — readable by any langua | `data_pipeline_run` | A data pipeline executed | | `replay_certification` | A replay was run and certified | | `approval` | An approval or delegation was issued | +| `compiled_action` | A compound agent action was compiled for execution | Record types are extensible. Define a custom type by providing a JSON Schema that extends the base record schema. @@ -187,17 +201,17 @@ YAML files that declare what regulatory controls require — which record types, controls: - id: article-12 title: Record-Keeping - required_record_types: [tool_invocation, decision, guardrail_activation, permission_check] + required_record_types: [tool_invocation, decision, guardrail_activation, permission_check, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous ``` -8 built-in starter frameworks ship with v1 (11 controls total). Add custom frameworks via YAML. +8 built-in starter frameworks ship with v1 (12 controls total). Add custom frameworks via YAML. | Framework | Scope | |---|---| | EU AI Act | Articles 9, 12, 14 (starter mapping) | -| SOC 2 | CC6, CC7 (starter mapping) | +| SOC 2 | CC6, CC7, CC8 (starter mapping) | | SOX | Change management (starter mapping) | | PCI-DSS | Requirement 10 (logging and monitoring) | | Texas TRAIGA | State AI regulation | @@ -302,8 +316,9 @@ canon/ Compatibility package (Gait migration) schema/ Compatibility package (Gait migration) exitcode/ Compatibility package (Gait migration) schemas/v1/ JSON Schema spec files (language-agnostic contract) - types/ 15 record type schemas + types/ 16 record type schemas frameworks/ 8 compliance framework YAML definitions +docs/ Supplementary format and interoperability documentation testdata/ Golden vectors and test fixtures scripts/ Test and validation scripts perf/ Performance budgets diff --git a/compiled_action_test.go b/compiled_action_test.go new file mode 100644 index 0000000..22ba338 --- /dev/null +++ b/compiled_action_test.go @@ -0,0 +1,114 @@ +package proof + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCompiledActionGoldenRecordsValidate(t *testing.T) { + paths := []string{ + filepath.Join("testdata", "records", "compiled_action_full.json"), + filepath.Join("testdata", "records", "compiled_action_minimal.json"), + } + for _, p := range paths { + r, err := ReadRecord(p) + require.NoError(t, err) + require.Equal(t, "compiled_action", r.RecordType) + require.NoError(t, ValidateRecord(r)) + h, err := ComputeRecordHash(r) + require.NoError(t, err) + require.Equal(t, r.Integrity.RecordHash, h) + } +} + +func TestCompiledActionSchemaRejectsInvalidFields(t *testing.T) { + _, err := NewRecord(RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 16, 0, 0, 0, time.UTC), + Source: "gait", + SourceProduct: "gait", + Type: "compiled_action", + Event: map[string]any{ + "script_hash": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "tool_sequence": []string{}, + "step_count": 1, + "has_conditionals": false, + "has_loops": false, + "composite_risk_class": "high", + }, + }) + require.Error(t, err) + + _, err = NewRecord(RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 16, 1, 0, 0, time.UTC), + Source: "gait", + SourceProduct: "gait", + Type: "compiled_action", + Event: map[string]any{ + "script_hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "tool_sequence": []string{"shell.exec"}, + "step_count": 1, + "has_conditionals": false, + "has_loops": false, + "composite_risk_class": "critical", + }, + }) + require.Error(t, err) +} + +func TestCompiledActionChainRoundTrip(t *testing.T) { + r, err := NewRecord(RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 16, 2, 0, 0, time.UTC), + Source: "gait", + SourceProduct: "gait", + Type: "compiled_action", + Event: map[string]any{ + "script_hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "tool_sequence": []string{"shell.exec", "http.request", "db.query", "fs.write", "notify.send"}, + "step_count": 5, + "has_conditionals": true, + "has_loops": false, + "composite_risk_class": "high", + "script_source": "ptc", + }, + }) + require.NoError(t, err) + + chain := NewChain("compiled-action") + require.NoError(t, AppendToChain(chain, r)) + v, err := VerifyChain(chain) + require.NoError(t, err) + require.True(t, v.Intact) + require.Equal(t, 1, v.Count) +} + +func TestRecordContextMetadataKeysCompatibility(t *testing.T) { + withContext, err := NewRecord(RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 16, 3, 0, 0, time.UTC), + Source: "axym", + SourceProduct: "axym", + Type: "decision", + Event: map[string]any{"action": "allow"}, + Metadata: map[string]any{ + "data_class": "pii", + "endpoint_class": "write", + "risk_level": "high", + "business_process": "customer-support", + "affected_entities": []string{"ticket:10", "customer:42"}, + }, + }) + require.NoError(t, err) + require.NoError(t, ValidateRecord(withContext)) + + withoutContext, err := NewRecord(RecordOpts{ + Timestamp: time.Date(2026, 2, 20, 16, 4, 0, 0, time.UTC), + Source: "axym", + SourceProduct: "axym", + Type: "decision", + Event: map[string]any{"action": "allow"}, + }) + require.NoError(t, err) + require.NoError(t, ValidateRecord(withoutContext)) +} diff --git a/core/framework/eu-ai-act.yaml b/core/framework/eu-ai-act.yaml index 84cfd26..56947e4 100644 --- a/core/framework/eu-ai-act.yaml +++ b/core/framework/eu-ai-act.yaml @@ -10,11 +10,11 @@ controls: minimum_frequency: quarterly - id: article-12 title: Record-Keeping - required_record_types: [tool_invocation, decision, guardrail_activation, permission_check] + required_record_types: [tool_invocation, decision, guardrail_activation, permission_check, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous - id: article-14 title: Human Oversight - required_record_types: [human_oversight, approval] + required_record_types: [human_oversight, approval, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: per-event diff --git a/core/framework/pci-dss.yaml b/core/framework/pci-dss.yaml index dca2fd6..95fcc39 100644 --- a/core/framework/pci-dss.yaml +++ b/core/framework/pci-dss.yaml @@ -5,6 +5,6 @@ framework: controls: - id: req-10 title: Logging and Monitoring - required_record_types: [tool_invocation, permission_check, incident] + required_record_types: [tool_invocation, permission_check, incident, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous diff --git a/core/framework/soc2.yaml b/core/framework/soc2.yaml index fd6a325..da95b4d 100644 --- a/core/framework/soc2.yaml +++ b/core/framework/soc2.yaml @@ -10,6 +10,11 @@ controls: minimum_frequency: continuous - id: cc7 title: System Operations - required_record_types: [incident, guardrail_activation] + required_record_types: [incident, guardrail_activation, compiled_action] + required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: cc8 + title: Change Management + required_record_types: [approval, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous diff --git a/core/schema/schema.go b/core/schema/schema.go index 0f10f66..199f4e1 100644 --- a/core/schema/schema.go +++ b/core/schema/schema.go @@ -37,6 +37,7 @@ var builtins = []RecordType{ {Name: "data_pipeline_run", Description: "A data pipeline executed", SchemaPath: "v1/types/data-pipeline-run.schema.json"}, {Name: "replay_certification", Description: "A replay was run and certified", SchemaPath: "v1/types/replay-certification.schema.json"}, {Name: "approval", Description: "An approval or delegation was issued", SchemaPath: "v1/types/approval.schema.json"}, + {Name: "compiled_action", Description: "A compound agent action was compiled for execution", SchemaPath: "v1/types/compiled-action.schema.json"}, } var customMu sync.RWMutex diff --git a/core/schema/schema_test.go b/core/schema/schema_test.go index 4993351..b31675e 100644 --- a/core/schema/schema_test.go +++ b/core/schema/schema_test.go @@ -98,3 +98,11 @@ func TestValidateAgainstSchemaAndErrors(t *testing.T) { require.Error(t, ValidateAgainstSchema([]byte("{"), "v1/chain-v1.schema.json")) require.Error(t, ValidateAgainstSchema(raw, "v1/missing.schema.json")) } + +func TestValidateGovernanceEventSchema(t *testing.T) { + valid := []byte(`{"event_id":"evt-1","timestamp":"2026-02-20T12:00:00Z","event_type":"tool_gate"}`) + require.NoError(t, ValidateAgainstSchema(valid, "v1/governance-event-v1.schema.json")) + + invalid := []byte(`{"timestamp":"2026-02-20T12:00:00Z","event_type":"tool_gate"}`) + require.Error(t, ValidateAgainstSchema(invalid, "v1/governance-event-v1.schema.json")) +} diff --git a/core/schema/v1/governance-event-v1.schema.json b/core/schema/v1/governance-event-v1.schema.json new file mode 100644 index 0000000..7f74737 --- /dev/null +++ b/core/schema/v1/governance-event-v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Clyra-AI/proof/schemas/v1/governance-event-v1.schema.json", + "type": "object", + "required": ["event_id", "timestamp", "event_type"], + "properties": { + "event_id": { "type": "string", "minLength": 1 }, + "timestamp": { "type": "string", "format": "date-time" }, + "event_type": { + "type": "string", + "enum": [ + "tool_gate", + "permission_check", + "approval_request", + "policy_evaluation", + "guardrail_activation", + "script_evaluation" + ] + }, + "agent_id": { "type": "string", "minLength": 1 }, + "tool_name": { "type": "string", "minLength": 1 }, + "verdict": { + "type": "string", + "enum": ["allow", "block", "dry_run", "require_approval", "pending"] + }, + "context": { + "type": "object", + "additionalProperties": true + }, + "detail": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/core/schema/v1/types/compiled-action.schema.json b/core/schema/v1/types/compiled-action.schema.json new file mode 100644 index 0000000..5fc991f --- /dev/null +++ b/core/schema/v1/types/compiled-action.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Clyra-AI/proof/schemas/v1/types/compiled-action.schema.json", + "type": "object", + "required": ["record_type", "event"], + "properties": { + "record_type": { "const": "compiled_action" }, + "event": { + "type": "object", + "required": [ + "script_hash", + "tool_sequence", + "step_count", + "has_conditionals", + "has_loops", + "composite_risk_class" + ], + "properties": { + "script_hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "tool_sequence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "step_count": { "type": "integer", "minimum": 1 }, + "has_conditionals": { "type": "boolean" }, + "has_loops": { "type": "boolean" }, + "composite_risk_class": { + "type": "string", + "enum": ["minimal", "limited", "high", "unacceptable"] + }, + "execution_trace_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^prf-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[a-f0-9]{8}$" + } + }, + "gate_verdict": { + "type": "string", + "enum": ["allow", "block", "dry_run", "require_approval"] + }, + "script_source": { + "type": "string", + "enum": ["ptc", "agent_planner", "workflow_engine", "unknown"] + } + } + } + } +} diff --git a/docs/governance-events.md b/docs/governance-events.md new file mode 100644 index 0000000..b00bc2d --- /dev/null +++ b/docs/governance-events.md @@ -0,0 +1,46 @@ +# Governance Events + +Governance events are lightweight, unsigned JSON objects for real-time telemetry. They are intentionally simple to emit from any runtime (for example JSONL to stdout), then later promoted into signed proof records. + +## Two-tier model + +1. Emit `GovernanceEvent` JSON (`event_id`, `timestamp`, `event_type`, optional context/detail). +2. Validate against `schemas/v1/governance-event-v1.schema.json`. +3. Promote to a signed proof record with `proof.NewRecordFromEvent(...)`. + +Events are not proof records. They are input artifacts for promotion. + +## Event type vocabulary + +- `tool_gate` +- `permission_check` +- `approval_request` +- `policy_evaluation` +- `guardrail_activation` +- `script_evaluation` + +## Emission examples + +```python +import json, datetime +print(json.dumps({"event_id":"evt-1","timestamp":datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00","Z"),"event_type":"tool_gate"})) +``` + +```ts +console.log(JSON.stringify({ event_id: "evt-1", timestamp: new Date().toISOString(), event_type: "tool_gate" })); +``` + +```go +_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"event_id": "evt-1", "timestamp": time.Now().UTC().Format(time.RFC3339), "event_type": "tool_gate"}) +``` + +## Promotion mapping + +`proof.NewRecordFromEvent` maps governance event types to proof record types: + +- `tool_gate` -> `policy_enforcement` +- `permission_check` -> `permission_check` +- `approval_request` -> `guardrail_activation` +- `policy_evaluation` -> `policy_enforcement` +- `guardrail_activation` -> `guardrail_activation` +- `script_evaluation` -> `compiled_action` diff --git a/docs/record-context-keys.md b/docs/record-context-keys.md new file mode 100644 index 0000000..743dc6a --- /dev/null +++ b/docs/record-context-keys.md @@ -0,0 +1,17 @@ +# Record Context Keys + +Proof records allow optional `metadata` for context enrichment. These are conventions for cross-product interoperability, not schema-required fields. + +## Recommended keys + +- `data_class` (string): `public` | `internal` | `confidential` | `pii` | `credentials` +- `endpoint_class` (string): `read` | `write` | `exec` | `admin` +- `risk_level` (string): `minimal` | `limited` | `high` | `unacceptable` +- `business_process` (string): workflow identifier or business process id +- `affected_entities` ([]string): entity identifiers affected by the action + +## Compatibility + +- Records with these keys are valid. +- Records without these keys are valid. +- Downstream products may use these keys for filtering and control matching, but Proof itself remains policy-neutral. diff --git a/frameworks/eu-ai-act.yaml b/frameworks/eu-ai-act.yaml index 84cfd26..56947e4 100644 --- a/frameworks/eu-ai-act.yaml +++ b/frameworks/eu-ai-act.yaml @@ -10,11 +10,11 @@ controls: minimum_frequency: quarterly - id: article-12 title: Record-Keeping - required_record_types: [tool_invocation, decision, guardrail_activation, permission_check] + required_record_types: [tool_invocation, decision, guardrail_activation, permission_check, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous - id: article-14 title: Human Oversight - required_record_types: [human_oversight, approval] + required_record_types: [human_oversight, approval, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: per-event diff --git a/frameworks/pci-dss.yaml b/frameworks/pci-dss.yaml index dca2fd6..95fcc39 100644 --- a/frameworks/pci-dss.yaml +++ b/frameworks/pci-dss.yaml @@ -5,6 +5,6 @@ framework: controls: - id: req-10 title: Logging and Monitoring - required_record_types: [tool_invocation, permission_check, incident] + required_record_types: [tool_invocation, permission_check, incident, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous diff --git a/frameworks/soc2.yaml b/frameworks/soc2.yaml index fd6a325..da95b4d 100644 --- a/frameworks/soc2.yaml +++ b/frameworks/soc2.yaml @@ -10,6 +10,11 @@ controls: minimum_frequency: continuous - id: cc7 title: System Operations - required_record_types: [incident, guardrail_activation] + required_record_types: [incident, guardrail_activation, compiled_action] + required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: cc8 + title: Change Management + required_record_types: [approval, compiled_action] required_fields: [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] minimum_frequency: continuous diff --git a/governance.go b/governance.go new file mode 100644 index 0000000..c1c916b --- /dev/null +++ b/governance.go @@ -0,0 +1,128 @@ +package proof + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Clyra-AI/proof/core/schema" +) + +// GovernanceEvent is a lightweight, unsigned governance signal that can be +// promoted into a signed proof record. +type GovernanceEvent struct { + EventID string `json:"event_id"` + Timestamp string `json:"timestamp"` + EventType string `json:"event_type"` + AgentID string `json:"agent_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Verdict string `json:"verdict,omitempty"` + Context map[string]any `json:"context,omitempty"` + Detail map[string]any `json:"detail,omitempty"` +} + +// ValidateGovernanceEvent validates a governance event against the embedded +// governance event schema. +func ValidateGovernanceEvent(event GovernanceEvent) error { + raw, err := json.Marshal(event) + if err != nil { + return err + } + if err := schema.ValidateAgainstSchema(raw, "v1/governance-event-v1.schema.json"); err != nil { + return fmt.Errorf("governance event validation failed: %w", err) + } + if _, err := parseGovernanceTimestamp(event.Timestamp); err != nil { + return fmt.Errorf("governance event validation failed: %w", err) + } + return nil +} + +// NewRecordFromEvent creates a proof.Record from a validated governance event. +// The caller is responsible for signing and chain-appending the returned record. +func NewRecordFromEvent(event GovernanceEvent, source string) (*Record, error) { + if err := ValidateGovernanceEvent(event); err != nil { + return nil, err + } + ts, err := parseGovernanceTimestamp(event.Timestamp) + if err != nil { + return nil, err + } + recordType, err := governanceEventRecordType(event.EventType) + if err != nil { + return nil, err + } + + eventPayload := cloneAnyMap(event.Detail) + if eventPayload == nil { + eventPayload = map[string]any{} + } + if _, ok := eventPayload["event_id"]; !ok { + eventPayload["event_id"] = strings.TrimSpace(event.EventID) + } + if _, ok := eventPayload["event_type"]; !ok { + eventPayload["event_type"] = strings.TrimSpace(event.EventType) + } + if strings.TrimSpace(event.ToolName) != "" { + if _, ok := eventPayload["tool_name"]; !ok { + eventPayload["tool_name"] = strings.TrimSpace(event.ToolName) + } + } + if strings.TrimSpace(event.Verdict) != "" { + if recordType == "compiled_action" { + if _, ok := eventPayload["gate_verdict"]; !ok { + eventPayload["gate_verdict"] = strings.TrimSpace(event.Verdict) + } + } else if _, ok := eventPayload["verdict"]; !ok { + eventPayload["verdict"] = strings.TrimSpace(event.Verdict) + } + } + + return NewRecord(RecordOpts{ + Timestamp: ts, + Source: strings.TrimSpace(source), + SourceProduct: strings.TrimSpace(source), + AgentID: strings.TrimSpace(event.AgentID), + Type: recordType, + Event: eventPayload, + Metadata: cloneAnyMap(event.Context), + }) +} + +func governanceEventRecordType(eventType string) (string, error) { + switch strings.TrimSpace(eventType) { + case "tool_gate": + return "policy_enforcement", nil + case "permission_check": + return "permission_check", nil + case "approval_request": + return "guardrail_activation", nil + case "policy_evaluation": + return "policy_enforcement", nil + case "guardrail_activation": + return "guardrail_activation", nil + case "script_evaluation": + return "compiled_action", nil + default: + return "", fmt.Errorf("unsupported governance event_type: %s", eventType) + } +} + +func parseGovernanceTimestamp(raw string) (time.Time, error) { + ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) + if err != nil { + return time.Time{}, fmt.Errorf("invalid timestamp %q: %w", raw, err) + } + return ts.UTC(), nil +} + +func cloneAnyMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/governance_test.go b/governance_test.go new file mode 100644 index 0000000..ee2096c --- /dev/null +++ b/governance_test.go @@ -0,0 +1,209 @@ +package proof + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestValidateGovernanceEventFixtures(t *testing.T) { + fixtures := []string{ + "tool_gate.json", + "permission_check.json", + "approval_request.json", + "policy_evaluation.json", + "guardrail_activation.json", + "script_evaluation.json", + } + for _, name := range fixtures { + t.Run(name, func(t *testing.T) { + event := loadGovernanceEventFixture(t, name) + require.NoError(t, ValidateGovernanceEvent(event)) + }) + } +} + +func TestValidateGovernanceEventMinimalAndRejectMissingRequired(t *testing.T) { + require.NoError(t, ValidateGovernanceEvent(loadGovernanceEventFixture(t, "minimal.json"))) + require.Error(t, ValidateGovernanceEvent(loadGovernanceEventFixture(t, "invalid-missing-required.json"))) +} + +func TestNewRecordFromEventMappings(t *testing.T) { + cases := []struct { + name string + event GovernanceEvent + expectType string + }{ + { + name: "tool_gate", + event: GovernanceEvent{ + EventID: "evt-1", + Timestamp: "2026-02-20T14:00:00Z", + EventType: "tool_gate", + AgentID: "agent-a", + ToolName: "shell.exec", + Verdict: "allow", + Context: map[string]any{ + "risk_level": "limited", + }, + Detail: map[string]any{ + "rule_id": "policy-1", + }, + }, + expectType: "policy_enforcement", + }, + { + name: "permission_check", + event: GovernanceEvent{ + EventID: "evt-2", + Timestamp: "2026-02-20T14:01:00Z", + EventType: "permission_check", + Verdict: "allow", + Detail: map[string]any{ + "permission": "repo.write", + }, + }, + expectType: "permission_check", + }, + { + name: "approval_request", + event: GovernanceEvent{ + EventID: "evt-3", + Timestamp: "2026-02-20T14:02:00Z", + EventType: "approval_request", + Verdict: "require_approval", + Detail: map[string]any{ + "reason": "high-risk-op", + }, + }, + expectType: "guardrail_activation", + }, + { + name: "policy_evaluation", + event: GovernanceEvent{ + EventID: "evt-4", + Timestamp: "2026-02-20T14:03:00Z", + EventType: "policy_evaluation", + Verdict: "allow", + Detail: map[string]any{ + "policy_set": "default", + }, + }, + expectType: "policy_enforcement", + }, + { + name: "guardrail_activation", + event: GovernanceEvent{ + EventID: "evt-5", + Timestamp: "2026-02-20T14:04:00Z", + EventType: "guardrail_activation", + Verdict: "block", + Detail: map[string]any{ + "guardrail": "secret-redaction", + }, + }, + expectType: "guardrail_activation", + }, + { + name: "script_evaluation", + event: GovernanceEvent{ + EventID: "evt-6", + Timestamp: "2026-02-20T14:05:00Z", + EventType: "script_evaluation", + Verdict: "dry_run", + Detail: map[string]any{ + "script_hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "tool_sequence": []string{"shell.exec", "http.request", "db.query", "fs.write", "notify.send"}, + "step_count": 5, + "has_conditionals": true, + "has_loops": false, + "composite_risk_class": "high", + "script_source": "ptc", + }, + }, + expectType: "compiled_action", + }, + } + + chain := NewChain("promoted") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, err := NewRecordFromEvent(tc.event, "axym") + require.NoError(t, err) + require.Equal(t, tc.expectType, r.RecordType) + require.NoError(t, ValidateRecord(r)) + require.Equal(t, "axym", r.Source) + require.Equal(t, "axym", r.SourceProduct) + require.Equal(t, tc.event.EventID, r.Event["event_id"]) + require.Equal(t, tc.event.EventType, r.Event["event_type"]) + if tc.event.Context != nil { + require.NotNil(t, r.Metadata) + } + require.NoError(t, AppendToChain(chain, r)) + }) + } + + verification, err := VerifyChain(chain) + require.NoError(t, err) + require.True(t, verification.Intact) + require.Equal(t, len(cases), verification.Count) + + key, err := GenerateSigningKey() + require.NoError(t, err) + for i := range chain.Records { + _, err := Sign(&chain.Records[i], key) + require.NoError(t, err) + require.NoError(t, Verify(&chain.Records[i], PublicKey{Public: key.Public})) + } +} + +func TestNewRecordFromEventErrors(t *testing.T) { + _, err := NewRecordFromEvent(GovernanceEvent{}, "axym") + require.Error(t, err) + + _, err = NewRecordFromEvent(GovernanceEvent{ + EventID: "evt-bad-type", + Timestamp: "2026-02-20T15:00:00Z", + EventType: "not-real", + }, "axym") + require.Error(t, err) + + _, err = NewRecordFromEvent(GovernanceEvent{ + EventID: "evt-script-missing", + Timestamp: "2026-02-20T15:01:00Z", + EventType: "script_evaluation", + }, "axym") + require.Error(t, err) + + _, err = NewRecordFromEvent(GovernanceEvent{ + EventID: "evt-empty-source", + Timestamp: "2026-02-20T15:02:00Z", + EventType: "tool_gate", + }, "") + require.Error(t, err) +} + +func loadGovernanceEventFixture(t *testing.T, name string) GovernanceEvent { + t.Helper() + path := filepath.Join("testdata", "governance_events", name) + raw, err := os.ReadFile(path) + require.NoError(t, err) + var event GovernanceEvent + require.NoError(t, json.Unmarshal(raw, &event)) + return event +} + +func TestGovernanceTimestampIsNormalizedToUTC(t *testing.T) { + event := GovernanceEvent{ + EventID: "evt-tz", + Timestamp: "2026-02-20T11:00:00-05:00", + EventType: "tool_gate", + } + r, err := NewRecordFromEvent(event, "axym") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 2, 20, 16, 0, 0, 0, time.UTC), r.Timestamp) +} diff --git a/internal/scenarios/scenario_test.go b/internal/scenarios/scenario_test.go index c411618..3991275 100644 --- a/internal/scenarios/scenario_test.go +++ b/internal/scenarios/scenario_test.go @@ -74,6 +74,14 @@ func runScenario(t *testing.T, binary, dir string) { require.Contains(t, out, "Chain intact") require.Contains(t, out, strconv.Itoa(expected.Count)+" records") + case "compiled-action-chain-round-trip": + require.Equal(t, "pass", expected.Verify) + require.Equal(t, "intact", expected.Chain) + out, code := runProof(binary, "verify", dir) + require.Equal(t, 0, code, out) + require.Contains(t, out, "Chain intact") + require.Contains(t, out, strconv.Itoa(expected.Count)+" records") + case "chain-tamper-detection": require.Equal(t, "fail", expected.Verify) tempDir := t.TempDir() diff --git a/scenarios/CHANGELOG.md b/scenarios/CHANGELOG.md index baceee6..b0244af 100644 --- a/scenarios/CHANGELOG.md +++ b/scenarios/CHANGELOG.md @@ -1,5 +1,9 @@ # Scenario Changelog +## 2026-02-20 + +- Added: compiled-action-chain-round-trip (validates compiled_action record verification in chain mode) + ## 2026-02-18 - Added: chain-round-trip (validates basic chain append + verify) diff --git a/scenarios/proof/compiled-action-chain-round-trip/README.md b/scenarios/proof/compiled-action-chain-round-trip/README.md new file mode 100644 index 0000000..3ea21b0 --- /dev/null +++ b/scenarios/proof/compiled-action-chain-round-trip/README.md @@ -0,0 +1,3 @@ +# compiled-action-chain-round-trip + +Validates that a compiled action proof record can be verified in a chain context. diff --git a/scenarios/proof/compiled-action-chain-round-trip/expected.yaml b/scenarios/proof/compiled-action-chain-round-trip/expected.yaml new file mode 100644 index 0000000..1bf9685 --- /dev/null +++ b/scenarios/proof/compiled-action-chain-round-trip/expected.yaml @@ -0,0 +1,3 @@ +verify: pass +chain: intact +count: 1 diff --git a/scenarios/proof/compiled-action-chain-round-trip/input-records.jsonl b/scenarios/proof/compiled-action-chain-round-trip/input-records.jsonl new file mode 100644 index 0000000..3346d6c --- /dev/null +++ b/scenarios/proof/compiled-action-chain-round-trip/input-records.jsonl @@ -0,0 +1 @@ +{"record_id":"prf-2026-02-20T13:00:00Z-a19ef462","record_version":"1.0","timestamp":"2026-02-20T13:00:00Z","source":"gait","source_product":"gait","record_type":"compiled_action","event":{"composite_risk_class":"high","execution_trace_refs":["prf-2026-02-20T13:01:00Z-aaaaaaaa","prf-2026-02-20T13:01:01Z-bbbbbbbb"],"gate_verdict":"allow","has_conditionals":true,"has_loops":false,"script_hash":"sha256:1111111111111111111111111111111111111111111111111111111111111111","script_source":"ptc","step_count":5,"tool_sequence":["shell.exec","fs.write","http.request","db.query","notify.send"]},"controls":{"permissions_enforced":false},"metadata":{"affected_entities":["customer:42","txn:99"],"business_process":"payments-refund","data_class":"pii","endpoint_class":"write","risk_level":"high"},"integrity":{"record_hash":"sha256:70f67498d1883cad8ac61129ef0f2e43c7e6c7700c02283e11a47d21d66c65f9"}} diff --git a/schemas/v1/governance-event-v1.schema.json b/schemas/v1/governance-event-v1.schema.json new file mode 100644 index 0000000..7f74737 --- /dev/null +++ b/schemas/v1/governance-event-v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Clyra-AI/proof/schemas/v1/governance-event-v1.schema.json", + "type": "object", + "required": ["event_id", "timestamp", "event_type"], + "properties": { + "event_id": { "type": "string", "minLength": 1 }, + "timestamp": { "type": "string", "format": "date-time" }, + "event_type": { + "type": "string", + "enum": [ + "tool_gate", + "permission_check", + "approval_request", + "policy_evaluation", + "guardrail_activation", + "script_evaluation" + ] + }, + "agent_id": { "type": "string", "minLength": 1 }, + "tool_name": { "type": "string", "minLength": 1 }, + "verdict": { + "type": "string", + "enum": ["allow", "block", "dry_run", "require_approval", "pending"] + }, + "context": { + "type": "object", + "additionalProperties": true + }, + "detail": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/schemas/v1/types/compiled-action.schema.json b/schemas/v1/types/compiled-action.schema.json new file mode 100644 index 0000000..5fc991f --- /dev/null +++ b/schemas/v1/types/compiled-action.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Clyra-AI/proof/schemas/v1/types/compiled-action.schema.json", + "type": "object", + "required": ["record_type", "event"], + "properties": { + "record_type": { "const": "compiled_action" }, + "event": { + "type": "object", + "required": [ + "script_hash", + "tool_sequence", + "step_count", + "has_conditionals", + "has_loops", + "composite_risk_class" + ], + "properties": { + "script_hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "tool_sequence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "step_count": { "type": "integer", "minimum": 1 }, + "has_conditionals": { "type": "boolean" }, + "has_loops": { "type": "boolean" }, + "composite_risk_class": { + "type": "string", + "enum": ["minimal", "limited", "high", "unacceptable"] + }, + "execution_trace_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^prf-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[a-f0-9]{8}$" + } + }, + "gate_verdict": { + "type": "string", + "enum": ["allow", "block", "dry_run", "require_approval"] + }, + "script_source": { + "type": "string", + "enum": ["ptc", "agent_planner", "workflow_engine", "unknown"] + } + } + } + } +} diff --git a/testdata/governance_events/approval_request.json b/testdata/governance_events/approval_request.json new file mode 100644 index 0000000..956c584 --- /dev/null +++ b/testdata/governance_events/approval_request.json @@ -0,0 +1 @@ +{"event_id":"evt-approval-request","timestamp":"2026-02-20T12:02:00Z","event_type":"approval_request","verdict":"require_approval"} diff --git a/testdata/governance_events/guardrail_activation.json b/testdata/governance_events/guardrail_activation.json new file mode 100644 index 0000000..9a76120 --- /dev/null +++ b/testdata/governance_events/guardrail_activation.json @@ -0,0 +1 @@ +{"event_id":"evt-guardrail-activation","timestamp":"2026-02-20T12:04:00Z","event_type":"guardrail_activation","verdict":"block"} diff --git a/testdata/governance_events/invalid-missing-required.json b/testdata/governance_events/invalid-missing-required.json new file mode 100644 index 0000000..fd524e8 --- /dev/null +++ b/testdata/governance_events/invalid-missing-required.json @@ -0,0 +1 @@ +{"timestamp":"2026-02-20T12:07:00Z","event_type":"tool_gate"} diff --git a/testdata/governance_events/minimal.json b/testdata/governance_events/minimal.json new file mode 100644 index 0000000..36b4626 --- /dev/null +++ b/testdata/governance_events/minimal.json @@ -0,0 +1 @@ +{"event_id":"evt-minimal","timestamp":"2026-02-20T12:06:00Z","event_type":"tool_gate"} diff --git a/testdata/governance_events/permission_check.json b/testdata/governance_events/permission_check.json new file mode 100644 index 0000000..72634af --- /dev/null +++ b/testdata/governance_events/permission_check.json @@ -0,0 +1 @@ +{"event_id":"evt-permission-check","timestamp":"2026-02-20T12:01:00Z","event_type":"permission_check","tool_name":"fs.write","verdict":"allow"} diff --git a/testdata/governance_events/policy_evaluation.json b/testdata/governance_events/policy_evaluation.json new file mode 100644 index 0000000..60bd54e --- /dev/null +++ b/testdata/governance_events/policy_evaluation.json @@ -0,0 +1 @@ +{"event_id":"evt-policy-evaluation","timestamp":"2026-02-20T12:03:00Z","event_type":"policy_evaluation","verdict":"allow"} diff --git a/testdata/governance_events/script_evaluation.json b/testdata/governance_events/script_evaluation.json new file mode 100644 index 0000000..2083fbc --- /dev/null +++ b/testdata/governance_events/script_evaluation.json @@ -0,0 +1 @@ +{"event_id":"evt-script-evaluation","timestamp":"2026-02-20T12:05:00Z","event_type":"script_evaluation","verdict":"dry_run"} diff --git a/testdata/governance_events/tool_gate.json b/testdata/governance_events/tool_gate.json new file mode 100644 index 0000000..e6fb2a1 --- /dev/null +++ b/testdata/governance_events/tool_gate.json @@ -0,0 +1 @@ +{"event_id":"evt-tool-gate","timestamp":"2026-02-20T12:00:00Z","event_type":"tool_gate","tool_name":"shell.exec","verdict":"allow"} diff --git a/testdata/records/compiled_action_full.json b/testdata/records/compiled_action_full.json new file mode 100644 index 0000000..769edfd --- /dev/null +++ b/testdata/records/compiled_action_full.json @@ -0,0 +1,44 @@ +{ + "record_id": "prf-2026-02-20T13:00:00Z-a19ef462", + "record_version": "1.0", + "timestamp": "2026-02-20T13:00:00Z", + "source": "gait", + "source_product": "gait", + "record_type": "compiled_action", + "event": { + "composite_risk_class": "high", + "execution_trace_refs": [ + "prf-2026-02-20T13:01:00Z-aaaaaaaa", + "prf-2026-02-20T13:01:01Z-bbbbbbbb" + ], + "gate_verdict": "allow", + "has_conditionals": true, + "has_loops": false, + "script_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "script_source": "ptc", + "step_count": 5, + "tool_sequence": [ + "shell.exec", + "fs.write", + "http.request", + "db.query", + "notify.send" + ] + }, + "controls": { + "permissions_enforced": false + }, + "metadata": { + "affected_entities": [ + "customer:42", + "txn:99" + ], + "business_process": "payments-refund", + "data_class": "pii", + "endpoint_class": "write", + "risk_level": "high" + }, + "integrity": { + "record_hash": "sha256:70f67498d1883cad8ac61129ef0f2e43c7e6c7700c02283e11a47d21d66c65f9" + } +} \ No newline at end of file diff --git a/testdata/records/compiled_action_minimal.json b/testdata/records/compiled_action_minimal.json new file mode 100644 index 0000000..17543f2 --- /dev/null +++ b/testdata/records/compiled_action_minimal.json @@ -0,0 +1,24 @@ +{ + "record_id": "prf-2026-02-20T13:10:00Z-9dac8ec3", + "record_version": "1.0", + "timestamp": "2026-02-20T13:10:00Z", + "source": "gait", + "source_product": "gait", + "record_type": "compiled_action", + "event": { + "composite_risk_class": "limited", + "has_conditionals": false, + "has_loops": false, + "script_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "step_count": 1, + "tool_sequence": [ + "shell.exec" + ] + }, + "controls": { + "permissions_enforced": false + }, + "integrity": { + "record_hash": "sha256:717a171c00b635e7023719a758b7188c65d3b3c36dd32adee57a57fa5920ccbf" + } +} \ No newline at end of file