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
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,23 @@ jobs:
with:
go-version-file: go.mod
- run: go build ./cmd/proof

gait-compat:
name: Gait compatibility
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Clone gait
run: git clone https://github.com/Clyra-AI/gait.git /tmp/gait
- name: Point gait at proof HEAD
run: |
cd /tmp/gait
go mod edit -replace github.com/Clyra-AI/proof=$GITHUB_WORKSPACE
go mod tidy
- name: Build gait
run: cd /tmp/gait && go build ./cmd/gait
- name: Test gait
run: cd /tmp/gait && go test ./...
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang 1.25.7
python 3.13.7
5 changes: 5 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Default owner — all files
* @davidahmann

# Scenario fixtures — human approval required for expected outcome changes
scenarios/ @davidahmann
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
BINARY := proof
GOLANGCI_LINT_VERSION := v2.0.1
GOSEC_VERSION := v2.23.0
GOVULNCHECK_VERSION := v1.1.4

.PHONY: fmt lint test build tidy contract coverage hooks prepush prepush-full test-integration test-e2e test-acceptance test-hardening test-chaos test-performance test-soak test-uat-local
.PHONY: fmt lint lint-full test build tidy contract coverage hooks prepush prepush-full test-integration test-e2e test-acceptance test-hardening test-chaos test-performance test-soak test-scenarios test-uat-local

fmt:
gofmt -w .
Expand All @@ -9,6 +12,12 @@ lint:
go vet ./...
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; fi

lint-full:
go vet ./...
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) && golangci-lint run ./...
go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) && gosec ./...
go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) && govulncheck ./...

gotest:
go test ./...

Expand Down Expand Up @@ -50,6 +59,7 @@ prepush-full:
$(MAKE) test-chaos
$(MAKE) test-performance
$(MAKE) test-soak
$(MAKE) test-scenarios

test-integration:
./scripts/test_integration.sh
Expand All @@ -72,5 +82,8 @@ test-performance:
test-soak:
./scripts/test_soak.sh

test-scenarios:
go test ./internal/scenarios -count=1 -tags=scenario -v

test-uat-local:
./scripts/test_uat_local.sh
194 changes: 194 additions & 0 deletions internal/scenarios/scenario_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//go:build scenario

package scenarios_test

import (
"bufio"
"encoding/hex"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"

"github.com/Clyra-AI/proof"
"github.com/Clyra-AI/proof/internal/testutil"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

type expectedInput struct {
File string `yaml:"file"`
ExitCode int `yaml:"exit_code"`
}

type expectedScenario struct {
Verify string `yaml:"verify"`
Count int `yaml:"count"`
Chain string `yaml:"chain"`
BreakPoint int `yaml:"break_point"`
Sign string `yaml:"sign"`
Algorithm string `yaml:"algorithm"`
Total int `yaml:"total"`
Sources []string `yaml:"sources"`
InvalidInputs []expectedInput `yaml:"invalid_inputs"`
}

func TestScenarios(t *testing.T) {
root := testutil.RepoRoot(t)
scenarioDir := filepath.Join(root, "scenarios", "proof")

entries, err := os.ReadDir(scenarioDir)
if err != nil {
t.Fatalf("read scenario dir: %v", err)
}

binary := testutil.BuildBinary(t, root)

for _, entry := range entries {
if !entry.IsDir() {
continue
}
t.Run(entry.Name(), func(t *testing.T) {
dir := filepath.Join(scenarioDir, entry.Name())
runScenario(t, binary, dir)
})
}
}

func runScenario(t *testing.T, binary, dir string) {
t.Helper()
expected := loadExpected(t, filepath.Join(dir, "expected.yaml"))
name := filepath.Base(dir)

switch name {
case "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()
copyFile(t, filepath.Join(dir, "tamper-record-5.jsonl"), filepath.Join(tempDir, "records.jsonl"))
out, code := runProof(binary, "verify", tempDir)
require.Equal(t, 2, code, out)
require.Contains(t, out, "chain verification failed at index")
if expected.BreakPoint > 0 {
re := regexp.MustCompile(`index ([0-9]+)`)
match := re.FindStringSubmatch(out)
require.Len(t, match, 2, "missing break index in output: %s", out)
index, err := strconv.Atoi(match[1])
require.NoError(t, err)
require.Equal(t, expected.BreakPoint, index+1)
}

case "signing-verify-round-trip":
require.Equal(t, "success", expected.Sign)
require.Equal(t, "pass", expected.Verify)
require.Equal(t, "ed25519", expected.Algorithm)
recordPath := filepath.Join(dir, "input-record.json")
r, err := proof.ReadRecord(recordPath)
require.NoError(t, err)
key, err := proof.GenerateSigningKey()
require.NoError(t, err)
_, err = proof.Sign(r, key)
require.NoError(t, err)
require.NotEmpty(t, r.Integrity.SigningKeyID)
require.True(t, strings.HasPrefix(r.Integrity.Signature, "base64:"))
signedPath := filepath.Join(t.TempDir(), "signed-record.json")
require.NoError(t, proof.WriteRecord(signedPath, r))
out, code := runProof(binary, "verify", "--signatures", "--public-key", hex.EncodeToString(key.Public), signedPath)
require.Equal(t, 0, code, out)
require.Contains(t, out, "Record verified")

case "schema-validation-reject":
require.NotEmpty(t, expected.InvalidInputs)
for _, tc := range expected.InvalidInputs {
out, code := runProof(binary, "verify", filepath.Join(dir, tc.File))
require.Equalf(t, tc.ExitCode, code, "file=%s output=%s", tc.File, out)
}

case "cross-product-mixed-chain":
require.Equal(t, "pass", expected.Verify)
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.Total)+" records")
foundSources := readSources(t,
filepath.Join(dir, "axym-records.jsonl"),
filepath.Join(dir, "gait-records.jsonl"),
filepath.Join(dir, "wrkr-records.jsonl"),
)
for _, source := range expected.Sources {
_, ok := foundSources[source]
require.Truef(t, ok, "expected source %s not present", source)
}

default:
t.Fatalf("unsupported scenario: %s", name)
}
}

