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
2 changes: 1 addition & 1 deletion core/gate/context_evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func mergeContextSource(current string, extra string) string {
}

func PolicyRequiresContextEvidence(policy Policy) bool {
normalizedPolicy, err := normalizePolicy(policy)
normalizedPolicy, err := normalizedPolicy(policy)
if err != nil {
return false
}
Expand Down
43 changes: 38 additions & 5 deletions core/gate/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type Policy struct {
FailClosed FailClosedPolicy `yaml:"fail_closed"`
MCPTrust MCPTrustPolicy `yaml:"mcp_trust"`
Rules []PolicyRule `yaml:"rules"`
normalized bool `yaml:"-" json:"-"`
}

type ScriptPolicy struct {
Expand Down Expand Up @@ -263,6 +264,13 @@ func ParsePolicyYAML(data []byte) (Policy, error) {
return normalizePolicy(policy)
}

func normalizedPolicy(input Policy) (Policy, error) {
if input.normalized {
return input, nil
}
return normalizePolicy(input)
}

func EvaluatePolicy(policy Policy, intent schemagate.IntentRequest, opts EvalOptions) (schemagate.GateResult, error) {
outcome, err := EvaluatePolicyDetailed(policy, intent, opts)
if err != nil {
Expand All @@ -272,7 +280,7 @@ func EvaluatePolicy(policy Policy, intent schemagate.IntentRequest, opts EvalOpt
}

func PolicyHasHighRiskUnbrokeredActions(policy Policy) bool {
normalizedPolicy, err := normalizePolicy(policy)
normalizedPolicy, err := normalizedPolicy(policy)
if err != nil {
return false
}
Expand All @@ -288,7 +296,7 @@ func PolicyHasHighRiskUnbrokeredActions(policy Policy) bool {
}

func PolicyRequiresBrokerForHighRisk(policy Policy) bool {
normalizedPolicy, err := normalizePolicy(policy)
normalizedPolicy, err := normalizedPolicy(policy)
if err != nil {
return false
}
Expand All @@ -304,7 +312,7 @@ func PolicyRequiresBrokerForHighRisk(policy Policy) bool {
}

func EvaluatePolicyDetailed(policy Policy, intent schemagate.IntentRequest, opts EvalOptions) (EvalOutcome, error) {
normalizedPolicy, err := normalizePolicy(policy)
normalizedPolicy, err := normalizedPolicy(policy)
if err != nil {
return EvalOutcome{}, err
}
Expand Down Expand Up @@ -721,7 +729,7 @@ func compositeRiskClass(riskClasses []string) string {
}

func PolicyDigest(policy Policy) (string, error) {
normalized, err := normalizePolicy(policy)
normalized, err := normalizedPolicy(policy)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -1246,6 +1254,7 @@ func normalizePolicy(input Policy) (Policy, error) {
}
return output.Rules[i].Name < output.Rules[j].Name
})
output.normalized = true
return output, nil
}

Expand Down Expand Up @@ -1990,8 +1999,32 @@ func normalizeStringListLower(values []string) []string {
}

func uniqueSorted(values []string) []string {
if len(values) == 0 {
switch len(values) {
case 0:
return []string{}
case 1:
trimmed := strings.TrimSpace(values[0])
if trimmed == "" {
return []string{}
}
return []string{trimmed}
case 2:
first := strings.TrimSpace(values[0])
second := strings.TrimSpace(values[1])
switch {
case first == "" && second == "":
return []string{}
case first == "":
return []string{second}
case second == "":
return []string{first}
case first == second:
return []string{first}
case first < second:
return []string{first, second}
default:
return []string{second, first}
}
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
Expand Down
40 changes: 30 additions & 10 deletions core/guard/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,15 @@ func VerifyPackWithOptions(path string, opts VerifyOptions) (VerifyResult, error
_ = zipReader.Close()
}()

files := make(map[string]*zip.File, len(zipReader.File))
for _, zipFile := range zipReader.File {
files[zipFile.Name] = zipFile
var files map[string]*zip.File
if len(zipReader.File) > 16 {
files = make(map[string]*zip.File, len(zipReader.File))
for _, zipFile := range zipReader.File {
files[zipFile.Name] = zipFile
}
}

manifestFile := files["pack_manifest.json"]
manifestFile := findZipFile(zipReader.File, files, "pack_manifest.json")
if manifestFile == nil {
return VerifyResult{}, fmt.Errorf("missing pack_manifest.json")
}
Expand All @@ -354,7 +357,7 @@ func VerifyPackWithOptions(path string, opts VerifyOptions) (VerifyResult, error
SignaturesTotal: len(manifest.Signatures),
}
for _, entry := range manifest.Contents {
zipFile := files[entry.Path]
zipFile := findZipFile(zipReader.File, files, entry.Path)
if zipFile == nil {
result.MissingFiles = append(result.MissingFiles, entry.Path)
continue
Expand All @@ -363,18 +366,22 @@ func VerifyPackWithOptions(path string, opts VerifyOptions) (VerifyResult, error
if err != nil {
return VerifyResult{}, fmt.Errorf("hash %s: %w", entry.Path, err)
}
if !strings.EqualFold(actualHash, entry.SHA256) {
if actualHash != entry.SHA256 && !strings.EqualFold(actualHash, entry.SHA256) {
result.HashMismatches = append(result.HashMismatches, HashMismatch{
Path: entry.Path,
Expected: entry.SHA256,
Actual: actualHash,
})
}
}
sort.Strings(result.MissingFiles)
sort.Slice(result.HashMismatches, func(i, j int) bool {
return result.HashMismatches[i].Path < result.HashMismatches[j].Path
})
if len(result.MissingFiles) > 1 {
sort.Strings(result.MissingFiles)
}
if len(result.HashMismatches) > 1 {
sort.Slice(result.HashMismatches, func(i, j int) bool {
return result.HashMismatches[i].Path < result.HashMismatches[j].Path
})
}
if len(manifest.Signatures) == 0 {
if opts.RequireSignature {
result.SignatureErrors = append(result.SignatureErrors, "pack manifest has no signatures")
Expand Down Expand Up @@ -418,6 +425,19 @@ func VerifyPackWithOptions(path string, opts VerifyOptions) (VerifyResult, error
return result, nil
}

func findZipFile(all []*zip.File, indexed map[string]*zip.File, name string) *zip.File {
if indexed != nil {
return indexed[name]
}
// Preserve the existing map-based last-entry-wins semantics for duplicate names.
for index := len(all) - 1; index >= 0; index-- {
if all[index].Name == name {
return all[index]
}
}
return nil
}

func buildRunpackSummary(data runpack.Runpack) ([]byte, error) {
summary := struct {
RunID string `json:"run_id"`
Expand Down
55 changes: 55 additions & 0 deletions core/guard/pack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,61 @@ func TestVerifyPackWithSignatures(t *testing.T) {
}
}

func TestVerifyPackDetectsTamperedLastDuplicateEntry(t *testing.T) {
workDir := t.TempDir()
now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
matchingContent := []byte(`{"status":"ok"}`)
tamperedContent := []byte(`{"status":"tampered"}`)
expectedHash := sha256Hex(matchingContent)
tamperedHash := sha256Hex(tamperedContent)

manifestBytes, err := marshalCanonicalJSON(schemaguard.PackManifest{
SchemaID: "gait.guard.pack_manifest",
SchemaVersion: "1.0.0",
CreatedAt: now,
ProducerVersion: "0.0.0-test",
PackID: "pack_duplicate_entry",
RunID: "run_duplicate_entry",
GeneratedAt: now,
Contents: []schemaguard.PackEntry{{
Path: "evidence.json",
SHA256: expectedHash,
Type: "evidence",
}},
})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}

var archive bytes.Buffer
if err := zipx.WriteDeterministicZip(&archive, []zipx.File{
{Path: "pack_manifest.json", Data: manifestBytes, Mode: 0o644},
{Path: "evidence.json", Data: matchingContent, Mode: 0o644},
{Path: "evidence.json", Data: tamperedContent, Mode: 0o644},
}); err != nil {
t.Fatalf("write duplicate-entry zip: %v", err)
}

packPath := filepath.Join(workDir, "duplicate_entries.zip")
if err := os.WriteFile(packPath, archive.Bytes(), 0o600); err != nil {
t.Fatalf("write duplicate-entry zip: %v", err)
}

verifyResult, err := VerifyPack(packPath)
if err != nil {
t.Fatalf("verify duplicate-entry zip: %v", err)
}
if len(verifyResult.HashMismatches) != 1 {
t.Fatalf("expected one hash mismatch, got %#v", verifyResult.HashMismatches)
}
if verifyResult.HashMismatches[0].Path != "evidence.json" {
t.Fatalf("expected mismatch for evidence.json, got %#v", verifyResult.HashMismatches)
}
if verifyResult.HashMismatches[0].Expected != expectedHash || verifyResult.HashMismatches[0].Actual != tamperedHash {
t.Fatalf("unexpected mismatch payload: %#v", verifyResult.HashMismatches[0])
}
}

func tamperPackMissingFile(t *testing.T, source string, destination string, remove string) {
t.Helper()
reader, err := zip.OpenReader(source)
Expand Down
Loading