diff --git a/.github/workflows/agent-identity.yml b/.github/workflows/agent-identity.yml new file mode 100644 index 0000000000..78f8e01eae --- /dev/null +++ b/.github/workflows/agent-identity.yml @@ -0,0 +1,85 @@ +name: Agent identity acceptance + +on: + pull_request: + branches: [app/v3] + paths: + - internal/agentdid/** + - internal/session/agent_identity*.go + - internal/session/place.go + - internal/session/session.go + - internal/session/approvalposture.go + - internal/session/effort.go + - cmd/codeaf/syncdrive_test.go + - cmd/codeaf/heldchat_drive_test.go + - cmd/codeaf/app_agent_identity_e2e_test.go + - .github/workflows/agent-identity.yml + +permissions: + contents: read + +concurrency: + group: agent-identity-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + binary-e2e: + name: Binary user-flow QA + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build the actual app binary + run: make build + - name: Exercise the app as a desktop client + run: go test ./cmd/codeaf -run '^TestAppAgentIdentityBinaryE2E$' -count=1 -v -timeout 5m + - name: Check that custody preserves existing sync behavior + run: go test ./cmd/codeaf -run '^TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable$|^TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat$|^TestDriveSideDoorPublishesAndReleases$|^TestDriveSideStartsWhenAPairingMakesTheIdentity$|^TestAChatMovedBackGetsADriveSideOfItsOwn$|^TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn$|^TestDriveSideStartsWhenTheSoloMarkerEnds$|^TestAppOrdinaryClientIDSurvivesABackendRestart$|^TestC6AppSendWithoutAViewerStaysLocal$|^TestC27NewChatByDefaultStaysQuiet$' -count=1 -v -timeout 5m + + - name: Repeat sealed history portability + run: go test ./internal/session -run '^TestSealedTreeCarriesTaskHistory$' -count=20 -v -timeout 5m + + race: + name: Custody race checks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Race-check custody and lifecycle + run: go test -race ./internal/agentdid ./internal/session -run 'TestCredentials|TestCustody|TestOnlyTheRecipient|TestUnknownDocument|TestAgentIdentity|TestAgentDID|TestToolCommandsSeeTheAgentDID|TestTaskWorker.*DID' -count=1 -timeout 15m + + repository: + name: Repository gates + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build with the pinned engine + run: make build + - name: Run the repository PR gates against app/v3 + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: make pr-ready + - name: Run touched packages even if an existing light gate fails + if: ${{ !cancelled() }} + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: make test-touched diff --git a/cmd/codeaf/app_agent_identity_e2e_test.go b/cmd/codeaf/app_agent_identity_e2e_test.go new file mode 100644 index 0000000000..58652bcbc5 --- /dev/null +++ b/cmd/codeaf/app_agent_identity_e2e_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Run make build first. This drives the shipped stdio door in a fresh process, +// including a real shell tool, restart, and migration. Only the model is scripted. +func TestAppAgentIdentityBinaryE2E(t *testing.T) { + binary, err := filepath.Abs(filepath.Join("..", "..", "bin", "codeaf")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(binary); err != nil { + t.Skip("run make build to exercise the actual app binary") + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + return + } + var body struct { + Stream bool `json:"stream"` + Tools []json.RawMessage + Messages []struct{ Role string } + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + needsTool := len(body.Tools) > 0 + for i := len(body.Messages) - 1; i >= 0; i-- { + if body.Messages[i].Role == "tool" { + needsTool = false + break + } + if body.Messages[i].Role == "user" { + break + } + } + delta := map[string]any{"content": "Identity QA complete"} + finish := "stop" + if needsTool { + args, _ := json.Marshal(map[string]string{"command": `printf '%s' "$CODEAF_AGENT_DID" > did.txt; env | sed -n '/^CODEAF_AGENT_/p' > identity-env.txt`}) + delta = map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "identity-qa", "type": "function", "function": map[string]any{"name": "bash", "arguments": string(args)}}}} + finish = "tool_calls" + } + if body.Stream { + w.Header().Set("Content-Type", "text/event-stream") + chunk, _ := json.Marshal(map[string]any{"id": "qa", "choices": []any{map[string]any{"index": 0, "delta": delta}}}) + fmt.Fprintf(w, "data: %s\n\ndata: {\"id\":\"qa\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":%q}]}\n\ndata: [DONE]\n\n", chunk, finish) + return + } + delta["role"] = "assistant" + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"index": 0, "message": delta, "finish_reason": finish}}}) + })) + defer model.Close() + root := resolvedTempDir(t) + appHome, workspace := filepath.Join(root, "state"), filepath.Join(root, "project") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + start := func() *identityQAProcess { + return startIdentityQAProcess(t, binary, root, appHome, workspace, model.URL) + } + p := start() + created := p.call("agents.create", map[string]any{"workspace": workspace, "model": "openai/gpt-4.1", "approval_posture": "allow"}) + var result struct{ Agent struct{ ID string } } + if err := json.Unmarshal(created, &result); err != nil || result.Agent.ID == "" { + t.Fatalf("create: %s, %v", created, err) + } + id := result.Agent.ID + run := func(p *identityQAProcess) { + p.call("agents.send", map[string]any{"id": id, "text": "Run the identity QA shell command"}) + p.wait(func(f identityQAFrame) bool { return f.Event == "turn.done" }) + } + run(p) + p.stop() + + var truth string + if err := filepath.WalkDir(appHome, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.Name() == "session.json" && filepath.Base(filepath.Dir(filepath.Dir(path))) == id { + truth = path + } + return nil + }); err != nil || truth == "" { + t.Fatalf("no sealed session truth: %s %v", truth, err) + } + readDoc := func() agentdid.Document { + raw, err := os.ReadFile(truth) + if err != nil { + t.Fatal(err) + } + var state struct { + Identity agentdid.Document `json:"agent_identity"` + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + return state.Identity + } + doc := readDoc() + if doc.V != agentdid.Version || doc.Signing == nil || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("incomplete identity after a real turn: %+v", doc) + } + // The real bash tool wrote only the public identity into its environment. + checkShell := func() { + did, err := os.ReadFile(filepath.Join(workspace, "did.txt")) + if err != nil || string(did) != doc.DID { + t.Fatalf("shell DID: %q %v", did, err) + } + env, err := os.ReadFile(filepath.Join(workspace, "identity-env.txt")) + if err != nil || string(env) != agentdid.AgentDIDEnv+"="+doc.DID+"\n" { + t.Fatalf("shell identity environment: %q %v", env, err) + } + } + checkShell() + if !identity.Solo(appHome) { + t.Fatal("a first app launch lost its solo sync posture") + } + owner, err := identity.Load(appHome) + if err != nil { + t.Fatal(err) + } + keys, err := doc.Unlock(owner.CellKey()) + if err != nil { + t.Fatal(err) + } + sig, err := keys.Sign(doc.DID, "receipt/v1", []byte("real process")) + if err != nil || !agentdid.Verify(doc.DID, "receipt/v1", []byte("real process"), sig) { + t.Fatalf("persisted signing custody: %v", err) + } + p = start() + listed := p.call("agents.list", map[string]any{"state": "all"}) + if !bytes.Contains(listed, []byte(doc.DID)) || bytes.Contains(listed, []byte("custody")) || bytes.Contains(listed, []byte("encryption")) { + t.Fatalf("agent list must expose only the public DID: %s", listed) + } + run(p) + p.stop() + checkShell() + again := readDoc() + if !bytes.Equal(again.Custody, doc.Custody) || again.DID != doc.DID { + t.Fatal("restarting replaced the agent's keys") + } + + // Model an actual #2027 folder, then use the app's open and send doors. + writeDocument := func(document string) []byte { + raw, _ := os.ReadFile(truth) + var state map[string]json.RawMessage + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state["agent_identity"] = json.RawMessage(document) + raw, _ = json.Marshal(state) + if err := os.WriteFile(truth, raw, 0o600); err != nil { + t.Fatal(err) + } + return raw + } + legacy, _ := json.Marshal(map[string]string{"did": doc.DID, "session": id}) + before := writeDocument(string(legacy)) + p = start() + p.call("agents.open", map[string]any{"id": id}) + after, _ := os.ReadFile(truth) + if !bytes.Equal(before, after) { + t.Fatalf("opening a legacy chat rewrote its identity: before %s; after %s", before, after) + } + run(p) + p.stop() + upgraded := readDoc() + if upgraded.DID != doc.DID || upgraded.V != agentdid.Version || !upgraded.SigningUnavailable || upgraded.Encryption == nil || len(upgraded.Custody) == 0 { + t.Fatalf("legacy chat did not lazily upgrade: %+v", upgraded) + } + future := fmt.Sprintf(`{"v":"v99", "did":%q, "session":%q, "statements": [ {"future": 1.00} ]}`, doc.DID, id) + // Preserve the future value's original whitespace inside its enclosing file. + raw := writeDocument(future) + var compact json.RawMessage + var state map[string]json.RawMessage + _ = json.Unmarshal(raw, &state) + compact = state["agent_identity"] + raw = bytes.Replace(raw, compact, []byte(future), 1) + if err := os.WriteFile(truth, raw, 0o600); err != nil { + t.Fatal(err) + } + p = start() + p.call("agents.rename", map[string]any{"id": id, "title": "Future identity QA"}) + p.stop() + raw, _ = os.ReadFile(truth) + if !bytes.Contains(raw, []byte(future)) { + t.Fatalf("rename rewrote a future document: %s", raw) + } + // Reading the chat through its public session API must preserve that document. + meta, err := session.LoadMeta(filepath.Dir(filepath.Dir(truth))) + if err != nil || meta.AgentDID() != doc.DID { + t.Fatalf("public DID disappeared after future write: %v", err) + } + t.Log("actual app binary: create, real bash environment, custody, restart, legacy open/send, future rename passed") +} + +type identityQAFrame struct { + ID int `json:"id"` + Event string `json:"event"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +type identityQAProcess struct { + t *testing.T + cmd *exec.Cmd + in io.WriteCloser + frames chan identityQAFrame + pending []identityQAFrame + stderr bytes.Buffer + next int + stopped bool +} + +func startIdentityQAProcess(t *testing.T, binary, root, home, workspace, base string) *identityQAProcess { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + t.Cleanup(cancel) + p := &identityQAProcess{t: t, frames: make(chan identityQAFrame, 4096)} + p.cmd = exec.CommandContext(ctx, binary, "app", "--home", home) + p.cmd.Dir = workspace + p.cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "home"), "TMPDIR=" + root, "OPENROUTER_API_KEY=sk-or-v1-identity-qa-dummy", "CODEAF_BASE_URL=" + base, "TERM=dumb"} + p.cmd.Stderr = &p.stderr + var err error + p.in, err = p.cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := p.cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := p.cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if !p.stopped { + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + t.Logf("app stderr: %s", &p.stderr) + } + }) + go func() { + defer close(p.frames) + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 4<<20) + for scanner.Scan() { + var frame identityQAFrame + if json.Unmarshal(scanner.Bytes(), &frame) == nil { + p.frames <- frame + } + } + }() + p.wait(func(f identityQAFrame) bool { return f.Event == "hello" }) + return p +} + +func (p *identityQAProcess) wait(matches func(identityQAFrame) bool) identityQAFrame { + p.t.Helper() + for i, frame := range p.pending { + if matches(frame) { + p.pending = append(p.pending[:i], p.pending[i+1:]...) + return frame + } + } + deadline := time.NewTimer(40 * time.Second) + defer deadline.Stop() + for { + select { + case frame, ok := <-p.frames: + if !ok { + p.t.Fatal("app stdout ended before the expected response") + } + if matches(frame) { + return frame + } + p.pending = append(p.pending, frame) + case <-deadline.C: + p.t.Fatal("app did not answer within 40 seconds") + } + } +} + +func (p *identityQAProcess) call(method string, params any) json.RawMessage { + p.t.Helper() + p.next++ + if err := json.NewEncoder(p.in).Encode(map[string]any{"id": p.next, "method": method, "params": params}); err != nil { + p.t.Fatal(err) + } + f := p.wait(func(f identityQAFrame) bool { return f.ID == p.next }) + if len(f.Error) != 0 && string(f.Error) != "null" { + p.t.Fatalf("%s: %s", method, f.Error) + } + return f.Result +} + +func (p *identityQAProcess) stop() { + p.t.Helper() + p.call("shutdown", map[string]any{}) + _ = p.in.Close() + err := p.cmd.Wait() + p.stopped = true + if err != nil { + p.t.Fatalf("app exit: %v\n%s", err, &p.stderr) + } +} diff --git a/cmd/codeaf/heldchat_drive_test.go b/cmd/codeaf/heldchat_drive_test.go index ef508eee0a..cc37718acd 100644 --- a/cmd/codeaf/heldchat_drive_test.go +++ b/cmd/codeaf/heldchat_drive_test.go @@ -209,7 +209,7 @@ func TestAChatAnotherProcessHoldsIsNeitherRenewedNorReleasedFromHere(t *testing. } func TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) s, writes := heldChatRelay(t, c.ID) firstBook := syncDrives defer firstBook.closeAll() @@ -276,7 +276,7 @@ func TestAQuitAtTheMoveQuestionLeavesTheOtherWindowsChatSendable(t *testing.T) { } func TestTheFreshChatBesideAHeldOneWorksOnItsOwnSeat(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) s, writes := heldChatRelay(t, c.ID) firstBook := syncDrives defer firstBook.closeAll() diff --git a/cmd/codeaf/syncdrive_test.go b/cmd/codeaf/syncdrive_test.go index 341f1b7f5d..d7ccf42322 100644 --- a/cmd/codeaf/syncdrive_test.go +++ b/cmd/codeaf/syncdrive_test.go @@ -53,8 +53,18 @@ func doorChat(t *testing.T) (session.Config, cell.Cell) { // spokenDoorChat records a person's command through the ordinary session input. // Publication fixtures need conversation work rather than an unused startup chat. func spokenDoorChat(t *testing.T) (session.Config, cell.Cell) { + return spokenDoorChatWithPairing(t, false) +} + +// Shared fixtures establish their paired profile before the first custody write. +func spokenDoorChatWithPairing(t *testing.T, paired bool) (session.Config, cell.Cell) { t.Helper() cfg, c := doorChat(t) + if paired { + if _, err := identity.Ensure(home.Dir()); err != nil { + t.Fatal(err) + } + } cfg.Workspace, cfg.SessionFile = cfg.Place.Workspace, cfg.Place.Transcript() cfg.Model, cfg.APIKey, cfg.BaseURL = "stub/chat", "stub", "http://127.0.0.1:1" agent, err := session.New(cfg) @@ -118,7 +128,7 @@ func TestDriveSideOffIsStageZero(t *testing.T) { // With a relay set the door notes every sealed turn for it, and closing the // door gives the lease back after publishing what was sealed. func TestDriveSideDoorPublishesAndReleases(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) t.Cleanup(srv.Close) if _, err := identity.Ensure(home.Dir()); err != nil { @@ -158,8 +168,8 @@ func TestSayRefusalOnlyForRefusals(t *testing.T) { } } -// A chat that opened before this computer had an identity starts to sync the -// moment /pair makes one, with no restart: the next call's seal is published and +// A chat that opened before this computer was paired starts to sync the +// moment /pair engages its identity, with no restart: the next call's seal is published and // closing the chat gives the lease back, exactly as for a chat that was paired // from the start. func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) { @@ -173,7 +183,7 @@ func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) { if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { t.Fatal(err) } - if _, err := identity.Ensure(home.Dir()); err != nil { // what /pair does in the running chat + if err := identity.EndSolo(home.Dir()); err != nil { // pairing engages an existing local identity t.Fatal(err) } if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { @@ -285,7 +295,7 @@ func TestStartDriveWithNoIdentityReportsNothing(t *testing.T) { // its own. The drive side kept from before the move only shows the chat, so reusing it would // refuse every tool call in the chat that was moved back. func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv, published, closeRelay := driveRelayWithPublication(t, c.ID) defer closeRelay() t.Setenv(syncsetup.URLVar, srv.URL) @@ -354,7 +364,7 @@ func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) { // drive side this window kept, opens with a drive side of its own: the old one publishes under a // lease that is gone, so reusing it would refuse every tool call in the chat that came back. func TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn(t *testing.T) { - cfg, c := spokenDoorChat(t) + cfg, c := spokenDoorChatWithPairing(t, true) srv, published, closeRelay := driveRelayWithPublication(t, c.ID) defer closeRelay() t.Setenv(syncsetup.URLVar, srv.URL) diff --git a/docs/changes/unreleased/2090-agent-identity-custody.md b/docs/changes/unreleased/2090-agent-identity-custody.md new file mode 100644 index 0000000000..dd21e428c3 --- /dev/null +++ b/docs/changes/unreleased/2090-agent-identity-custody.md @@ -0,0 +1,18 @@ +--- +kind: added +title: Agent identities retain encrypted signing and encryption keys +pr: 2090 +surface: [chat, engine] +invalidates: + - "An agent DID used to be a public name only; new conversations now retain encrypted signing and encryption keys." +--- + +Each new conversation keeps a v1 identity document in its sealed truth, including +Ed25519 and companion X25519 public keys and encrypted private-key custody. +Purpose-separated signing verifies with the public DID alone. Commands and +`agents.list` still expose only the public DID. A copy or branch mints fresh keys. + +Public-only identities migrate on the next metadata write without changing their +DID. Their missing signing key remains explicitly unavailable; they gain encrypted +X25519 custody. Opening an old folder writes nothing. Unknown document versions +remain byte-identical across metadata writes. Agent key rotation is not included. diff --git a/internal/agentdid/agentdid.go b/internal/agentdid/agentdid.go index 20da535690..fccb66de19 100644 --- a/internal/agentdid/agentdid.go +++ b/internal/agentdid/agentdid.go @@ -11,9 +11,7 @@ // base64url without padding, after the `z` (control-plane's DIDService). Using // the same spelling lets the two products read each other's identifiers. // -// THE KEY IS NOT KEPT. A DID here is an identifier, not a credential: nothing -// signs with it today, so only the public half is minted and the private half -// is dropped at once. Nothing secret is ever written to disk for it. +// New conversations retain both keys through encrypted Document custody. package agentdid import ( diff --git a/internal/agentdid/credentials.go b/internal/agentdid/credentials.go new file mode 100644 index 0000000000..d59c1adc44 --- /dev/null +++ b/internal/agentdid/credentials.go @@ -0,0 +1,309 @@ +package agentdid + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strings" +) + +// Credentials hold private keys only in memory. JSON and formatting expose no +// key material; persistence goes through Document.Protect exclusively. +type Credentials struct { + signing [ed25519.SeedSize]byte + encryption [32]byte + hasSigning bool +} + +// String prevents ordinary diagnostic formatting from revealing key material. +func (Credentials) String() string { return "agent credentials (private)" } + +// GoString prevents detailed diagnostic formatting from revealing key material. +func (c Credentials) GoString() string { return c.String() } + +// Generate mints both keys without writing anything to disk. +func Generate(session string) (Document, *Credentials, error) { + c := &Credentials{hasSigning: true} + if _, err := rand.Read(c.signing[:]); err != nil { + return Document{}, nil, err + } + d := Document{V: Version, Session: session, pending: c, DID: FromPublicKey(c.public())} + if err := c.addEncryption(&d); err != nil { + return Document{}, nil, err + } + d.Signing = &Key{Type: "Ed25519", Public: encode(c.public())} + return d, c, nil +} + +// Upgrade preserves a public-only DID whose discarded private key cannot be +// recovered. It adds encryption and states the lack of signing custody honestly. +func Upgrade(d Document) (Document, *Credentials, error) { + if d.V != "" || !Valid(d.DID) { + return Document{}, nil, ErrDocument + } + c := &Credentials{} + d.V, d.SigningUnavailable = Version, true + d.Signing = &Key{Type: "Ed25519", Public: encode(publicKey(d.DID))} + if err := c.addEncryption(&d); err != nil { + return Document{}, nil, err + } + return d, c, nil +} + +func (c *Credentials) addEncryption(d *Document) error { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return err + } + copy(c.encryption[:], key.Bytes()) + d.Encryption = &Key{Type: "X25519", Public: encode(key.PublicKey().Bytes())} + return nil +} + +func (c *Credentials) public() ed25519.PublicKey { + return ed25519.NewKeyFromSeed(c.signing[:]).Public().(ed25519.PublicKey) +} + +func encode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func publicKey(did string) ed25519.PublicKey { + if !Valid(did) { + return nil + } + raw, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(did, prefix)) + return ed25519.PublicKey(raw[len(multicodec):]) +} + +// purposeBody frames each component so a purpose or payload cannot be confused +// with an adjacent field. Every application purpose carries its own version. +func purposeBody(label, did, purpose string, payload []byte) ([]byte, error) { + separator := strings.LastIndex(purpose, "/v") + if separator <= 0 { + return nil, errors.New("agent identity: purpose must end in /v") + } + version := purpose[separator+2:] + if version == "" || version[0] == '0' { + return nil, errors.New("agent identity: purpose must end in /v") + } + for _, ch := range version { + if ch < '0' || ch > '9' { + return nil, errors.New("agent identity: invalid purpose version") + } + } + out := []byte(label) + for _, value := range [][]byte{[]byte(did), []byte(purpose), payload} { + out = binary.BigEndian.AppendUint64(out, uint64(len(value))) + out = append(out, value...) + } + return out, nil +} + +// Sign signs a purpose-framed statement only as the DID owned by these keys. +func (c *Credentials) Sign(did, purpose string, payload []byte) ([]byte, error) { + if c == nil || !c.hasSigning { + return nil, ErrUnavailable + } + if did != FromPublicKey(c.public()) { + return nil, ErrDocument + } + body, err := purposeBody("codeaf/agent-signature/v1", did, purpose, payload) + if err != nil { + return nil, err + } + return ed25519.Sign(ed25519.NewKeyFromSeed(c.signing[:]), body), nil +} + +// Verify needs only the public DID, never a cell, identity document or home key. +func Verify(did, purpose string, payload, signature []byte) bool { + public := publicKey(did) + if public == nil { + return false + } + body, err := purposeBody("codeaf/agent-signature/v1", did, purpose, payload) + return err == nil && ed25519.Verify(public, body, signature) +} + +func aeadFor(key []byte, label string) (cipher.AEAD, error) { + derived, err := hkdf.Key(sha256.New, key, nil, label, 32) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(derived) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func encrypt(key, plain, aad []byte, label string) ([]byte, error) { + aead, err := aeadFor(key, label) + if err != nil { + return nil, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return aead.Seal(nonce, nonce, plain, aad), nil +} + +func decrypt(key, box, aad []byte, label string) ([]byte, error) { + aead, err := aeadFor(key, label) + if err != nil { + return nil, err + } + if len(box) < aead.NonceSize()+aead.Overhead() { + return nil, ErrDocument + } + return aead.Open(nil, box[:aead.NonceSize()], box[aead.NonceSize():], aad) +} + +func (d Document) binding() []byte { + body, _ := purposeBody("codeaf/agent-custody/v1", d.DID, "custody/v1", []byte(d.Session)) + return body +} + +// Protect encrypts both private keys under the profile's cell key. The nonce +// and ciphertext travel inside the cell; the wrapping key never does. +func (d *Document) Protect(c *Credentials, cellKey []byte) error { + if len(cellKey) != 32 || d.V != Version || c == nil { + return ErrDocument + } + if err := c.matches(*d); err != nil { + return err + } + plain := make([]byte, 65) + if c.hasSigning { + plain[0] = 1 + } + copy(plain[1:33], c.signing[:]) + copy(plain[33:], c.encryption[:]) + defer clear(plain) + box, err := encrypt(cellKey, plain, d.binding(), "codeaf/agent-custody/v1") + if err == nil { + d.Custody = box + d.pending = nil + } + return err +} + +// Unlock refuses transplanted, corrupt and unsupported custody without +// exposing private material in errors. +func (d Document) Unlock(cellKey []byte) (*Credentials, error) { + if d.V == Version && len(d.Custody) == 0 && d.pending != nil { + if err := d.pending.matches(d); err != nil { + return nil, err + } + return d.pending, nil + } + if d.V != Version || len(cellKey) != 32 { + return nil, ErrDocument + } + plain, err := decrypt(cellKey, d.Custody, d.binding(), "codeaf/agent-custody/v1") + if err != nil { + return nil, fmt.Errorf("agent identity: custody could not be opened") + } + defer clear(plain) + if len(plain) != 65 || plain[0] > 1 { + return nil, ErrDocument + } + c := &Credentials{hasSigning: plain[0] == 1} + copy(c.signing[:], plain[1:33]) + copy(c.encryption[:], plain[33:]) + if err := c.matches(d); err != nil { + clear(c.signing[:]) + clear(c.encryption[:]) + return nil, err + } + return c, nil +} + +func (c *Credentials) matches(d Document) error { + if d.Signing == nil || d.Encryption == nil || d.Signing.Type != "Ed25519" || d.Encryption.Type != "X25519" { + return ErrDocument + } + if d.Signing.Public != encode(publicKey(d.DID)) || !Valid(d.DID) { + return ErrDocument + } + if c.hasSigning == d.SigningUnavailable { + return ErrDocument + } + if c.hasSigning && FromPublicKey(c.public()) != d.DID { + return ErrDocument + } + key, err := ecdh.X25519().NewPrivateKey(c.encryption[:]) + if err != nil || d.Encryption.Public != encode(key.PublicKey().Bytes()) { + return ErrDocument + } + return nil +} + +// Seal encrypts material to the document's X25519 key, using a fresh ephemeral +// sender key. The recipient and purpose are authenticated with the ciphertext. +func Seal(d Document, purpose string, payload []byte) ([]byte, error) { + if d.V != Version || d.Encryption == nil || d.Encryption.Type != "X25519" || !Valid(d.DID) { + return nil, ErrDocument + } + aad, err := purposeBody("codeaf/agent-envelope/v1", d.DID, purpose, nil) + if err != nil { + return nil, err + } + raw, err := base64.RawURLEncoding.DecodeString(d.Encryption.Public) + if err != nil { + return nil, ErrDocument + } + public, err := ecdh.X25519().NewPublicKey(raw) + if err != nil { + return nil, err + } + ephemeral, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + shared, err := ephemeral.ECDH(public) + if err != nil { + return nil, err + } + defer clear(shared) + box, err := encrypt(shared, payload, aad, "codeaf/agent-envelope/v1") + if err != nil { + return nil, err + } + return append(ephemeral.PublicKey().Bytes(), box...), nil +} + +// Open decrypts material only with this document's private companion key. +func (c *Credentials) Open(d Document, purpose string, box []byte) ([]byte, error) { + if c == nil || d.V != Version || len(box) < 32 { + return nil, ErrDocument + } + if err := c.matches(d); err != nil { + return nil, err + } + aad, err := purposeBody("codeaf/agent-envelope/v1", d.DID, purpose, nil) + if err != nil { + return nil, err + } + public, err := ecdh.X25519().NewPublicKey(box[:32]) + if err != nil { + return nil, err + } + key, err := ecdh.X25519().NewPrivateKey(c.encryption[:]) + if err != nil { + return nil, err + } + shared, err := key.ECDH(public) + if err != nil { + return nil, err + } + defer clear(shared) + return decrypt(shared, box[32:], aad, "codeaf/agent-envelope/v1") +} diff --git a/internal/agentdid/credentials_test.go b/internal/agentdid/credentials_test.go new file mode 100644 index 0000000000..cb5c352ca1 --- /dev/null +++ b/internal/agentdid/credentials_test.go @@ -0,0 +1,197 @@ +package agentdid + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "testing" +) + +func TestCredentialsProveTheDIDAndSeparatePurposes(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + payload := []byte("a statement") + sig, err := keys.Sign(d.DID, "receipt/v1", payload) + if err != nil { + t.Fatal(err) + } + if !Verify(d.DID, "receipt/v1", payload, sig) { + t.Fatal("public DID did not verify") + } + for _, purpose := range []string{"receipt/v2", "other/v1", ""} { + if Verify(d.DID, purpose, payload, sig) { + t.Fatal("cross-purpose signature accepted") + } + } + if Verify(d.DID, "receipt/v1", []byte("changed"), sig) { + t.Fatal("changed statement accepted") + } + other, _, _ := Generate("other") + if _, err := keys.Sign(other.DID, "receipt/v1", payload); err == nil { + t.Fatal("signed another DID") + } + if _, err := keys.Sign(d.DID, "unversioned", payload); err == nil { + t.Fatal("unversioned purpose accepted") + } +} + +func TestCustodyIsEncryptedAndBoundToTheDocument(t *testing.T) { + d, keys, _ := Generate("chat") + wrapping := bytes.Repeat([]byte{3}, 32) + if err := d.Protect(keys, wrapping); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(d) + for _, secret := range [][]byte{keys.signing[:], keys.encryption[:]} { + for _, encoded := range [][]byte{secret, []byte(base64.StdEncoding.EncodeToString(secret)), []byte(encode(secret)), []byte(hex.EncodeToString(secret))} { + if bytes.Contains(raw, encoded) || bytes.Contains([]byte(fmt.Sprintf("%v %+v %#v", keys, keys, keys)), encoded) { + t.Fatal("plaintext key escaped JSON or diagnostics") + } + } + } + restored, err := d.Unlock(wrapping) + if err != nil { + t.Fatal(err) + } + if _, err := restored.Sign(d.DID, "test/v1", nil); err != nil { + t.Fatal(err) + } + if _, err := d.Unlock(bytes.Repeat([]byte{4}, 32)); err == nil { + t.Fatal("wrong home opened custody") + } + d.Session = "copy" + if _, err := d.Unlock(wrapping); err == nil { + t.Fatal("copy opened original custody") + } +} + +func TestCustodyRejectsCorruptionAndPublicKeySubstitution(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + wrapping := bytes.Repeat([]byte{7}, 32) + d, err = d.Persist(wrapping) + if err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*Document){ + func(d *Document) { d.Custody[0] ^= 1 }, + func(d *Document) { d.Custody = d.Custody[:5] }, + func(d *Document) { d.DID, _ = New() }, + func(d *Document) { d.Signing.Public = "substituted" }, + func(d *Document) { d.Encryption.Public = "substituted" }, + func(d *Document) { d.SigningUnavailable = true }, + } { + raw, _ := json.Marshal(d) + var changed Document + if err := json.Unmarshal(raw, &changed); err != nil { + t.Fatal(err) + } + mutate(&changed) + if opened, err := changed.Unlock(wrapping); err == nil || opened != nil { + t.Fatal("tampered custody returned private credentials") + } + } + for _, purpose := range []string{"", "receipt", "/v1", "receipt/v0", "receipt/v01", "receipt/v1/other"} { + if _, err := keys.Sign(d.DID, purpose, nil); err == nil { + t.Fatalf("invalid purpose %q accepted", purpose) + } + } +} + +func TestOnlyTheRecipientCanOpenSealedMaterial(t *testing.T) { + d, keys, _ := Generate("chat") + other, wrong, _ := Generate("other") + box, err := Seal(d, "message/v1", []byte("for one agent")) + if err != nil { + t.Fatal(err) + } + plain, err := keys.Open(d, "message/v1", box) + if err != nil || string(plain) != "for one agent" { + t.Fatalf("open: %q %v", plain, err) + } + if _, err := wrong.Open(other, "message/v1", box); err == nil { + t.Fatal("other agent opened message") + } + if _, err := keys.Open(d, "other/v1", box); err == nil { + t.Fatal("cross-purpose message opened") + } + box[len(box)-1] ^= 1 + if _, err := keys.Open(d, "message/v1", box); err == nil { + t.Fatal("damaged message opened") + } +} + +func TestUnknownDocumentRoundTrips(t *testing.T) { + raw := []byte(`{"v":"v99", "did":"future", "session":"chat", "keys":{"future":[1,2]}}`) + var d Document + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + got, _ := d.MarshalJSON() + if !bytes.Equal(got, raw) { + t.Fatalf("future document changed: %s", got) + } + if _, err := d.Unlock(make([]byte, 32)); err == nil { + t.Fatal("future document unlocked") + } +} + +func TestCredentialsRejectMalformedEnvelopesAndSupportHierarchicalPurposes(t *testing.T) { + d, keys, err := Generate("chat") + if err != nil { + t.Fatal(err) + } + const purpose = "codeaf/verification/receipt/v1" + sig, err := keys.Sign(d.DID, purpose, nil) + if err != nil || !Verify(d.DID, purpose, nil, sig) { + t.Fatalf("hierarchical versioned purpose: %v", err) + } + for _, did := range []string{"", "did:key:z", "did:key:z!!!!", d.DID + "#w1"} { + if Verify(did, purpose, nil, sig) { + t.Fatalf("malformed agent DID %q verified", did) + } + } + box, err := Seal(d, purpose, []byte("private")) + if err != nil { + t.Fatal(err) + } + for _, broken := range [][]byte{nil, box[:5], box[:32], append(make([]byte, 32), box[32:]...)} { + if _, err := keys.Open(d, purpose, broken); err == nil { + t.Fatal("malformed or low-order envelope opened") + } + } + if _, err := (*Credentials)(nil).Sign(d.DID, purpose, nil); err == nil { + t.Fatal("nil custody signed a statement") + } + if _, err := (*Credentials)(nil).Open(d, purpose, box); err == nil { + t.Fatal("nil custody opened an envelope") + } + for _, public := range []string{"!", encode(make([]byte, 31)), encode(make([]byte, 32))} { + broken := d + broken.Encryption = &Key{Type: "X25519", Public: public} + if _, err := Seal(broken, purpose, nil); err == nil { + t.Fatal("invalid recipient key accepted") + } + } +} + +func TestCustodyEncodingCannotLookLikeAnAbsolutePath(t *testing.T) { + original := custody{255, 255, 255, 255} + raw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + if bytes.ContainsAny(raw, "/+=") { + t.Fatalf("custody is not URL-safe: %s", raw) + } + var restored custody + if err := json.Unmarshal(raw, &restored); err != nil || !bytes.Equal(original, restored) { + t.Fatalf("round trip: %v", err) + } +} diff --git a/internal/agentdid/document.go b/internal/agentdid/document.go new file mode 100644 index 0000000000..1507061ccb --- /dev/null +++ b/internal/agentdid/document.go @@ -0,0 +1,163 @@ +package agentdid + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" +) + +// Version is the identity document format understood by this build. +const Version = "v1" + +// Key describes a public key without exposing its private half. +type Key struct { + Type string `json:"type"` + Public string `json:"public"` +} + +// Document keeps public keys and encrypted custody together as sealed truth. +// Unknown documents retain their original bytes, including future statements. +type Document struct { + V string `json:"v,omitempty"` + DID string `json:"did"` + Session string `json:"session"` + Signing *Key `json:"signing,omitempty"` + Encryption *Key `json:"encryption,omitempty"` + Custody custody `json:"custody,omitempty"` + SigningUnavailable bool `json:"signing_unavailable,omitempty"` + pending *Credentials + raw json.RawMessage +} + +// custody uses URL-safe encoding so opaque ciphertext never resembles a path. +type custody []byte + +func (c custody) MarshalJSON() ([]byte, error) { + return json.Marshal(base64.RawURLEncoding.EncodeToString(c)) +} + +func (c *custody) UnmarshalJSON(raw []byte) error { + var encoded string + if err := json.Unmarshal(raw, &encoded); err != nil { + return err + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return err + } + *c = decoded + return nil +} + +type documentWire Document + +// UnmarshalJSON reads routing fields while retaining the complete document. +func (d *Document) UnmarshalJSON(raw []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + *d = Document{raw: append([]byte(nil), raw...)} + _ = json.Unmarshal(fields["did"], &d.DID) + _ = json.Unmarshal(fields["session"], &d.Session) + if v, ok := fields["v"]; ok { + if json.Unmarshal(v, &d.V) != nil || d.V == "" { + d.V = "unknown" + } + } + if d.V != "" && d.V != Version { + return nil + } + var wire documentWire + if err := json.Unmarshal(raw, &wire); err != nil { + return err + } + wire.raw = d.raw + *d = Document(wire) + return nil +} + +// MarshalJSON never translates a document supplied by a newer writer. +func (d Document) MarshalJSON() ([]byte, error) { + if d.Unknown() && len(d.raw) != 0 { + return append([]byte(nil), d.raw...), nil + } + if d.V == Version && len(d.raw) != 0 { + // Keep additive fields even when this version does not interpret them. + var fields map[string]json.RawMessage + if err := json.Unmarshal(d.raw, &fields); err != nil { + return nil, err + } + encoded, err := json.Marshal(documentWire(d)) + if err != nil { + return nil, err + } + var known map[string]json.RawMessage + if err := json.Unmarshal(encoded, &known); err != nil { + return nil, err + } + for k, v := range known { + fields[k] = v + } + return json.Marshal(fields) + } + return json.Marshal(documentWire(d)) +} + +// Unknown reports a version that this build must preserve without interpreting. +func (d Document) Unknown() bool { return d.V != "" && d.V != Version } + +// PreserveJSON restores the original future document after an enclosing JSON +// encoder has indented it. This keeps its value byte-identical across meta writes. +func (d Document) PreserveJSON(encoded []byte) []byte { + if !d.Unknown() || len(d.raw) == 0 { + return encoded + } + marker := []byte(`"agent_identity":`) + start := bytes.Index(encoded, marker) + if start < 0 { + return encoded + } + start += len(marker) + for start < len(encoded) && (encoded[start] == ' ' || encoded[start] == '\n' || encoded[start] == '\t') { + start++ + } + var value json.RawMessage + if json.NewDecoder(bytes.NewReader(encoded[start:])).Decode(&value) != nil { + return encoded + } + result := append([]byte(nil), encoded[:start]...) + result = append(result, d.raw...) + return append(result, encoded[start+len(value):]...) +} + +var ( + // ErrUnavailable means the identity cannot prove ownership of its signing key. + ErrUnavailable = errors.New("agent identity: signing key unavailable") + // ErrDocument means a future or inconsistent document cannot be used for custody. + ErrDocument = errors.New("agent identity: unsupported or inconsistent document") +) + +// NeedsProtection identifies documents that need their first custody write. +func (d Document) NeedsProtection() bool { return d.V == "" || (d.V == Version && len(d.Custody) == 0) } + +// Persist returns an encrypted document without mutating a running agent's copy. +func (d Document) Persist(cellKey []byte) (Document, error) { + if !d.NeedsProtection() { + return d, nil + } + keys := d.pending + if d.V == "" { + var err error + d, keys, err = Upgrade(d) + if err != nil { + return Document{}, err + } + } + if err := d.Protect(keys, cellKey); err != nil { + return Document{}, err + } + d.pending = nil + return d, nil +} diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 10c273e71d..78bfde002a 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3505,4 +3505,17 @@ record which agent acted. A task's commands see the conversation's DID followed by `#w` and the task's number, for example `did:key:z7QE…#w3`. The desktop app reads the same DID in the agent list as `did`. -The DID is a name, not a credential: no key is kept and nothing is signed with it. +New conversations retain an Ed25519 signing key and a separate X25519 encryption +key in encrypted custody inside their cell. The versioned identity document +travels with the chat; paired computers with the same cell key can open it. +Commands and the agent list receive only the public DID, never private keys. + +The engine can sign statements for a versioned purpose and open material sealed +to the companion encryption key. A signature can be checked with the DID alone; +it does not verify for a different purpose. These are engine APIs, not shell +commands or a new tool on the agent's belt. + +Older public-only identities upgrade on the next metadata write, never just by +opening the conversation. Their DID stays the same. Their discarded signing key +cannot be recovered, so signing remains unavailable; the upgrade adds encryption. +An identity document from a newer build is preserved without rewriting it. diff --git a/internal/session/agent_identity.go b/internal/session/agent_identity.go index bab9b98daa..fa7d4e6c5e 100644 --- a/internal/session/agent_identity.go +++ b/internal/session/agent_identity.go @@ -21,14 +21,13 @@ import ( "github.com/Agent-Field/codeaf/internal/agentdid" "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" ) -// AgentIdentity is one conversation's agent DID and the conversation id it -// was minted for. -type AgentIdentity struct { - DID string `json:"did"` - Session string `json:"session"` -} +// AgentIdentity is a conversation's versioned public document and encrypted +// key custody, bound to the conversation id it was minted for. +type AgentIdentity = agentdid.Document // AgentDID is the conversation's agent DID, or "" when it has none yet or the // one it holds was minted for another id. @@ -43,7 +42,7 @@ func (m Meta) AgentDID() string { // this id, else the one the save brings when it is bound to the save's id. // Nothing replaces an identity once written. func keptAgentIdentity(current, next Meta) *AgentIdentity { - if current.AgentDID() != "" && current.ID == next.ID { + if current.AgentIdentity != nil && current.ID == next.ID && (current.AgentIdentity.Unknown() || current.AgentDID() != "") { return current.AgentIdentity } if next.AgentDID() != "" { @@ -61,7 +60,10 @@ func keptAgentIdentity(current, next Meta) *AgentIdentity { // be minted or written leaves the variable unset, and the conversation opens. func configureAgentIdentity(config *Config) { if !config.InTask && config.Place.Dir != "" && config.agentDID == "" { - config.agentDID = conversationAgentDID(config.Place) + config.agentDocument = conversationAgentIdentity(config.Place) + if config.agentDocument != nil { + config.agentDID = (Meta{ID: config.Place.ID(), AgentIdentity: config.agentDocument}).AgentDID() + } config.actorDID = config.agentDID } if config.InTask && config.taskID != 0 && config.agentDID != "" { @@ -73,7 +75,7 @@ func configureAgentIdentity(config *Config) { config.Seat = executor.WithEnvironment(config.seat(), []string{agentdid.AgentDIDEnv + "=" + config.actorDID}) } -// conversationAgentDID answers the DID kept in place, or mints a fresh one. +// conversationAgentIdentity reads the kept document, or mints a fresh one. // // OPENING WRITES NOTHING. A conversation opens without touching its files (an // older folder must resume exactly as it was), so a minted DID is carried on @@ -81,14 +83,17 @@ func configureAgentIdentity(config *Config) { // ([Agent.fillMetaLocked]). That write comes at the latest when the first turn // ends, and a turn is the only thing that runs commands, so every DID a // command has seen is on disk once its turn is over. -func conversationAgentDID(place Place) string { +func conversationAgentIdentity(place Place) *AgentIdentity { if meta, err := LoadMeta(place.Dir); err == nil { - if did := meta.AgentDID(); did != "" { - return did + if meta.ID == place.ID() && meta.AgentIdentity != nil && (meta.AgentIdentity.Unknown() || meta.AgentDID() != "") { + return meta.AgentIdentity } } - did, _ := agentdid.New() - return did + doc, _, err := agentdid.Generate(place.ID()) + if err != nil { + return nil + } + return &doc } // agentIdentity is the identity a conversation writes into its own folder, and @@ -97,7 +102,7 @@ func (c Config) agentIdentity() *AgentIdentity { if c.InTask || c.agentDID == "" || c.Place.Dir == "" { return nil } - return &AgentIdentity{DID: c.agentDID, Session: c.Place.ID()} + return c.agentDocument } // AgentDID is who this agent's commands act as: the conversation's DID, or a @@ -107,3 +112,61 @@ func (a *Agent) AgentDID() string { defer a.mu.Unlock() return a.config.actorDID } + +// protectAgentIdentity runs only on a meta write. Reading an older folder +// neither creates a home identity nor upgrades its public-only record. +func protectAgentIdentity(doc *AgentIdentity) (*AgentIdentity, error) { + if doc == nil || !doc.NeedsProtection() { + return doc, nil + } + // Like the vault, custody creates a quiet local profile on first use. + // Existing paired or solo profiles retain their sync posture. + root, err := identity.EnsureSolo(home.Dir()) + if err != nil { + return nil, err + } + next, err := doc.Persist(root.CellKey()) + return &next, err +} + +// SignStatement proves this conversation's DID for one versioned purpose. +// Private material stays inside custody and is never handed to command tools. +func (a *Agent) SignStatement(purpose string, payload []byte) ([]byte, error) { + doc, keys, err := a.identityCredentials() + if err != nil { + return nil, err + } + return keys.Sign(doc.DID, purpose, payload) +} + +// OpenSealed opens material addressed to this conversation's companion key. +func (a *Agent) OpenSealed(purpose string, box []byte) ([]byte, error) { + doc, keys, err := a.identityCredentials() + if err != nil { + return nil, err + } + return keys.Open(*doc, purpose, box) +} + +func (a *Agent) identityCredentials() (*AgentIdentity, *agentdid.Credentials, error) { + a.mu.Lock() + defer a.mu.Unlock() + doc := a.config.agentDocument + if doc == nil || doc.Session != a.config.Place.ID() { + return nil, nil, agentdid.ErrUnavailable + } + // Load the durable upgrade if an earlier meta write supplied legacy custody. + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.AgentDID() == doc.DID && meta.AgentIdentity.V == agentdid.Version { + doc = meta.AgentIdentity + } + root, err := identity.Load(home.Dir()) + if err != nil { + keys, pendingErr := doc.Unlock(nil) + if pendingErr != nil { + return nil, nil, err + } + return doc, keys, nil + } + keys, err := doc.Unlock(root.CellKey()) + return doc, keys, err +} diff --git a/internal/session/agent_identity_custody_test.go b/internal/session/agent_identity_custody_test.go new file mode 100644 index 0000000000..0e035a5b10 --- /dev/null +++ b/internal/session/agent_identity_custody_test.go @@ -0,0 +1,260 @@ +package session + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" +) + +func identityTruthFixture(t *testing.T, id, document string) (Place, string, []byte) { + t.Helper() + place := identityPlace(t, id) + state := filepath.Join(place.Dir, cellStateDir) + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(state, placeMetaTruth) + raw := []byte(`{"V":1,"agent_identity":` + document + `}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return place, path, raw +} + +func TestAgentIdentityCustodySurvivesRestartAndMovesWithCell(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + place := identityPlace(t, "custody-chat") + if err := os.MkdirAll(filepath.Join(place.Dir, cellStateDir), 0o700); err != nil { + t.Fatal(err) + } + agent := openIdentityAgent(t, place, nil) + did := agent.AgentDID() + agent.stampMeta() + meta, err := LoadMeta(place.Dir) + if err != nil || meta.AgentIdentity == nil { + t.Fatalf("load identity: %v", err) + } + doc := *meta.AgentIdentity + if doc.V != agentdid.Version || doc.Signing == nil || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("missing versioned keys or encrypted custody: %+v", doc) + } + message := []byte("a receipt from this conversation") + sig, err := agent.SignStatement("receipt/v1", message) + if err != nil || !agentdid.Verify(did, "receipt/v1", message, sig) || agentdid.Verify(did, "other/v1", message, sig) { + t.Fatalf("sign/verify: %v", err) + } + box, err := agentdid.Seal(doc, "mail/v1", message) + if err != nil { + t.Fatal(err) + } + _ = agent.Close() + + // A relocated chat on a paired computer has the same profile cell key. + root, err := identity.Load(profile) + if err != nil { + t.Fatal(err) + } + pairedHome := t.TempDir() + if err := identity.Save(pairedHome, root); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, pairedHome) + moved := Place{Dir: filepath.Join(t.TempDir(), place.ID())} + if err := os.Rename(place.Dir, moved.Dir); err != nil { + t.Fatal(err) + } + again := openIdentityAgent(t, moved, nil) + if again.AgentDID() != did { + t.Fatal("moving or restarting replaced the DID") + } + plain, err := again.OpenSealed("mail/v1", box) + if err != nil || !bytes.Equal(plain, message) { + t.Fatalf("moved cell could not open its message: %v", err) + } + sig, err = again.SignStatement("receipt/v1", message) + if err != nil || !agentdid.Verify(did, "receipt/v1", message, sig) { + t.Fatalf("moved cell lost its signing key: %v", err) + } + other := openIdentityAgent(t, identityPlace(t, "other-chat"), nil) + if _, err := other.OpenSealed("mail/v1", box); err == nil { + t.Fatal("another conversation opened the sealed message") + } + // A copy with a new chat id cannot inherit signing or decryption custody. + meta.ID = "branched-chat" + branch := identityPlace(t, meta.ID) + if err := SaveMeta(branch.Dir, meta); err != nil { + t.Fatal(err) + } + branched := openIdentityAgent(t, branch, nil) + if branched.AgentDID() == did { + t.Fatal("branch kept its parent's DID") + } + if _, err := branched.OpenSealed("mail/v1", box); err == nil { + t.Fatal("branch opened its parent's message") + } + branched.stampMeta() + // Copying the files before rewriting meta.json also mints a fresh DID. + copyPlace := Place{Dir: filepath.Join(t.TempDir(), "copied-chat")} + if err := os.CopyFS(copyPlace.Dir, os.DirFS(moved.Dir)); err != nil { + t.Fatal(err) + } + copied := openIdentityAgent(t, copyPlace, nil) + if copied.AgentDID() == did || !agentdid.Valid(copied.AgentDID()) { + t.Fatal("a folder copy did not mint its own identity") + } + // Losing access to the wrapping key must fail closed, never mint a replacement. + t.Setenv(home.EnvVar, t.TempDir()) + if _, err := identity.EnsureSolo(home.Dir()); err != nil { + t.Fatal(err) + } + if _, err := again.SignStatement("receipt/v1", message); err == nil { + t.Fatal("unrelated profile unlocked custody") + } + if again.AgentDID() != did { + t.Fatal("failed unlock replaced the DID") + } +} + +func TestAgentIdentityLegacyUpgradeOnlyOnWrite(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + did, _ := agentdid.New() + document, _ := json.Marshal(AgentIdentity{DID: did, Session: "legacy-chat"}) + place, path, before := identityTruthFixture(t, "legacy-chat", string(document)) + agent := openIdentityAgent(t, place, nil) + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("opening rewrote a public-only identity") + } + if _, err := identity.Load(profile); !errors.Is(err, identity.ErrNone) { + t.Fatalf("opening created profile custody: %v", err) + } + agent.stampMeta() + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + doc := meta.AgentIdentity + if meta.AgentDID() != did || doc.V != agentdid.Version || !doc.SigningUnavailable || doc.Encryption == nil || len(doc.Custody) == 0 { + t.Fatalf("invalid lazy upgrade: %+v", doc) + } + if _, err := agent.SignStatement("receipt/v1", nil); !errors.Is(err, agentdid.ErrUnavailable) { + t.Fatalf("discarded legacy key must be unavailable: %v", err) + } + box, err := agentdid.Seal(*doc, "mail/v1", []byte("legacy mail")) + if err != nil { + t.Fatal(err) + } + if plain, err := agent.OpenSealed("mail/v1", box); err != nil || string(plain) != "legacy mail" { + t.Fatalf("upgraded legacy decryption: %v", err) + } + agent.stampMeta() + next, _ := LoadMeta(place.Dir) + if next.AgentIdentity.Encryption.Public != doc.Encryption.Public || !bytes.Equal(next.AgentIdentity.Custody, doc.Custody) { + t.Fatal("a later save replaced upgraded keys") + } +} + +func TestAgentIdentityFutureDocumentPreservedVerbatimOnMetaWrites(t *testing.T) { + for _, fields := range []string{`"did":"future", "session":"future-chat",`, `"future_binding":["opaque"],`} { + t.Run(fields, func(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + document := `{"v":"v99", ` + fields + ` "statements": [ {"what": "future", "n": 1.00} ]}` + place, path, before := identityTruthFixture(t, "future-chat", document) + agent := openIdentityAgent(t, place, nil) + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("opening changed a future document") + } + agent.stampMeta() + for range 2 { + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + meta.Title = "an unrelated change" + if err := SaveMeta(place.Dir, meta); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if !bytes.Contains(after, []byte(document)) { + t.Fatalf("future document rewritten: %s", after) + } + } + }) + } +} + +func TestAgentIdentityRestoringSavedDialsDoesNotMigrateOnOpen(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + did, _ := agentdid.New() + document, _ := json.Marshal(AgentIdentity{DID: did, Session: "saved-dials"}) + place, path, _ := identityTruthFixture(t, "saved-dials", string(document)) + raw := []byte(`{"V":1,"approval":"allow","effort":"high","agent_identity":` + string(document) + `}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Place = place + config.ApprovalGate = &fakeApprovalGate{standing: PostureAsk} + }) + if err := agent.SetApprovalPosture(PostureAllow); err != nil { + t.Fatal(err) + } + if !agent.SetConversationEffort("high") { + t.Fatal("saved effort was refused") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(raw, after) { + t.Fatal("restoring preferences migrated the identity on open") + } + if agent.ApprovalPosture() != PostureAllow || agent.ConversationEffort() != "high" { + t.Fatal("restored preferences were not applied in memory") + } + if !agent.SetConversationEffort("low") { + t.Fatal("new effort was refused") + } + meta, _ := LoadMeta(place.Dir) + if meta.AgentIdentity.V != agentdid.Version || meta.Effort != "low" { + t.Fatal("an actual preference change did not persist and migrate") + } +} + +func TestAgentIdentityCustodyPreservesExistingProfilesSyncPosture(t *testing.T) { + for _, solo := range []bool{false, true} { + t.Run(map[bool]string{false: "unmarked", true: "solo"}[solo], func(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + ensure := identity.Ensure + if solo { + ensure = identity.EnsureSolo + } + if _, err := ensure(profile); err != nil { + t.Fatal(err) + } + agent := openIdentityAgent(t, identityPlace(t, "sync-posture"), nil) + agent.stampMeta() + if identity.Solo(profile) != solo { + t.Fatal("custody changed whether this profile talks to sync") + } + }) + } +} + +func TestAgentIdentityCustodyMakesAFreshProfileQuiet(t *testing.T) { + profile := t.TempDir() + t.Setenv(home.EnvVar, profile) + agent := openIdentityAgent(t, identityPlace(t, "fresh-profile"), nil) + agent.stampMeta() + if !identity.Solo(profile) { + t.Fatal("custody made a fresh profile eligible for sync") + } +} diff --git a/internal/session/approvalposture.go b/internal/session/approvalposture.go index 510949de50..7cfa0f6794 100644 --- a/internal/session/approvalposture.go +++ b/internal/session/approvalposture.go @@ -181,6 +181,14 @@ func (a *Agent) SetApprovalPosture(posture string) error { a.approvalPosture = word a.guardianOverride = &guardian a.mu.Unlock() + // The launcher restores the saved dial through this door. Applying the + // gate in memory must not turn an unchanged preference into a meta write + // (and therefore an identity migration) just because a chat was opened. + if a.config.Place.Dir != "" { + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.Approval == word { + return nil + } + } // Written through the same door every other fact about this conversation is // written through, so the file keeps one shape and one writer. a.stampApproval() diff --git a/internal/session/effort.go b/internal/session/effort.go index d39c3f2abe..3d47fef5ac 100644 --- a/internal/session/effort.go +++ b/internal/session/effort.go @@ -86,6 +86,12 @@ func (a *Agent) SetConversationEffort(rung string) bool { a.mu.Lock() a.effort = parsed a.mu.Unlock() + // Reapplying a stored dial on resume changes memory, not the chat's files. + if a.config.Place.Dir != "" { + if meta, err := LoadMeta(a.config.Place.Dir); err == nil && meta.Effort == parsed.String() { + return true + } + } // Written through the same door every other fact about this conversation is // written through, so the file keeps one shape and one writer. a.stampEffort() diff --git a/internal/session/place.go b/internal/session/place.go index 08d70803ad..6e39225017 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -513,6 +513,10 @@ func saveMetaLocked(dir string, meta Meta) error { meta.ChatURI = current.ChatURI } meta.AgentIdentity = keptAgentIdentity(current, meta) + meta.AgentIdentity, err = protectAgentIdentity(meta.AgentIdentity) + if err != nil { + return fmt.Errorf("save agent identity: %w", err) + } // K3: schema paths use slashes even when their source machine is Windows. for _, field := range []*string{&meta.Workspace, &meta.Origin, &meta.FromWorkspace} { if windowsSource(*field) { @@ -540,6 +544,16 @@ func writeJSONAtomic(path string, v any) error { if err != nil { return err } + switch value := v.(type) { + case Meta: + if value.AgentIdentity != nil { + raw = value.AgentIdentity.PreserveJSON(raw) + } + case SessionTruth: + if value.AgentIdentity != nil { + raw = value.AgentIdentity.PreserveJSON(raw) + } + } tmp, err := os.CreateTemp(filepath.Dir(path), ".meta-*.json") if err != nil { return err diff --git a/internal/session/session.go b/internal/session/session.go index f2149faac0..089b5b170e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2611,8 +2611,9 @@ type Config struct { // commands act as: the same DID in a conversation, `#w` in a // task worker (agent_identity.go). A child agent inherits both from the // agent that built it ([Agent.newChildAgent]); no surface sets them. - agentDID string - actorDID string + agentDID string + agentDocument *AgentIdentity + actorDID string // automationID is the id of the automation whose run this agent IS // (automation_run.go), and empty in every conversation and every ordinary