func loadExpected(t *testing.T, path string) expectedScenario {
t.Helper()
raw, err := os.ReadFile(path)
require.NoError(t, err)
var expected expectedScenario
require.NoError(t, yaml.Unmarshal(raw, &expected))
return expected
}

func runProof(binary string, args ...string) (string, int) {
cmd := exec.Command(binary, args...) // #nosec G204 -- test harness executes fixed binary with fixture args.
out, err := cmd.CombinedOutput()
if err == nil {
return string(out), 0
}
exitErr, ok := err.(*exec.ExitError)
if !ok {
return string(out), 1
}
return string(out), exitErr.ExitCode()
}

func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
require.NoError(t, err)
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
require.NoError(t, err)
defer func() { _ = out.Close() }()
_, err = io.Copy(out, in)
require.NoError(t, err)
}

func readSources(t *testing.T, paths ...string) map[string]struct{} {
t.Helper()
out := map[string]struct{}{}
for _, path := range paths {
f, err := os.Open(path)
require.NoError(t, err)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record proof.Record
require.NoError(t, json.Unmarshal([]byte(line), &record))
out[record.Source] = struct{}{}
}
require.NoError(t, scanner.Err())
_ = f.Close()
}
return out
}
10 changes: 10 additions & 0 deletions scenarios/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Scenario Changelog

## 2026-02-18

- Added: chain-round-trip (validates basic chain append + verify)
- Added: chain-tamper-detection (validates integrity break on modified record)
- Added: signing-verify-round-trip (validates Ed25519 sign + verify cycle)
- Added: schema-validation-reject (validates exit code 6 on invalid input)
- Added: cross-product-mixed-chain (validates mixed-source chain integrity)
- Author: @davidahmann
7 changes: 7 additions & 0 deletions scenarios/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Scenarios

Scenario fixtures for acceptance and regression checks.

- Source of truth for expected outcomes lives in each scenario `expected.yaml`.
- Scenario tests run with `make test-scenarios`.
- Changes to expected outcomes require explicit human review (see `CODEOWNERS`).
3 changes: 3 additions & 0 deletions scenarios/proof/chain-round-trip/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# chain-round-trip

Validates that a deterministic 5-record proof chain verifies as intact.
3 changes: 3 additions & 0 deletions scenarios/proof/chain-round-trip/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
verify: pass
count: 5
chain: intact
5 changes: 5 additions & 0 deletions scenarios/proof/chain-round-trip/input-records.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"record_id":"prf-2026-02-18T10:00:00Z-7f4138c7","record_version":"1.0","timestamp":"2026-02-18T10:00:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"round-01","step":1},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:dbcd4333b4f6e585c01127435f6041189cd4742fdacea3c7a1a68362b08cd169"}}
{"record_id":"prf-2026-02-18T10:01:00Z-bcc3f471","record_version":"1.0","timestamp":"2026-02-18T10:01:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"round-02","step":2},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:1d4d093c3eea85d8b760f80d081d71db5dde78c148b0d8b2c2b31c544b604913","previous_record_hash":"sha256:dbcd4333b4f6e585c01127435f6041189cd4742fdacea3c7a1a68362b08cd169"}}
{"record_id":"prf-2026-02-18T10:02:00Z-8d03ac2b","record_version":"1.0","timestamp":"2026-02-18T10:02:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"round-03","step":3},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:434e0c1ececfb2b39e8f4e79c2ef97e4f65450484a43c6c65a992ff01310f127","previous_record_hash":"sha256:1d4d093c3eea85d8b760f80d081d71db5dde78c148b0d8b2c2b31c544b604913"}}
{"record_id":"prf-2026-02-18T10:03:00Z-bbd81b6e","record_version":"1.0","timestamp":"2026-02-18T10:03:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"round-04","step":4},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:b41571d029ce11db86e53b34caae57f1cc5d4f2edf7637e365bf72e72797518b","previous_record_hash":"sha256:434e0c1ececfb2b39e8f4e79c2ef97e4f65450484a43c6c65a992ff01310f127"}}
{"record_id":"prf-2026-02-18T10:04:00Z-b621f095","record_version":"1.0","timestamp":"2026-02-18T10:04:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"round-05","step":5},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:70158f8bcf65bce0d4fdba876fc7ce52f0a719e5479b1859fa80e68a77fabd45","previous_record_hash":"sha256:b41571d029ce11db86e53b34caae57f1cc5d4f2edf7637e365bf72e72797518b"}}
3 changes: 3 additions & 0 deletions scenarios/proof/chain-tamper-detection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# chain-tamper-detection

