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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,15 @@ jobs:
PYTHONPATH: .
shell: bash
run: |
go test ./core/pack -count=1 -run 'TestVerifyInspectAndDiffRejectDuplicateEntries'
go test ./core/runpack -count=1 -run 'TestVerifyZipRejectsDuplicateEntries|TestReadRunpackRejectsDuplicateEntries|TestRecordRunNormalizesMissingDigestsFromNormalization|TestRecordRunRejectsDigestMismatchAgainstNormalization'
go test ./core/mcp -count=1 -run 'TestEvaluateServerTrustInvalidDuplicateSnapshotFailsClosed'
go test ./cmd/gait -count=1 -run 'TestRunGateEvalRequiresVerifiedContextEnvelopeForContextPolicies|TestPolicyTestEqualPriorityRenamesDoNotChangeVerdict'
go test ./cmd/gait -count=1 -run 'TestRunPackVerifyRejectsDuplicateEntries|TestRunVerifyRejectsDuplicateEntries|TestRunMCPVerifyRejectsDuplicateSnapshotIdentities|TestRunMCPProxyRejectsDuplicateSnapshotIdentities|TestMCPServeHandlerRejectsDuplicateSnapshotIdentities|TestRunRecordNormalizesMissingDigestsFromNormalization|TestRunRecordRejectsDigestMismatchAgainstNormalization'
go test ./core/gate -count=1 -run 'TestEvaluatePolicyDetailedEqualPriorityRenameDoesNotChangeVerdict'
go test ./core/jobruntime -count=1 -run 'TestSubmitAppendFailureRollsBackNewJob|TestMutationAppendFailureRollsBackStateAndRetrySucceeds|TestMutationAppendFailureWithDurableEventPreservesPendingMarker'
cd sdk/python
uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error'
uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads'

e2e:
needs: changes
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/pr-fast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,15 @@ jobs:
PYTHONPATH: .
shell: bash
run: |
go test ./core/pack -count=1 -run 'TestVerifyInspectAndDiffRejectDuplicateEntries'
go test ./core/runpack -count=1 -run 'TestVerifyZipRejectsDuplicateEntries|TestReadRunpackRejectsDuplicateEntries|TestRecordRunNormalizesMissingDigestsFromNormalization|TestRecordRunRejectsDigestMismatchAgainstNormalization'
go test ./core/mcp -count=1 -run 'TestEvaluateServerTrustInvalidDuplicateSnapshotFailsClosed'
go test ./cmd/gait -count=1 -run 'TestRunGateEvalRequiresVerifiedContextEnvelopeForContextPolicies|TestPolicyTestEqualPriorityRenamesDoNotChangeVerdict'
go test ./cmd/gait -count=1 -run 'TestRunPackVerifyRejectsDuplicateEntries|TestRunVerifyRejectsDuplicateEntries|TestRunMCPVerifyRejectsDuplicateSnapshotIdentities|TestRunMCPProxyRejectsDuplicateSnapshotIdentities|TestMCPServeHandlerRejectsDuplicateSnapshotIdentities|TestRunRecordNormalizesMissingDigestsFromNormalization|TestRunRecordRejectsDigestMismatchAgainstNormalization'
go test ./core/gate -count=1 -run 'TestEvaluatePolicyDetailedEqualPriorityRenameDoesNotChangeVerdict'
go test ./core/jobruntime -count=1 -run 'TestSubmitAppendFailureRollsBackNewJob|TestMutationAppendFailureRollsBackStateAndRetrySucceeds|TestMutationAppendFailureWithDurableEventPreservesPendingMarker'
cd sdk/python
uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error'
uv run --python ${{ matrix.python-version }} --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads'

