diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index fe8d619..2e254e7 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:624cc8e3d9318169eb170ba7cb677b06e11a7ff68bef59b86bb7318bd8652ce8" - digest: "sha256:d5b8988c4c228533eea9f8c22b5e080088494d380ff01688f999d285236c9360" + source_tree_digest: "sha256:a585302e6ef2f8fa86e88dcd6dd692ea4fc04ad03de874fe719b0c0dd2e891a4" + digest: "sha256:18d891dd71baa2f881ab5694e025dbc1d9e93b570bd4e1278779c76bbb17d084" projection: - input_digest: "sha256:55d339ca3fd58f5eba0795c38f68a878fe03c4e4d6b0f149d3c273dea56c5b8b" - output_digest: "sha256:daf1d7ea532a9ed356eeb053f73bc08cb5b3fe4e863ead0f440b4492c2f06ba4" + input_digest: "sha256:beddf89f25e80a82d63914f92f8689f2d0d0873c581ca420496c4521367a7ebd" + output_digest: "sha256:1761fa50949eac3c2ff2d121dd69a66b3d2544c664877c52c5cea2ceb0d78121" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:18dfb90210b2fb933ae5141aabfecee9ec074fdb74d412aea5a8234dd406cdc7" + digest: "sha256:c367d9e29a6fad68d8e38820b0bcf5b4e22f7c5c44fac48bf86111d2b2fb3790" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index f397eef..865dbbc 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.9.7-dev -# source-tree-digest: sha256:624cc8e3d9318169eb170ba7cb677b06e11a7ff68bef59b86bb7318bd8652ce8 -# input-digest: sha256:55d339ca3fd58f5eba0795c38f68a878fe03c4e4d6b0f149d3c273dea56c5b8b +# source-tree-digest: sha256:a585302e6ef2f8fa86e88dcd6dd692ea4fc04ad03de874fe719b0c0dd2e891a4 +# input-digest: sha256:beddf89f25e80a82d63914f92f8689f2d0d0873c581ca420496c4521367a7ebd # output-digest: sha256:b9bf3d0c64c0fb371596e7d090e82e62aebbfde91929115fc15fb28644e4fd38 # edit-source: # - .gds/repository.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c2cf5..6034987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ Versioning. ## [Unreleased] +- Add `gds evidence record` and `gds evidence verify`: repo-scoped session + evidence for the repository the agent ran in, including every Git module + inside its boundary. `record` binds the `.gds/repository.yaml` identity, + HEAD/upstream position, change counts and changed paths, and per-submodule + gitlink/checked-out OIDs into a canonical artifact signed with Ed25519 under + the `gds-session-evidence/v1` domain (`session-evidence` trust role), links + it to the previous artifact for the same repository, and writes it under the + device state root with mode `0600` — never into the repository. `verify` + checks the embedded schema, canonical digest, signature and structural + invariants without the recording session, harness or repository. New schema + `session-evidence` with valid and invalid fixtures. - Remove release channels from the release pipeline (ADR 0038). Bundle manifests, release envelopes, locks, rollout documents and trust policy no longer carry or require `channel`; the fields remain optional for decoding diff --git a/core/app/services.go b/core/app/services.go index 9ffa46a..693976b 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -45,6 +45,7 @@ import ( type Services struct { Schemas *validation.Set Git *gitprovider.Runner + Manifests *manifest.Loader GitMutations *gitprovider.MutationRunner Context *contextresolver.Resolver Discovery *discovery.Local @@ -138,6 +139,7 @@ func NewServices(clock inventory.Clock) (*Services, error) { return &Services{ Schemas: schemas, Git: git, + Manifests: manifests, GitMutations: gitMutations, Context: contextresolver.NewResolver(git, manifests, schemas, policyProver), Discovery: localDiscovery, diff --git a/core/app/session_evidence.go b/core/app/session_evidence.go new file mode 100644 index 0000000..708fc81 --- /dev/null +++ b/core/app/session_evidence.go @@ -0,0 +1,358 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/approval" + "github.com/NDDev-OpenNetwork/github-device-sync/core/canonicaljson" + "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/identity" + "github.com/NDDev-OpenNetwork/github-device-sync/core/serialization" + "github.com/NDDev-OpenNetwork/github-device-sync/core/sessionevidence" + "github.com/NDDev-OpenNetwork/github-device-sync/core/trust" +) + +// SessionEvidenceRecordOptions describes one repo-scoped session evidence +// capture. The artifact is private: it names working-tree paths and a session +// identity, so it is written under the device state root, never into the +// repository. +type SessionEvidenceRecordOptions struct { + Path string + DeviceID string + SessionID string + HarnessID string + HarnessVersion string + ActorID string + KeyID string + PrivateKeyPath string + Output string + EvidenceRoot string + GDSVersion string +} + +// SessionEvidenceRecordData reports one recorded artifact. +type SessionEvidenceRecordData struct { + EvidenceID string `json:"evidence_id"` + RepositoryID string `json:"repository_id"` + Path string `json:"path"` + EvidenceDigest string `json:"evidence_digest"` + PreviousDigest string `json:"previous_evidence_digest,omitempty"` + SubmoduleCount int `json:"submodule_count"` + ChangedPathCount int `json:"changed_path_count"` +} + +var harnessIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + +// RecordSessionEvidence captures the current repository boundary — head, +// branch, upstream position, change counts and paths, and every Git module +// inside it — into a signed artifact under the device evidence root. It +// records observed state only; it does not claim the named session produced +// that state, and it reads nothing outside the repository boundary. +func (services *Services) RecordSessionEvidence( + ctx context.Context, options SessionEvidenceRecordOptions, +) domain.Envelope { + command := "gds evidence record" + fail := func(code, message string, evidence map[string]any) domain.Envelope { + return domain.NewEnvelope(command, domain.ExitInput, map[string]any{ + "target": options.Path, + }, domain.Finding{Code: code, Severity: domain.SeverityHigh, Message: message, Evidence: evidence}) + } + if options.DeviceID == "" || options.SessionID == "" || options.ActorID == "" || + options.KeyID == "" || options.PrivateKeyPath == "" { + return fail("GDS_INPUT_INVALID", + "--device-id, --session-id, --actor-id, --key-id and --private-key are required.", nil) + } + if !harnessIDPattern.MatchString(options.HarnessID) { + return fail("GDS_INPUT_INVALID", + "--harness must be a lowercase harness identifier such as codex, claude-code, cursor or grok.", + map[string]any{"harness": options.HarnessID}) + } + info, err := services.Git.RepositoryInfo(ctx, options.Path) + if err != nil { + return envelopeForError(command, options.Path, err) + } + anchor, anchorFindings := services.Manifests.LoadRepository(info.WorktreeRoot) + if len(anchorFindings) != 0 { + return domain.NewEnvelope(command, domain.ExitInput, map[string]any{ + "target": info.WorktreeRoot, + }, anchorFindings...) + } + status, err := services.Git.InspectStatus(ctx, info.WorktreeRoot) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + topology, err := services.Git.InspectTopology(ctx, info.WorktreeRoot) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + changedPaths, err := services.sessionChangedPaths(ctx, info.WorktreeRoot) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + statusDigest, err := canonicaljson.Digest(status) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + submodules := make([]sessionevidence.SubmoduleEvidence, 0, len(topology.Submodules)) + for _, module := range topology.Submodules { + submodules = append(submodules, sessionevidence.SubmoduleEvidence{ + Path: module.Path, + GitlinkOID: module.GitlinkOID, + CurrentOID: module.CurrentOID, + WorktreeState: module.WorktreeState, + }) + } + sort.Slice(submodules, func(i, j int) bool { return submodules[i].Path < submodules[j].Path }) + evidenceRoot, err := sessionEvidenceRoot(options.EvidenceRoot) + if err != nil { + return envelopeForError(command, options.Path, err) + } + previousDigest, err := latestSessionEvidenceDigest(evidenceRoot, anchor.Repository.ID) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + now := services.Now().UTC() + evidenceID, err := identity.New("sev", now, nil) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + repositoryName := anchor.Provider.Name + if repositoryName == "" { + repositoryName = anchor.Repository.DisplayName + } + payload := sessionevidence.Payload{ + SchemaVersion: sessionevidence.SchemaVersion, + EvidenceID: evidenceID, + RepositoryID: anchor.Repository.ID, + RepositoryName: repositoryName, + DeviceID: options.DeviceID, + SessionID: options.SessionID, + HarnessID: options.HarnessID, + HarnessVersion: options.HarnessVersion, + ActorID: options.ActorID, + ObservedAt: now, + GDSVersion: options.GDSVersion, + Baseline: sessionevidence.Baseline{ + HeadOID: status.Head.OID, + HeadMode: status.Head.Mode, + Branch: status.Branch.Name, + Upstream: status.Branch.Upstream, + UpstreamState: status.Branch.UpstreamState, + Ahead: status.Branch.Ahead, + Behind: status.Branch.Behind, + Diverged: status.Branch.Diverged, + Staged: status.Changes.Staged, + Unstaged: status.Changes.Unstaged, + Untracked: status.Changes.Untracked, + Conflicted: status.Changes.Conflicted, + Classification: status.Classification, + ChangedPaths: changedPaths, + StatusDigest: statusDigest, + Submodules: submodules, + }, + PreviousDigest: previousDigest, + } + privateKey, err := approval.LoadPrivateKey(options.PrivateKeyPath) + if err != nil { + return envelopeForError(command, options.PrivateKeyPath, err) + } + artifact, err := sessionevidence.Sign(payload, options.KeyID, privateKey) + if err != nil { + return envelopeForError(command, info.WorktreeRoot, err) + } + outputPath := options.Output + if outputPath == "" { + outputPath = filepath.Join(evidenceRoot, safePathSegment(repositoryName), evidenceID+".json") + } + if err := writeSessionEvidenceArtifact(outputPath, artifact); err != nil { + return envelopeForError(command, outputPath, err) + } + return domain.Success(command, SessionEvidenceRecordData{ + EvidenceID: evidenceID, + RepositoryID: anchor.Repository.ID, + Path: outputPath, + EvidenceDigest: artifact.EvidenceDigest, + PreviousDigest: previousDigest, + SubmoduleCount: len(submodules), + ChangedPathCount: len(changedPaths), + }) +} + +// VerifySessionEvidence independently verifies one artifact: strict decode, +// schema, canonical digest, Ed25519 signature under the supplied trust policy +// and structural invariants. It does not need the recording session, harness +// or repository to exist. +func (services *Services) VerifySessionEvidence( + ctx context.Context, filePath string, trustPolicyPath string, +) domain.Envelope { + command := "gds evidence verify" + if filePath == "" || trustPolicyPath == "" { + return domain.NewEnvelope(command, domain.ExitInput, map[string]any{}, + domain.Finding{Code: "GDS_INPUT_INVALID", Severity: domain.SeverityHigh, + Message: "--file and --trust-policy are required."}) + } + value, err := serialization.DecodeFile(filePath) + if err != nil { + return envelopeForError(command, filePath, err) + } + if findings := services.Schemas.Validate(sessionevidence.SchemaName, value, filePath); len(findings) != 0 { + return domain.NewEnvelope(command, domain.ExitNotProven, map[string]any{"file": filePath}, findings...) + } + raw, err := json.Marshal(value) + if err != nil { + return envelopeForError(command, filePath, err) + } + var artifact sessionevidence.Artifact + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&artifact); err != nil { + return envelopeForError(command, filePath, fmt.Errorf("decode session evidence: %w", err)) + } + policy, err := trust.LoadPolicy(trustPolicyPath) + if err != nil { + return envelopeForError(command, trustPolicyPath, err) + } + verifier, err := sessionevidence.NewVerifier(policy) + if err != nil { + return envelopeForError(command, trustPolicyPath, err) + } + assessment, err := verifier.Verify(ctx, artifact) + if err != nil { + return domain.NewEnvelope(command, domain.ExitNotProven, map[string]any{ + "file": filePath, + "evidence_id": artifact.Payload.EvidenceID, + }, domain.Finding{ + Code: "GDS_SESSION_EVIDENCE_INVALID", + Severity: domain.SeverityHigh, + Message: err.Error(), + Evidence: map[string]any{"file": filePath}, + }) + } + return domain.Success(command, assessment) +} + +// sessionChangedPaths lists every path Git reports as changed inside the +// repository, sorted. Paths are recorded verbatim because the artifact never +// leaves the device state root. +func (services *Services) sessionChangedPaths(ctx context.Context, root string) ([]string, error) { + result, err := services.Git.Run(ctx, root, "status", "--porcelain=v2", "--branch", "-z") + if err != nil { + return nil, err + } + paths := make([]string, 0, 16) + // Porcelain v2 -z emits NUL-separated fields; every record's path is its + // last space-separated token, and a rename/copy record trails a second + // field carrying the source path. + entries := bytes.Split(result.Stdout, []byte{0}) + for index := 0; index < len(entries); index++ { + entry := entries[index] + if len(entry) < 2 { + continue + } + kind := entry[0] + if kind == '#' { + continue + } + // Fixed field counts before the unquoted path in porcelain v2: + // ordinary entries carry 8 fields, unmerged 10, rename/copy 9 and + // untracked 1. + fields := map[byte]int{'1': 8, 'u': 10, '2': 9, '?': 1}[kind] + if fields == 0 { + continue + } + path := string(entry) + for field := 0; field < fields; field++ { + cut := strings.IndexByte(path, ' ') + if cut < 0 { + path = "" + break + } + path = path[cut+1:] + } + if kind == '2' { + index++ + } + if path != "" { + paths = append(paths, path) + } + } + sort.Strings(paths) + return paths, nil +} + +func sessionEvidenceRoot(configured string) (string, error) { + if configured != "" { + return configured, nil + } + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locate device state root: %w", err) + } + stateHome = filepath.Join(home, ".local", "state") + } + return filepath.Join(stateHome, "github-device-sync", "session-evidence"), nil +} + +func writeSessionEvidenceArtifact(path string, artifact sessionevidence.Artifact) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create session evidence directory: %w", err) + } + raw, err := json.MarshalIndent(artifact, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(raw, '\n'), 0o600) +} + +// latestSessionEvidenceDigest links each artifact to the newest previously +// recorded artifact for the same repository on this device, forming a local +// hash chain. A missing or unreadable prior artifact is not an error; a +// malformed one is ignored only when it cannot be decoded at all. +func latestSessionEvidenceDigest(root string, repositoryID string) (string, error) { + entries, err := filepath.Glob(filepath.Join(root, "*", "*.json")) + if err != nil { + return "", err + } + var newest sessionevidence.Artifact + found := false + for _, path := range entries { + raw, err := os.ReadFile(path) + if err != nil { + continue + } + var artifact sessionevidence.Artifact + if err := json.Unmarshal(raw, &artifact); err != nil { + continue + } + if artifact.Payload.RepositoryID != repositoryID { + continue + } + if !found || artifact.Payload.ObservedAt.After(newest.Payload.ObservedAt) { + newest, found = artifact, true + } + } + if !found { + return "", nil + } + return newest.EvidenceDigest, nil +} + +var unsafePathSegment = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func safePathSegment(name string) string { + segment := unsafePathSegment.ReplaceAllString(strings.TrimSpace(name), "-") + if segment == "" || segment == "." || segment == ".." { + return "repository" + } + return segment +} diff --git a/core/capabilities/registry.go b/core/capabilities/registry.go index f3aa1ae..de2a4e6 100644 --- a/core/capabilities/registry.go +++ b/core/capabilities/registry.go @@ -35,6 +35,7 @@ type Definition struct { var rootCommandNames = []string{ "context", "session", + "evidence", "sync", "handoff", "complete", @@ -84,7 +85,7 @@ var definitions = []Definition{ Policy: "explicit-approval", }, CommandCarriers: []string{ - "complete", "fork", "generate", "git", "github", "handoff", "harness", + "complete", "evidence", "fork", "generate", "git", "github", "handoff", "harness", "memory", "module", "operation", "portfolio", "recover", "release", "repository", "rollout", "session", "source", "state", "sync", "workspace", }, diff --git a/core/cli/evidence_test.go b/core/cli/evidence_test.go new file mode 100644 index 0000000..b482d9a --- /dev/null +++ b/core/cli/evidence_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/sessionevidence" +) + +func writeSessionKeyPair(t *testing.T) (string, string) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "session-key.pem") + raw := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path, base64.RawURLEncoding.EncodeToString(publicKey) +} + +func writeSessionTrustPolicy(t *testing.T, publicKey string) string { + t.Helper() + policy := map[string]any{ + "schema_version": 1, + "policy_id": "test-session-policy", + "identities": []map[string]any{{ + "actor_id": "owner:test", + "roles": []string{"session-evidence"}, + "keys": []map[string]any{{ + "algorithm": "ed25519", + "key_id": "session-key-2026", + "public_key": publicKey, + "valid_from": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + "valid_until": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "status": "active", + }}, + }}, + } + path := filepath.Join(t.TempDir(), "trust-policy.json") + raw, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func recordArgs(client, keyPath, evidenceRoot string) []string { + return []string{ + "--json", "--cwd", client, "evidence", "record", + "--device-id", "device:test", "--session-id", "session:test", + "--harness", "codex", "--harness-version", "0.1.0", + "--actor-id", "owner:test", "--key-id", "session-key-2026", + "--private-key", keyPath, "--evidence-root", evidenceRoot, + } +} + +func readArtifact(t *testing.T, path string) sessionevidence.Artifact { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var artifact sessionevidence.Artifact + if err := json.Unmarshal(raw, &artifact); err != nil { + t.Fatal(err) + } + return artifact +} + +func TestEvidenceRecordAndVerifyRoundTrip(t *testing.T) { + fixture := sessionFixture(t) + keyPath, publicKey := writeSessionKeyPair(t) + policyPath := writeSessionTrustPolicy(t, publicKey) + evidenceRoot := filepath.Join(t.TempDir(), "session-evidence") + + exitCode, envelope, stderr := executeJSON(t, recordArgs(fixture.client, keyPath, evidenceRoot)...) + if exitCode != 0 { + t.Fatalf("record failed: %d %s %#v", exitCode, stderr, envelope.Findings) + } + data, ok := envelope.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected record data: %#v", envelope.Data) + } + artifactPath, _ := data["path"].(string) + if artifactPath == "" || !strings.HasPrefix(artifactPath, evidenceRoot) { + t.Fatalf("artifact written outside evidence root: %q", artifactPath) + } + info, err := os.Stat(artifactPath) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("artifact permissions: %v %v", info, err) + } + artifact := readArtifact(t, artifactPath) + if artifact.Payload.Baseline.HeadOID != fixture.firstOID { + t.Fatalf("head oid %q != fixture %q", artifact.Payload.Baseline.HeadOID, fixture.firstOID) + } + if artifact.Payload.RepositoryName != "example-repository" && + !strings.Contains(artifact.Payload.RepositoryName, "example") { + t.Fatalf("unexpected repository name %q", artifact.Payload.RepositoryName) + } + + exitCode, verifyEnvelope, stderr := executeJSON( + t, "--json", "--cwd", fixture.client, "evidence", "verify", + "--file", artifactPath, "--trust-policy", policyPath, + ) + if exitCode != 0 { + t.Fatalf("verify failed: %d %s %#v", exitCode, stderr, verifyEnvelope.Findings) + } + + // A second record chains to the first artifact for the same repository. + exitCode, second, stderr := executeJSON(t, recordArgs(fixture.client, keyPath, evidenceRoot)...) + if exitCode != 0 { + t.Fatalf("second record failed: %d %s", exitCode, stderr) + } + secondData, _ := second.Data.(map[string]any) + if secondData["previous_evidence_digest"] != artifact.EvidenceDigest { + t.Fatalf("hash chain broken: previous=%v first=%s", + secondData["previous_evidence_digest"], artifact.EvidenceDigest) + } +} + +func TestEvidenceVerifyRejectsTamperedArtifact(t *testing.T) { + fixture := sessionFixture(t) + keyPath, publicKey := writeSessionKeyPair(t) + policyPath := writeSessionTrustPolicy(t, publicKey) + evidenceRoot := filepath.Join(t.TempDir(), "session-evidence") + + exitCode, envelope, stderr := executeJSON(t, recordArgs(fixture.client, keyPath, evidenceRoot)...) + if exitCode != 0 { + t.Fatalf("record failed: %d %s %#v", exitCode, stderr, envelope.Findings) + } + data, _ := envelope.Data.(map[string]any) + artifactPath, _ := data["path"].(string) + artifact := readArtifact(t, artifactPath) + artifact.Payload.Baseline.Untracked = 42 + raw, _ := json.MarshalIndent(artifact, "", " ") + if err := os.WriteFile(artifactPath, append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + exitCode, verifyEnvelope, _ := executeJSON( + t, "--json", "--cwd", fixture.client, "evidence", "verify", + "--file", artifactPath, "--trust-policy", policyPath, + ) + if exitCode == 0 { + t.Fatalf("tampered artifact verified: %#v", verifyEnvelope.Data) + } +} diff --git a/core/cli/root.go b/core/cli/root.go index d17f103..ef9c463 100644 --- a/core/cli/root.go +++ b/core/cli/root.go @@ -141,7 +141,8 @@ func (executor *executor) rootCommand() *cobra.Command { ) commands := map[string]*cobra.Command{ "context": executor.contextCommand(), "session": executor.sessionCommand(), - "sync": executor.syncCommand(), "handoff": executor.handoffCommand(), + "evidence": executor.evidenceCommand(), + "sync": executor.syncCommand(), "handoff": executor.handoffCommand(), "complete": executor.completeCommand(), "status": executor.statusCommand(), "discover": executor.discoverCommand(), "inventory": executor.inventoryCommand(), "validate": executor.validateCommand(), "doctor": executor.doctorCommand(), @@ -389,6 +390,52 @@ func (executor *executor) sessionCommand() *cobra.Command { return command } +func (executor *executor) evidenceCommand() *cobra.Command { + record := app.SessionEvidenceRecordOptions{} + verifyFile := "" + verifyTrustPolicy := "" + command := &cobra.Command{ + Use: "evidence", + Short: "Record and verify repo-scoped agent session evidence", + Args: cobra.NoArgs, + } + recordCommand := &cobra.Command{ + Use: "record", + Short: "Capture the repository boundary into a signed session evidence artifact", + Args: cobra.NoArgs, + RunE: func(child *cobra.Command, _ []string) error { + record.GDSVersion = Version + record.Path = executor.options.cwd + return executor.run(child, func(ctx context.Context) domain.Envelope { + return executor.services.RecordSessionEvidence(ctx, record) + }) + }, + } + recordCommand.Flags().StringVar(&record.DeviceID, "device-id", "", "canonical device identity") + recordCommand.Flags().StringVar(&record.SessionID, "session-id", "", "bounded session identity") + recordCommand.Flags().StringVar(&record.HarnessID, "harness", "", "agent harness identifier (codex, claude-code, cursor, grok)") + recordCommand.Flags().StringVar(&record.HarnessVersion, "harness-version", "", "declared harness version") + recordCommand.Flags().StringVar(&record.ActorID, "actor-id", "", "signing actor identity") + recordCommand.Flags().StringVar(&record.KeyID, "key-id", "", "trust-policy key identifier") + recordCommand.Flags().StringVar(&record.PrivateKeyPath, "private-key", "", "PKCS#8 Ed25519 private key (mode 0600)") + recordCommand.Flags().StringVar(&record.Output, "output", "", "artifact output path (default: device evidence root)") + recordCommand.Flags().StringVar(&record.EvidenceRoot, "evidence-root", "", "device session evidence root override") + verifyCommand := &cobra.Command{ + Use: "verify", + Short: "Independently verify one session evidence artifact", + Args: cobra.NoArgs, + RunE: func(child *cobra.Command, _ []string) error { + return executor.run(child, func(ctx context.Context) domain.Envelope { + return executor.services.VerifySessionEvidence(ctx, verifyFile, verifyTrustPolicy) + }) + }, + } + verifyCommand.Flags().StringVar(&verifyFile, "file", "", "session evidence artifact path") + verifyCommand.Flags().StringVar(&verifyTrustPolicy, "trust-policy", "", "trust policy path") + command.AddCommand(recordCommand, verifyCommand) + return command +} + func (executor *executor) recoverCommand() *cobra.Command { plan := false applyPlanID := "" diff --git a/core/operations/engine.go b/core/operations/engine.go index 8e575aa..4335817 100644 --- a/core/operations/engine.go +++ b/core/operations/engine.go @@ -265,13 +265,30 @@ func (engine *Engine) apply( return ApplyResult{}, err } enablement, enableErr := engine.Store.GetPlanEnablement(ctx, "enablement:"+signed.ApprovalID) - if enableErr != nil || enablement.Status != "active" || enablement.PlanID != plan.PlanID || - enablement.PlanDigest != plan.PlanDigest || enablement.ApprovalID != signed.ApprovalID || - enablement.ApprovalDigest != signedDigest || enablement.DeviceID != engine.DeviceID || - enablement.SessionID != engine.SessionID || !enablement.ExpiresAt.After(now) { + enablementProblem := "" + switch { + case enableErr != nil: + enablementProblem = "no enablement exists for this approval" + case enablement.Status != "active": + enablementProblem = fmt.Sprintf("enablement is %q, not active", enablement.Status) + case enablement.PlanID != plan.PlanID || enablement.PlanDigest != plan.PlanDigest: + enablementProblem = "enablement is bound to a different plan" + case enablement.ApprovalID != signed.ApprovalID || enablement.ApprovalDigest != signedDigest: + enablementProblem = "enablement is bound to a different approval" + case enablement.DeviceID != engine.DeviceID: + enablementProblem = "enablement is bound to a different device" + case enablement.SessionID != engine.SessionID: + enablementProblem = fmt.Sprintf( + "enablement is bound to session %q, but apply uses session %q; re-run enable and apply with the same --session-id", + enablement.SessionID, engine.SessionID) + case !enablement.ExpiresAt.After(now): + enablementProblem = "enablement has expired" + } + if enablementProblem != "" { return ApplyResult{PlanID: planID, Status: "planned"}, newError( "GDS_PLAN_ENABLEMENT_REQUIRED", domain.ExitApproval, - "Create a separate active one-shot enablement for this exact signed plan before apply.", enableErr, + "Apply requires an active one-shot enablement bound to this exact plan, approval, device and session: "+enablementProblem+".", + enableErr, ) } } diff --git a/core/sessionevidence/evidence.go b/core/sessionevidence/evidence.go new file mode 100644 index 0000000..9eafc0a --- /dev/null +++ b/core/sessionevidence/evidence.go @@ -0,0 +1,221 @@ +// Package sessionevidence defines the signed artifact GDS produces when a +// supported agent harness runs in a repository. The artifact binds a session +// identity to the repository's observed Git state at capture time: head, +// branch, upstream position, change counts, changed paths and every Git module +// inside the repository boundary. It records what was observed, not whether the +// session's work was correct. +// +// Evidence is a private artifact: it names repository paths and a session +// identity, so it belongs on the device that produced it, never in the +// repository itself. The signature proves integrity and the signing identity; +// the repository binding is proven by the recorded OIDs, which any later +// verifier can compare against the actual repository. +package sessionevidence + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "sort" + "time" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/canonicaljson" + "github.com/NDDev-OpenNetwork/github-device-sync/core/trust" +) + +const ( + // SignatureDomain separates session evidence signatures from every other + // signed GDS artifact. + SignatureDomain = "gds-session-evidence/v1" + // SignatureRole is the trust role required of a signing key. + SignatureRole = "session-evidence" + // SchemaName identifies the embedded validation schema. + SchemaName = "session-evidence" + // SchemaVersion is the payload schema version this package emits. + SchemaVersion = 1 +) + +var ( + // ErrMalformed reports an artifact that cannot be decoded into the expected + // shape. + ErrMalformed = errors.New("session evidence is malformed") + // ErrDigestMismatch reports an artifact whose payload digest does not match + // the recorded evidence digest. + ErrDigestMismatch = errors.New("session evidence digest mismatch") + // ErrInvalidSignature reports a signature the configured trust policy does + // not accept. + ErrInvalidSignature = errors.New("session evidence signature is invalid") +) + +// SubmoduleEvidence binds one Git module inside the repository boundary to the +// OID recorded in the parent tree and the OID actually checked out. An +// uninitialized or absent module is still recorded, with no OIDs, so the +// artifact enumerates the whole boundary. +type SubmoduleEvidence struct { + Path string `json:"path"` + GitlinkOID string `json:"gitlink_oid,omitempty"` + CurrentOID string `json:"current_oid,omitempty"` + WorktreeState string `json:"worktree_state"` +} + +// Baseline is the observed Git state of the repository at capture time. Change +// counts and paths describe the working tree, including state that predates the +// session; the artifact does not claim the session produced them. +type Baseline struct { + HeadOID string `json:"head_oid"` + HeadMode string `json:"head_mode"` + Branch string `json:"branch,omitempty"` + Upstream string `json:"upstream,omitempty"` + UpstreamState string `json:"upstream_state"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Diverged bool `json:"diverged"` + Staged int `json:"staged"` + Unstaged int `json:"unstaged"` + Untracked int `json:"untracked"` + Conflicted int `json:"conflicted"` + Classification string `json:"classification"` + ChangedPaths []string `json:"changed_paths"` + StatusDigest string `json:"status_digest"` + Submodules []SubmoduleEvidence `json:"submodules"` +} + +// Payload is the signed body of a session evidence artifact. +type Payload struct { + SchemaVersion int `json:"schema_version"` + EvidenceID string `json:"evidence_id"` + RepositoryID string `json:"repository_id"` + RepositoryName string `json:"repository_name"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + HarnessID string `json:"harness_id"` + HarnessVersion string `json:"harness_version"` + ActorID string `json:"actor_id"` + ObservedAt time.Time `json:"observed_at"` + GDSVersion string `json:"gds_version"` + Baseline Baseline `json:"baseline"` + PreviousDigest string `json:"previous_evidence_digest,omitempty"` +} + +// Artifact is a payload plus its canonical digest and signature. +type Artifact struct { + Payload Payload `json:"payload"` + EvidenceDigest string `json:"evidence_digest"` + Signature trust.Signature `json:"signature"` +} + +// Assessment reports what verification established. +type Assessment struct { + EvidenceID string `json:"evidence_id"` + RepositoryID string `json:"repository_id"` + RepositoryName string `json:"repository_name"` + SessionID string `json:"session_id"` + ObservedAt time.Time `json:"observed_at"` + KeyID string `json:"key_id"` + ActorID string `json:"actor_id"` +} + +// Verifier checks artifact integrity and signature against a trust policy. +type Verifier struct { + trust trust.Verifier +} + +// NewVerifier constructs a verifier for the given policy. +func NewVerifier(policy trust.Policy) (*Verifier, error) { + if policy.SchemaVersion != 1 || policy.PolicyID == "" { + return nil, errors.New("session evidence trust policy is invalid") + } + return &Verifier{trust: trust.Verifier{Policy: policy}}, nil +} + +// SignPayload canonicalizes the payload and returns the signature input. +func SignPayload(payload Payload) ([]byte, error) { + return trust.SigningBytes(SignatureDomain, payload) +} + +// Sign produces a signed artifact for the payload. +func Sign(payload Payload, keyID string, privateKey ed25519.PrivateKey) (Artifact, error) { + if keyID == "" || len(privateKey) != ed25519.PrivateKeySize { + return Artifact{}, errors.New("session evidence signing identity is invalid") + } + raw, err := SignPayload(payload) + if err != nil { + return Artifact{}, err + } + digest, err := DigestPayload(payload) + if err != nil { + return Artifact{}, err + } + return Artifact{ + Payload: payload, + EvidenceDigest: digest, + Signature: trust.Signature{ + Algorithm: trust.Ed25519, + KeyID: keyID, + Value: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, raw)), + }, + }, nil +} + +// DigestPayload returns the canonical digest recorded as evidence_digest. +func DigestPayload(payload Payload) (string, error) { + return canonicaljson.Digest(payload) +} + +// Verify validates one artifact end to end: digest, signature and structural +// invariants. +func (verifier *Verifier) Verify(ctx context.Context, artifact Artifact) (Assessment, error) { + payload := artifact.Payload + if payload.SchemaVersion != SchemaVersion { + return Assessment{}, fmt.Errorf("%w: unsupported schema_version %d", ErrMalformed, payload.SchemaVersion) + } + if payload.EvidenceID == "" || payload.RepositoryID == "" || payload.DeviceID == "" || + payload.SessionID == "" || payload.HarnessID == "" || payload.ActorID == "" { + return Assessment{}, fmt.Errorf("%w: payload identity fields are incomplete", ErrMalformed) + } + if payload.ObservedAt.IsZero() { + return Assessment{}, fmt.Errorf("%w: observed_at is required", ErrMalformed) + } + if payload.Baseline.HeadOID == "" { + return Assessment{}, fmt.Errorf("%w: baseline head_oid is required", ErrMalformed) + } + if payload.Baseline.StatusDigest == "" { + return Assessment{}, fmt.Errorf("%w: baseline status_digest is required", ErrMalformed) + } + seen := make(map[string]struct{}, len(payload.Baseline.Submodules)) + for _, submodule := range payload.Baseline.Submodules { + if submodule.Path == "" { + return Assessment{}, fmt.Errorf("%w: submodule path is required", ErrMalformed) + } + if _, duplicate := seen[submodule.Path]; duplicate { + return Assessment{}, fmt.Errorf("%w: duplicate submodule path %q", ErrMalformed, submodule.Path) + } + seen[submodule.Path] = struct{}{} + } + if !sort.StringsAreSorted(payload.Baseline.ChangedPaths) { + return Assessment{}, fmt.Errorf("%w: changed_paths must be sorted", ErrMalformed) + } + expectedDigest, err := DigestPayload(payload) + if err != nil { + return Assessment{}, fmt.Errorf("%w: %v", ErrMalformed, err) + } + if artifact.EvidenceDigest != expectedDigest { + return Assessment{}, ErrDigestMismatch + } + if err := verifier.trust.Verify( + SignatureDomain, payload.ActorID, SignatureRole, payload.ObservedAt, payload, artifact.Signature, + ); err != nil { + return Assessment{}, fmt.Errorf("%w: %v", ErrInvalidSignature, err) + } + return Assessment{ + EvidenceID: payload.EvidenceID, + RepositoryID: payload.RepositoryID, + RepositoryName: payload.RepositoryName, + SessionID: payload.SessionID, + ObservedAt: payload.ObservedAt, + KeyID: artifact.Signature.KeyID, + ActorID: payload.ActorID, + }, nil +} diff --git a/core/sessionevidence/evidence_test.go b/core/sessionevidence/evidence_test.go new file mode 100644 index 0000000..ace1d1f --- /dev/null +++ b/core/sessionevidence/evidence_test.go @@ -0,0 +1,183 @@ +package sessionevidence + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/trust" +) + +func testPayload(now time.Time) Payload { + return Payload{ + SchemaVersion: SchemaVersion, + EvidenceID: "sev_test", + RepositoryID: "repository:example", + RepositoryName: "example", + DeviceID: "device:test", + SessionID: "session:test", + HarnessID: "codex", + HarnessVersion: "0.1.0", + ActorID: "owner:test", + ObservedAt: now, + GDSVersion: "0.9.9-dev", + Baseline: Baseline{ + HeadOID: strings.Repeat("a", 40), + HeadMode: "branch", + Branch: "main", + Upstream: "origin/main", + UpstreamState: "present", + Classification: "clean", + ChangedPaths: []string{"README.md", "core/app/x.go"}, + StatusDigest: "sha256:" + strings.Repeat("b", 64), + Submodules: []SubmoduleEvidence{{ + Path: "modules/one", GitlinkOID: strings.Repeat("c", 40), + CurrentOID: strings.Repeat("c", 40), WorktreeState: "clean", + }}, + }, + } +} + +func testVerifier(t *testing.T, publicKey ed25519.PublicKey, now time.Time) *Verifier { + t.Helper() + verifier, err := NewVerifier(trust.Policy{ + SchemaVersion: 1, PolicyID: "test-policy", + Identities: []trust.Identity{{ + ActorID: "owner:test", Roles: []string{SignatureRole}, + Keys: []trust.Key{{ + Algorithm: trust.Ed25519, KeyID: "session-key", + PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), + ValidFrom: now.Add(-time.Hour), ValidUntil: now.Add(24 * time.Hour), Status: "active", + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + return verifier +} + +func TestSignedSessionEvidenceVerifies(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + artifact, err := Sign(testPayload(now), "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + assessment, err := testVerifier(t, publicKey, now).Verify(context.Background(), artifact) + if err != nil { + t.Fatal(err) + } + if assessment.EvidenceID != "sev_test" || assessment.RepositoryID != "repository:example" || + assessment.SessionID != "session:test" || assessment.KeyID != "session-key" { + t.Fatalf("assessment=%#v", assessment) + } +} + +func TestTamperedPayloadFailsDigest(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + artifact, err := Sign(testPayload(now), "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + artifact.Payload.Baseline.Staged = 7 + if _, err := testVerifier(t, publicKey, now).Verify(context.Background(), artifact); err == nil { + t.Fatal("tampered payload was accepted") + } +} + +func TestForgedDigestStillFailsSignature(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + artifact, err := Sign(testPayload(now), "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + artifact.Payload.Baseline.Staged = 7 + forged, err := DigestPayload(artifact.Payload) + if err != nil { + t.Fatal(err) + } + artifact.EvidenceDigest = forged + if _, err := testVerifier(t, publicKey, now).Verify(context.Background(), artifact); err == nil { + t.Fatal("payload with a recomputed digest but stale signature was accepted") + } +} + +func TestWrongActorOrKeyRejected(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + artifact, err := Sign(testPayload(now), "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + verifier := testVerifier(t, publicKey, now) + artifact.Payload.ActorID = "owner:other" + if _, err := verifier.Verify(context.Background(), artifact); err == nil { + t.Fatal("artifact naming an untrusted actor was accepted") + } + otherPublic, _, _ := ed25519.GenerateKey(rand.Reader) + verifier = testVerifier(t, otherPublic, now) + artifact.Payload.ActorID = "owner:test" + if _, err := verifier.Verify(context.Background(), artifact); err == nil { + t.Fatal("artifact signed by a different key was accepted") + } +} + +func TestMalformedArtifactsRejected(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + verifier := testVerifier(t, publicKey, now) + + cases := map[string]func(Payload) Payload{ + "empty repository": func(p Payload) Payload { p.RepositoryID = ""; return p }, + "empty head": func(p Payload) Payload { p.Baseline.HeadOID = ""; return p }, + "unsorted paths": func(p Payload) Payload { p.Baseline.ChangedPaths = []string{"b", "a"}; return p }, + "duplicate submodule": func(p Payload) Payload { + p.Baseline.Submodules = append(p.Baseline.Submodules, p.Baseline.Submodules[0]) + return p + }, + "future schema": func(p Payload) Payload { p.SchemaVersion = 99; return p }, + } + for name, mutate := range cases { + payload := mutate(testPayload(now)) + artifact, err := Sign(payload, "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := verifier.Verify(context.Background(), artifact); err == nil { + t.Fatalf("%s: malformed artifact was accepted", name) + } + } +} + +func TestExpiredKeyRejected(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) + artifact, err := Sign(testPayload(now), "session-key", privateKey) + if err != nil { + t.Fatal(err) + } + verifier, err := NewVerifier(trust.Policy{ + SchemaVersion: 1, PolicyID: "test-policy", + Identities: []trust.Identity{{ + ActorID: "owner:test", Roles: []string{SignatureRole}, + Keys: []trust.Key{{ + Algorithm: trust.Ed25519, KeyID: "session-key", + PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), + ValidFrom: now.Add(-2 * time.Hour), ValidUntil: now.Add(-time.Hour), Status: "active", + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := verifier.Verify(context.Background(), artifact); err == nil { + t.Fatal("artifact signed outside the key validity window was accepted") + } +} diff --git a/docs/adr/0039-repo-scoped-session-evidence.md b/docs/adr/0039-repo-scoped-session-evidence.md new file mode 100644 index 0000000..bdedeb4 --- /dev/null +++ b/docs/adr/0039-repo-scoped-session-evidence.md @@ -0,0 +1,63 @@ +# ADR 0039: Repo-scoped session evidence + +Status: Accepted + +Date: 2026-09-21 + +## Context + +GDS already produces device-wide evidence (`core/deviceevidence`): a signed +inventory of an entire workspace — every repository, every harness, provider +refresh digests. That is the wrong unit for the recurring question "what did +this agent session touch". A session runs inside one repository boundary, +which may itself contain nested Git modules; sweeping `~/Developer` records +sibling repositories the session never entered and leaks their state into an +artifact whose subject is narrower. + +External practice for agent audit trails converged on the same invariants: +capture repository state at the session boundary, record typed bounded +observations, canonicalize the signed payload, separate the signature domain, +and state honestly what the artifact does not prove (in-toto-style separation +of subject, predicate and envelope; the Agent Receipts specification reaches +the same shape). + +## Decision + +`gds evidence record` produces one signed artifact scoped to exactly the +repository it runs in — and to every Git module inside that boundary, because +a submodule is part of the repository's own tree. + +1. **Scope is the repository boundary.** The artifact carries the + `.gds/repository.yaml` identity, HEAD mode and OID, branch and upstream + position, staged/unstaged/untracked/conflicted counts, the sorted changed + path list, and every submodule's path, gitlink OID and checked-out OID. + Nothing outside the worktree root is read. +2. **The artifact is private.** It names working-tree paths, so it is written + under the device state root (`session-evidence//.json`, mode + `0600`) — never committed to the repository it describes. +3. **Integrity is canonical + signed.** `evidence_digest` is the canonical + JSON digest of the payload; the signature is Ed25519 over the + `gds-session-evidence/v1` domain under the `session-evidence` trust role. + Each record links the newest prior artifact for the same repository via + `previous_evidence_digest`, forming a local hash chain. +4. **Verification is independent.** `gds evidence verify` needs only the + artifact and a trust policy — not the session, the harness, or the + repository. It checks schema, digest, signature and structural invariants + (required identity fields, sorted paths, unique submodule paths). +5. **Honesty about claims.** The artifact proves that a signing identity + recorded this repository state at this time under this session identity. + It does not prove the named session produced that state, that provider + transcripts were captured, or that the session's work was correct. + Pre-existing dirty state is recorded as observed, not attributed. + +## Consequences + +- Historical Cursor, Claude Code, Grok and Codex sessions cannot be + retroactively attested; provider transcript content is outside this + artifact's scope unless a harness exposes it, in which case it belongs in a + separate harness evidence document, not here. +- Estates grant the `session-evidence` role to the identities allowed to + attest agent sessions (device owners), keeping the signing authority off + the release path exactly as ADR 0038 required. +- A later end-of-session or diff-bound receipt can extend the same schema + family; the hash chain already orders multiple records per repository. diff --git a/docs/adr/README.md b/docs/adr/README.md index acfbe14..e31b9d8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,6 +8,7 @@ clause stops being normative. | ADR | Title | Status | Supersedes | Superseded by | |---|---|---|---|---| +| [0039](0039-repo-scoped-session-evidence.md) | Repo-scoped session evidence | Accepted | — | — | | [0038](0038-release-identity-carries-no-channel.md) | Release identity carries no channel | Accepted | ADR 0016 | — | | [0037](0037-seven-harnesses-one-per-setup-system.md) | Seven harnesses, one per setup system | Accepted | ADR 0011 | — | | [0036](0036-harness-identity-follows-the-consumer.md) | Harness identity follows the consumer contract | Accepted | — | — | diff --git a/docs/contracts/cli-v1.md b/docs/contracts/cli-v1.md index bf92493..95c1769 100644 --- a/docs/contracts/cli-v1.md +++ b/docs/contracts/cli-v1.md @@ -234,6 +234,31 @@ strictly bounded, explicitly requested remote-tracking ref refresh, records local ref mutation, detects forced updates by ancestry, and never integrates the current branch or changes worktree/index content. +### `gds evidence record` + +Captures the current repository boundary into a signed session evidence +artifact: the `.gds/repository.yaml` identity, HEAD and upstream position, +change counts and changed paths, and every Git module inside the boundary with +its gitlink and checked-out OID. The artifact is private — it names +working-tree paths — so it is written under the device state root +(`--evidence-root`, default +`${XDG_STATE_HOME:-$HOME/.local/state}/github-device-sync/session-evidence/`), +mode `0600`, never into the repository. Each record links the newest prior +artifact for the same repository through `previous_evidence_digest`. Signing +requires `--device-id`, `--session-id`, `--harness`, `--actor-id`, `--key-id` +and a PKCS#8 Ed25519 `--private-key`; the signature domain is +`gds-session-evidence/v1` and the required trust role is `session-evidence`. +The artifact records observed state; it does not prove the named session +produced that state or that the work was correct. + +### `gds evidence verify --file --trust-policy ` + +Independently verifies one artifact: strict decode, embedded schema, canonical +payload digest, Ed25519 signature under the supplied trust policy, and +structural invariants (required identity fields, sorted paths, unique +submodule paths). Verification needs only the artifact and the policy — not +the recording session, harness, or repository. + ### `gds state initialize --plan|--apply |--verify ` Uses a deterministic self-hosting lifecycle plan because the operational state diff --git a/docs/contracts/operations-v1.md b/docs/contracts/operations-v1.md index dd7f45d..7011395 100644 --- a/docs/contracts/operations-v1.md +++ b/docs/contracts/operations-v1.md @@ -167,7 +167,7 @@ runtime, authorization, policy, and kill-switch checks. | Capability | Support | Runtime | Policy | Registered command carriers | |---|---|---|---|---| | `provider_observation` | `implemented` | `configuration-required` | `read-only` | `github`, `reconcile`, `repository` | -| `mutations` | `implemented` | `configuration-required` | `explicit-approval` | `complete`, `fork`, `generate`, `git`, `github`, `handoff`, `harness`, `memory`, `module`, `operation`, `portfolio`, `recover`, `release`, `repository`, `rollout`, `session`, `source`, `state`, `sync`, `workspace` | +| `mutations` | `implemented` | `configuration-required` | `explicit-approval` | `complete`, `evidence`, `fork`, `generate`, `git`, `github`, `handoff`, `harness`, `memory`, `module`, `operation`, `portfolio`, `recover`, `release`, `repository`, `rollout`, `session`, `source`, `state`, `sync`, `workspace` | Handlers are registered per command and per immutable plan; there is no global diff --git a/schemas/v1/session-evidence.schema.json b/schemas/v1/session-evidence.schema.json new file mode 100644 index 0000000..2fae292 --- /dev/null +++ b/schemas/v1/session-evidence.schema.json @@ -0,0 +1 @@ +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/NDDev-OpenNetwork/github-device-sync/schemas/v1/session-evidence.schema.json","title":"GDS repo-scoped session evidence v1","type":"object","additionalProperties":false,"required":["payload","evidence_digest","signature"],"properties":{"payload":{"type":"object","additionalProperties":false,"required":["schema_version","evidence_id","repository_id","repository_name","device_id","session_id","harness_id","actor_id","observed_at","gds_version","baseline"],"properties":{"schema_version":{"const":1},"evidence_id":{"type":"string","minLength":1},"repository_id":{"type":"string","minLength":1},"repository_name":{"type":"string","minLength":1},"device_id":{"type":"string","minLength":1},"session_id":{"type":"string","minLength":1},"harness_id":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"harness_version":{"type":"string"},"actor_id":{"type":"string","minLength":1},"observed_at":{"type":"string","format":"date-time"},"gds_version":{"type":"string"},"previous_evidence_digest":{"$ref":"common.schema.json#/$defs/sha256Digest"},"baseline":{"type":"object","additionalProperties":false,"required":["head_oid","head_mode","upstream_state","ahead","behind","diverged","staged","unstaged","untracked","conflicted","classification","changed_paths","status_digest","submodules"],"properties":{"head_oid":{"$ref":"common.schema.json#/$defs/sourceCommit"},"head_mode":{"type":"string","minLength":1},"branch":{"type":"string"},"upstream":{"type":"string"},"upstream_state":{"type":"string","minLength":1},"ahead":{"type":"integer","minimum":0},"behind":{"type":"integer","minimum":0},"diverged":{"type":"boolean"},"staged":{"type":"integer","minimum":0},"unstaged":{"type":"integer","minimum":0},"untracked":{"type":"integer","minimum":0},"conflicted":{"type":"integer","minimum":0},"classification":{"type":"string","minLength":1},"changed_paths":{"type":"array","items":{"type":"string","minLength":1}},"status_digest":{"$ref":"common.schema.json#/$defs/sha256Digest"},"submodules":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["path","worktree_state"],"properties":{"path":{"type":"string","minLength":1},"gitlink_oid":{"$ref":"common.schema.json#/$defs/sourceCommit"},"current_oid":{"$ref":"common.schema.json#/$defs/sourceCommit"},"worktree_state":{"type":"string","minLength":1}}}}}}}},"evidence_digest":{"$ref":"common.schema.json#/$defs/sha256Digest"},"signature":{"$ref":"#/$defs/signature"}},"$defs":{"signature":{"type":"object","additionalProperties":false,"required":["algorithm","key_id","value"],"properties":{"algorithm":{"const":"ed25519"},"key_id":{"type":"string","minLength":1},"value":{"type":"string","pattern":"^[A-Za-z0-9_-]+$"}}}}} diff --git a/scripts/validate_gds_schemas.py b/scripts/validate_gds_schemas.py index 7485211..f6942bc 100755 --- a/scripts/validate_gds_schemas.py +++ b/scripts/validate_gds_schemas.py @@ -104,6 +104,7 @@ "field-ownership": "field-ownership.schema.json", "freshness-policy": "freshness-policy.schema.json", "device-evidence": "device-evidence.schema.json", + "session-evidence": "session-evidence.schema.json", "delegated-harness-evidence": "delegated-harness-evidence.schema.json", "harness-runtime-manifest": "harness-runtime-manifest.schema.json", "plan": "plan.schema.json", diff --git a/tests/fixtures/schemas/v1/cases.json b/tests/fixtures/schemas/v1/cases.json index e7b0d56..c85a2da 100644 --- a/tests/fixtures/schemas/v1/cases.json +++ b/tests/fixtures/schemas/v1/cases.json @@ -514,6 +514,8 @@ "path": "invalid-bundle-manifest-oversized-file.json", "valid": false, "expected_code": "GDS_INSTANCE_INVALID" - } + }, + {"id":"valid-session-evidence","schema":"session-evidence","path":"valid-session-evidence.json","valid":true}, + {"id":"invalid-session-evidence-missing-fields","schema":"session-evidence","path":"invalid-session-evidence-missing-fields.json","valid":false,"expected_code":"GDS_INSTANCE_INVALID"} ] } diff --git a/tests/fixtures/schemas/v1/invalid-session-evidence-missing-fields.json b/tests/fixtures/schemas/v1/invalid-session-evidence-missing-fields.json new file mode 100644 index 0000000..c565ee5 --- /dev/null +++ b/tests/fixtures/schemas/v1/invalid-session-evidence-missing-fields.json @@ -0,0 +1,47 @@ +{ + "payload": { + "schema_version": 1, + "evidence_id": "sev_01KEXAMPLE000000000000000000", + "repository_id": "repository:example-engine", + "repository_name": "example-engine", + "device_id": "device:example-device", + "harness_id": "codex", + "harness_version": "0.1.0", + "actor_id": "owner:example-owner", + "observed_at": "2026-09-21T12:00:00Z", + "gds_version": "0.9.9", + "baseline": { + "head_oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "head_mode": "branch", + "branch": "main", + "upstream": "origin/main", + "upstream_state": "present", + "ahead": 0, + "behind": 0, + "diverged": false, + "staged": 0, + "unstaged": 0, + "untracked": 0, + "conflicted": 0, + "classification": "clean", + "changed_paths": [ + "README.md", + "core/app/session_evidence.go" + ], + "submodules": [ + { + "path": "modules/example", + "gitlink_oid": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "current_oid": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "worktree_state": "clean" + } + ] + } + }, + "evidence_digest": "sha256:76101a505aeff87372e76e93edecfdf565fdb4f1cb8d6a4d12979a7f62c273f3", + "signature": { + "algorithm": "ed25519", + "key_id": "example-session-key", + "value": "40B-zrgQVnTsjwzo77qwdIuXGzAEYEMf_CHV30VhH7107_K4yJ0RoefnnUgBbvMOW0Jqcr0qK-_BwRdNXAc0BQ" + } +} \ No newline at end of file diff --git a/tests/fixtures/schemas/v1/valid-session-evidence.json b/tests/fixtures/schemas/v1/valid-session-evidence.json new file mode 100644 index 0000000..61ac2f9 --- /dev/null +++ b/tests/fixtures/schemas/v1/valid-session-evidence.json @@ -0,0 +1,49 @@ +{ + "payload": { + "schema_version": 1, + "evidence_id": "sev_01KEXAMPLE000000000000000000", + "repository_id": "repository:example-engine", + "repository_name": "example-engine", + "device_id": "device:example-device", + "session_id": "session:example-session", + "harness_id": "codex", + "harness_version": "0.1.0", + "actor_id": "owner:example-owner", + "observed_at": "2026-09-21T12:00:00Z", + "gds_version": "0.9.9", + "baseline": { + "head_oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "head_mode": "branch", + "branch": "main", + "upstream": "origin/main", + "upstream_state": "present", + "ahead": 0, + "behind": 0, + "diverged": false, + "staged": 0, + "unstaged": 0, + "untracked": 0, + "conflicted": 0, + "classification": "clean", + "changed_paths": [ + "README.md", + "core/app/session_evidence.go" + ], + "status_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "submodules": [ + { + "path": "modules/example", + "gitlink_oid": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "current_oid": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "worktree_state": "clean" + } + ] + } + }, + "evidence_digest": "sha256:76101a505aeff87372e76e93edecfdf565fdb4f1cb8d6a4d12979a7f62c273f3", + "signature": { + "algorithm": "ed25519", + "key_id": "example-session-key", + "value": "40B-zrgQVnTsjwzo77qwdIuXGzAEYEMf_CHV30VhH7107_K4yJ0RoefnnUgBbvMOW0Jqcr0qK-_BwRdNXAc0BQ" + } +}