Validates that tampering a single record causes chain verification failure.
2 changes: 2 additions & 0 deletions scenarios/proof/chain-tamper-detection/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
verify: fail
break_point: 5
10 changes: 10 additions & 0 deletions scenarios/proof/chain-tamper-detection/input-records.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{"record_id":"prf-2026-02-18T11:00:00Z-a29b4457","record_version":"1.0","timestamp":"2026-02-18T11:00:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-01","step":1},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:0a23896e6ea5cdf3b3fcaaab3bab323a051c02fe255a76f8ccc804e5041b4ff8"}}
{"record_id":"prf-2026-02-18T11:01:00Z-29d61a07","record_version":"1.0","timestamp":"2026-02-18T11:01:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-02","step":2},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:c6fa7ba16a8acbf37af31e01f9bcfdec9b9f57676b629c7afce529550325cbb7","previous_record_hash":"sha256:0a23896e6ea5cdf3b3fcaaab3bab323a051c02fe255a76f8ccc804e5041b4ff8"}}
{"record_id":"prf-2026-02-18T11:02:00Z-32d00e63","record_version":"1.0","timestamp":"2026-02-18T11:02:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-03","step":3},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:7e63b79e7e86191aadf004764e16905c17ecfbf50908b0fc3359eefa55766d99","previous_record_hash":"sha256:c6fa7ba16a8acbf37af31e01f9bcfdec9b9f57676b629c7afce529550325cbb7"}}
{"record_id":"prf-2026-02-18T11:03:00Z-c37ddca3","record_version":"1.0","timestamp":"2026-02-18T11:03:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-04","step":4},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:613710ef6c376396c1307f9bd8038caeec42fd2815afa0949ae35922e494cb6b","previous_record_hash":"sha256:7e63b79e7e86191aadf004764e16905c17ecfbf50908b0fc3359eefa55766d99"}}
{"record_id":"prf-2026-02-18T11:04:00Z-4a4e7aeb","record_version":"1.0","timestamp":"2026-02-18T11:04:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-05","step":5},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:877b3a9adff59ab666f35e05f7e355225a961571df7079f0d43f4dc9389672b3","previous_record_hash":"sha256:613710ef6c376396c1307f9bd8038caeec42fd2815afa0949ae35922e494cb6b"}}
{"record_id":"prf-2026-02-18T11:05:00Z-fc749276","record_version":"1.0","timestamp":"2026-02-18T11:05:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-06","step":6},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:0070ac6d9499e2190aa116d700f6e3810282d03d8ba95e12153385523b5f0435","previous_record_hash":"sha256:877b3a9adff59ab666f35e05f7e355225a961571df7079f0d43f4dc9389672b3"}}
{"record_id":"prf-2026-02-18T11:06:00Z-24535ec5","record_version":"1.0","timestamp":"2026-02-18T11:06:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-07","step":7},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:f94039b9e9571b0ab5fcaf4caf29f714718927edc48f4b76b6da47bd5d2d3d61","previous_record_hash":"sha256:0070ac6d9499e2190aa116d700f6e3810282d03d8ba95e12153385523b5f0435"}}
{"record_id":"prf-2026-02-18T11:07:00Z-4403e0e0","record_version":"1.0","timestamp":"2026-02-18T11:07:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-08","step":8},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:053911e06a74e327bb97fb262a7ecf02664ca5f4344eb8340dde86e5c8ee830b","previous_record_hash":"sha256:f94039b9e9571b0ab5fcaf4caf29f714718927edc48f4b76b6da47bd5d2d3d61"}}
{"record_id":"prf-2026-02-18T11:08:00Z-10918428","record_version":"1.0","timestamp":"2026-02-18T11:08:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-09","step":9},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:5f7499fb7ead8616a2782125f3420977e169ec5fe65da37aa253d1acd4e10bfd","previous_record_hash":"sha256:053911e06a74e327bb97fb262a7ecf02664ca5f4344eb8340dde86e5c8ee830b"}}
{"record_id":"prf-2026-02-18T11:09:00Z-fa03e16a","record_version":"1.0","timestamp":"2026-02-18T11:09:00Z","source":"proof","source_product":"proof","record_type":"decision","event":{"event_id":"tamper-10","step":10},"controls":{"permissions_enforced":true},"integrity":{"record_hash":"sha256:f7d125de6c13347e9c257e054bef42e68faed86f4554a5d1bd38b8bba41e197a","previous_record_hash":"sha256:5f7499fb7ead8616a2782125f3420977e169ec5fe65da37aa253d1acd4e10bfd"}}
Loading
Loading