windows-fast:
name: pr-fast-windows
Expand Down Expand Up @@ -119,4 +123,4 @@ jobs:
PYTHONPATH: .
run: |
cd sdk/python
uv run --python 3.11 --extra dev pytest tests/test_client.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error'
uv run --python 3.11 --extra dev pytest tests/test_client.py tests/test_session.py -q -k 'capture_demo_runpack_uses_json_cli_contract or capture_demo_runpack_malformed_json_raises_command_error or run_session_captures_attempts_and_emits_runpack or run_session_records_executor_errors or run_session_rejects_set_payloads'
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ See [`examples/integrations/openai_agents/`](examples/integrations/openai_agents

The official LangChain surface is middleware with optional callback correlation. Enforcement happens in `wrap_tool_call`; callbacks are additive only.

`run_session(...)` and other Python run-capture helpers delegate digest completion to `gait run record` in Go rather than hashing artifact fields in Python. Normalize `set` values to JSON lists before calling the SDK; unsupported non-JSON values now fail deterministically instead of being coerced into digest-affecting output.

```bash
(cd sdk/python && uv sync --extra langchain --extra dev)
(cd sdk/python && uv run --python 3.13 --extra langchain python ../../examples/integrations/langchain/quickstart.py --scenario allow)
Expand Down Expand Up @@ -252,6 +254,7 @@ Current shipped model:
- external scanners or registries produce a local trust snapshot
- `gait mcp verify`, `gait mcp proxy`, and `gait mcp serve` consume that local file
- Gait enforces the decision at the boundary; it does not replace the scanner
- duplicate normalized `server_id` / `server_name` entries invalidate the snapshot, and required high-risk trust paths fail closed on that ambiguity

This is the right split with tools such as Snyk: external tooling finds the issue, and Gait enforces the runtime response.

Expand Down Expand Up @@ -287,6 +290,7 @@ Every Gait decision can produce signed proof artifacts that map to operational a

- `gait verify`, `gait pack verify`, and `gait trace verify` work offline
- packs use Ed25519 signatures plus SHA-256 manifests
- duplicate ZIP entry names are treated as verification failures rather than ambiguous soft passes
- artifacts are deterministic, versioned, and designed for PRs, incidents, change control, and audits

Framework mapping and evidence docs:
Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Security checks run in CI and local lint workflows:

- `go vet`, `golangci-lint`, `gosec`, `govulncheck` for Go
- `ruff`, `mypy`, `bandit`, `pytest` for Python wrapper code
- artifact verification fails closed on duplicate ZIP entry names
- MCP trust snapshots with duplicate normalized identities are treated as invalid and fail closed on required high-risk paths

Release integrity is validated with signed checksums, SBOM, and provenance artifacts.

Expand Down
173 changes: 173 additions & 0 deletions cmd/gait/duplicate_verify_cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package main

import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Clyra-AI/gait/core/jobruntime"
packcore "github.com/Clyra-AI/gait/core/pack"
runpackcore "github.com/Clyra-AI/gait/core/runpack"
schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack"
)

func TestRunPackVerifyRejectsDuplicateEntries(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)
jobsRoot := filepath.Join(workDir, "jobs")
jobID := "job_pack_duplicate_cli"

if _, err := jobruntime.Submit(jobsRoot, jobruntime.SubmitOptions{JobID: jobID}); err != nil {
t.Fatalf("submit job: %v", err)
}
packPath := filepath.Join(workDir, "job_pack.zip")
if _, err := packcore.BuildJobPackFromPath(jobsRoot, jobID, packPath, "test-v24", nil); err != nil {
t.Fatalf("build job pack: %v", err)
}

duplicatePath := filepath.Join(workDir, "job_pack_duplicate.zip")
if err := writeDuplicateEntryZip(packPath, duplicatePath, "job_state.json", []byte(`{"job_id":"evil"}`), false); err != nil {
t.Fatalf("write duplicate pack zip: %v", err)
}

code, output := runPackJSON(t, []string{"verify", duplicatePath, "--json"})
if code != exitVerifyFailed {
t.Fatalf("pack verify duplicate expected %d got %d output=%#v", exitVerifyFailed, code, output)
}
if output.OK {
t.Fatalf("expected pack verify output ok=false: %#v", output)
}
if !strings.Contains(output.Error, "zip contains duplicate entries: job_state.json") {
t.Fatalf("unexpected pack verify error: %#v", output)
}
}

func TestRunVerifyRejectsDuplicateEntries(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)
now := time.Date(2026, time.March, 18, 0, 0, 0, 0, time.UTC)
runpackPath := filepath.Join(workDir, "runpack_valid.zip")

if _, err := runpackcore.WriteRunpack(runpackPath, runpackcore.RecordOptions{
Run: schemarunpack.Run{
RunID: "run_duplicate_cli",
CreatedAt: now,
ProducerVersion: "0.0.0-test",
},
Intents: []schemarunpack.IntentRecord{{
IntentID: "intent_1",
RunID: "run_duplicate_cli",
ToolName: "tool.write",
ArgsDigest: strings.Repeat("a", 64),
}},
Results: []schemarunpack.ResultRecord{{
IntentID: "intent_1",
RunID: "run_duplicate_cli",
Status: "ok",
ResultDigest: strings.Repeat("b", 64),
}},
Refs: schemarunpack.Refs{
RunID: "run_duplicate_cli",
Receipts: []schemarunpack.RefReceipt{},
},
CaptureMode: "reference",
}); err != nil {
t.Fatalf("write runpack: %v", err)
}

duplicatePath := filepath.Join(workDir, "runpack_duplicate.zip")
if err := writeDuplicateEntryZip(runpackPath, duplicatePath, "run.json", []byte(`{"run":"evil"}`), true); err != nil {
t.Fatalf("write duplicate runpack zip: %v", err)
}

var code int
raw := captureStdout(t, func() {
code = runVerify([]string{"--json", duplicatePath})
})
if code != exitVerifyFailed {
t.Fatalf("verify duplicate expected %d got %d raw=%s", exitVerifyFailed, code, raw)
}
var output verifyOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("decode verify output: %v raw=%s", err, raw)
}
if output.OK {
t.Fatalf("expected verify output ok=false: %#v", output)
}
if !strings.Contains(output.Error, "zip contains duplicate entries: run.json") {
t.Fatalf("unexpected verify error output: %#v", output)
}
}

func writeDuplicateEntryZip(srcPath string, dstPath string, entryName string, duplicatePayload []byte, duplicateFirst bool) error {
reader, err := zip.OpenReader(srcPath)
if err != nil {
return err
}
defer func() {
_ = reader.Close()
}()

var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
inserted := false
for _, file := range reader.File {
if file.Name == entryName && duplicateFirst && !inserted {
target, err := writer.Create(entryName)
if err != nil {
_ = writer.Close()
return err
}
if _, err := target.Write(duplicatePayload); err != nil {
_ = writer.Close()
return err
}
inserted = true
}

fileReader, err := file.Open()
if err != nil {
_ = writer.Close()
return err
}
payload, err := io.ReadAll(fileReader)
_ = fileReader.Close()
if err != nil {
_ = writer.Close()
return err
}

target, err := writer.Create(file.Name)
if err != nil {
_ = writer.Close()
return err
}
if _, err := target.Write(payload); err != nil {
_ = writer.Close()
return err
}

if file.Name == entryName && !duplicateFirst && !inserted {
target, err := writer.Create(entryName)
if err != nil {
_ = writer.Close()
return err
}
if _, err := target.Write(duplicatePayload); err != nil {
_ = writer.Close()
return err
}
inserted = true
}
}
if err := writer.Close(); err != nil {
return err
}
return os.WriteFile(dstPath, buffer.Bytes(), 0o600)
}
55 changes: 55 additions & 0 deletions cmd/gait/mcp_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,61 @@ func TestMCPServeHandlerEvaluateSSE(t *testing.T) {
}
}

func TestMCPServeHandlerRejectsDuplicateSnapshotIdentities(t *testing.T) {
workDir := t.TempDir()
snapshotPath := filepath.Join(workDir, "trust_snapshot.json")
policyPath := filepath.Join(workDir, "policy.yaml")
mustWriteFile(t, policyPath, strings.Join([]string{
"default_verdict: allow",
"mcp_trust:",
" snapshot: " + snapshotPath,
" action: block",
" required_risk_classes: [high]",
}, "\n")+"\n")
mustWriteFile(t, snapshotPath, `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`)

handler, err := newMCPServeHandler(mcpServeConfig{
PolicyPath: policyPath,
DefaultAdapter: "mcp",
TraceDir: filepath.Join(workDir, "traces"),
KeyMode: "dev",
})
if err != nil {
t.Fatalf("newMCPServeHandler: %v", err)
}

requestBody := []byte(`{
"run_id":"run_mcp_server_duplicate_snapshot",
"call":{
"name":"tool.read",
"args":{"path":"/tmp/out.txt"},
"server":{"server_id":"github","server_name":"GitHub"},
"context":{"identity":"alice","workspace":"/repo/gait","risk_class":"high"}
}
}`)
request := httptest.NewRequest(http.MethodPost, "/v1/evaluate", bytes.NewReader(requestBody))
request.Header.Set("content-type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusOK {
t.Fatalf("evaluate status: expected %d got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var response mcpServeEvaluateResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode evaluate response: %v", err)
}
if response.Verdict != "block" || response.MCPTrust == nil || response.MCPTrust.Status != "invalid" {
t.Fatalf("expected invalid trust block response, got %#v", response)
}
if response.ExitCode != exitPolicyBlocked {
t.Fatalf("expected exit code %d got %d", exitPolicyBlocked, response.ExitCode)
}
if !strings.Contains(strings.Join(response.ReasonCodes, ","), "mcp_trust_snapshot_invalid") {
t.Fatalf("expected duplicate snapshot reason code, got %#v", response)
}
}

func TestMCPServeHandlerEvaluateStream(t *testing.T) {
workDir := t.TempDir()
policyPath := filepath.Join(workDir, "policy.yaml")
Expand Down
75 changes: 75 additions & 0 deletions cmd/gait/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,81 @@ func TestRunMCPVerifyInfersPolicyRiskClass(t *testing.T) {
}
}

func TestRunMCPVerifyRejectsDuplicateSnapshotIdentities(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)

policyPath := filepath.Join(workDir, "policy.yaml")
mustWriteFile(t, policyPath, strings.Join([]string{
"default_verdict: allow",
"mcp_trust:",
" snapshot: ./trust_snapshot.json",
" action: block",
" required_risk_classes: [high]",
}, "\n")+"\n")
mustWriteFile(t, filepath.Join(workDir, "trust_snapshot.json"), `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`)
serverPath := filepath.Join(workDir, "server.json")
mustWriteFile(t, serverPath, `{"server_id":"github","server_name":"GitHub"}`)

var code int
raw := captureStdout(t, func() {
code = runMCPVerify([]string{"--policy", policyPath, "--server", serverPath, "--json"})
})
if code != exitPolicyBlocked {
t.Fatalf("runMCPVerify duplicate snapshot expected %d got %d (%s)", exitPolicyBlocked, code, raw)
}
var output mcpVerifyOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("decode verify output: %v", err)
}
if output.OK || output.MCPTrust == nil || output.MCPTrust.Status != "invalid" {
t.Fatalf("expected invalid verify output, got %#v", output)
}
if !strings.Contains(strings.Join(output.ReasonCodes, ","), "mcp_trust_snapshot_invalid") {
t.Fatalf("expected duplicate snapshot reason code, got %#v", output)
}
}

func TestRunMCPProxyRejectsDuplicateSnapshotIdentities(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)

policyPath := filepath.Join(workDir, "policy.yaml")
mustWriteFile(t, policyPath, strings.Join([]string{
"default_verdict: allow",
"mcp_trust:",
" snapshot: ./trust_snapshot.json",
" action: block",
" required_risk_classes: [high]",
}, "\n")+"\n")
mustWriteFile(t, filepath.Join(workDir, "trust_snapshot.json"), `{"schema_id":"gait.mcp.trust_snapshot","schema_version":"1.0.0","created_at":"2026-03-01T00:00:00Z","producer_version":"test","entries":[{"server_id":"github","status":"trusted","updated_at":"2026-03-01T00:00:00Z","score":0.95},{"server_name":"GitHub","status":"blocked","updated_at":"2026-03-01T00:00:00Z","score":0.10}]}`)
callPath := filepath.Join(workDir, "call.json")
mustWriteFile(t, callPath, `{
"name":"tool.read",
"args":{"path":"/tmp/in.txt"},
"server":{"server_id":"github","server_name":"GitHub"},
"context":{"identity":"alice","workspace":"/repo/gait","risk_class":"high","run_id":"run_mcp_duplicate_snapshot"}
}`)

var code int
raw := captureStdout(t, func() {
code = runMCPProxy([]string{"--policy", policyPath, "--call", callPath, "--json"})
})
if code != exitPolicyBlocked {
t.Fatalf("runMCPProxy duplicate snapshot expected %d got %d (%s)", exitPolicyBlocked, code, raw)
}
var output mcpProxyOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("decode proxy output: %v", err)
}
if output.Verdict != "block" || output.MCPTrust == nil || output.MCPTrust.Status != "invalid" {
t.Fatalf("expected invalid trust block output, got %#v", output)
}
if !strings.Contains(strings.Join(output.ReasonCodes, ","), "mcp_trust_snapshot_invalid") {
t.Fatalf("expected duplicate snapshot reason code, got %#v", output)
}
}

func TestRunMCPVerifyBlockedAndReadServerErrors(t *testing.T) {
workDir := t.TempDir()
withWorkingDir(t, workDir)
Expand Down
Loading
Loading