gabriel / muse public
test_agent_signing.py python
521 lines 18.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for agent-first signing — compound identity keys, MUSE_AGENT_KEY injection,
2 and end-to-end agent commit signing.
3
4 Covers:
5 - identity.py: compound key load/save/clear, provisioned_by field
6 - keypair.py: agent-specific key paths and generation
7 - config.py: MUSE_AGENT_KEY env var, agent_id resolution order
8 - commit.py: signing uses agent key when --agent-id is set
9 - resolve_signing_identity: agent key → human key fallback
10 """
11 from __future__ import annotations
12
13 import pathlib
14 import json
15
16 import pytest
17
18 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
19 from cryptography.hazmat.primitives.serialization import (
20 Encoding,
21 NoEncryption,
22 PrivateFormat,
23 )
24
25 from muse.core.identity import (
26 IdentityEntry,
27 _identity_key,
28 clear_identity,
29 hostname_from_url,
30 list_all_identities,
31 load_identity,
32 resolve_signing_identity,
33 save_identity,
34 )
35 from muse.core.keypair import (
36 generate_keypair,
37 key_path_for,
38 load_private_key,
39 load_private_key_from_pem,
40 )
41
42
43 # ---------------------------------------------------------------------------
44 # Fixtures
45 # ---------------------------------------------------------------------------
46
47
48 @pytest.fixture()
49 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
50 """Redirect the identity store to a temp directory for test isolation."""
51 import muse.core.identity as _id_mod
52 identity_dir = tmp_path / ".muse"
53 identity_dir.mkdir()
54 monkeypatch.setattr(_id_mod, "_IDENTITY_DIR", identity_dir)
55 monkeypatch.setattr(_id_mod, "_IDENTITY_FILE", identity_dir / "identity.toml")
56 return identity_dir
57
58
59 @pytest.fixture()
60 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
61 """Redirect key storage to a temp directory."""
62 import muse.core.keypair as _kp_mod
63 keys_dir = tmp_path / ".muse" / "keys"
64 keys_dir.mkdir(parents=True)
65 monkeypatch.setattr(_kp_mod, "_KEYS_DIR", keys_dir)
66 return keys_dir
67
68
69 # ---------------------------------------------------------------------------
70 # _identity_key helper
71 # ---------------------------------------------------------------------------
72
73
74 class TestIdentityKey:
75 def test_human_key_is_bare_hostname(self) -> None:
76 assert _identity_key("localhost:10003") == "localhost:10003"
77
78 def test_agent_key_uses_hash_separator(self) -> None:
79 assert _identity_key("localhost:10003", "agent-abc") == "localhost:10003#agent-abc"
80
81 def test_none_agent_id_gives_bare_hostname(self) -> None:
82 assert _identity_key("musehub.ai", None) == "musehub.ai"
83
84 def test_empty_agent_id_gives_bare_hostname(self) -> None:
85 # empty string is falsy
86 assert _identity_key("musehub.ai", "") == "musehub.ai"
87
88
89 # ---------------------------------------------------------------------------
90 # load_identity / save_identity compound keys
91 # ---------------------------------------------------------------------------
92
93
94 class TestCompoundIdentityKeys:
95 def test_human_and_agent_entries_coexist(
96 self, isolated_identity: pathlib.Path
97 ) -> None:
98 hub = "http://localhost:10003"
99
100 human: IdentityEntry = {
101 "type": "human",
102 "handle": "gabriel",
103 "key_path": "/fake/human.pem",
104 "algorithm": "ed25519",
105 "fingerprint": "a" * 64,
106 }
107 agent: IdentityEntry = {
108 "type": "agent",
109 "handle": "agentception-abc",
110 "key_path": "/fake/agent.pem",
111 "algorithm": "ed25519",
112 "fingerprint": "b" * 64,
113 "provisioned_by": "gabriel",
114 }
115
116 save_identity(hub, human)
117 save_identity(hub, agent, agent_id="agentception-abc")
118
119 loaded_human = load_identity(hub)
120 loaded_agent = load_identity(hub, agent_id="agentception-abc")
121
122 assert loaded_human is not None
123 assert loaded_human["handle"] == "gabriel"
124 assert loaded_human["type"] == "human"
125
126 assert loaded_agent is not None
127 assert loaded_agent["handle"] == "agentception-abc"
128 assert loaded_agent["type"] == "agent"
129 assert loaded_agent.get("provisioned_by") == "gabriel"
130
131 def test_agent_entry_does_not_shadow_human(
132 self, isolated_identity: pathlib.Path
133 ) -> None:
134 hub = "http://localhost:10003"
135 human: IdentityEntry = {"type": "human", "handle": "gabriel"}
136 save_identity(hub, human)
137
138 # Load without agent_id → human entry
139 loaded = load_identity(hub)
140 assert loaded is not None
141 assert loaded["handle"] == "gabriel"
142
143 def test_load_missing_agent_returns_none(
144 self, isolated_identity: pathlib.Path
145 ) -> None:
146 hub = "http://localhost:10003"
147 assert load_identity(hub, agent_id="nonexistent") is None
148
149 def test_clear_agent_identity_leaves_human_intact(
150 self, isolated_identity: pathlib.Path
151 ) -> None:
152 hub = "http://localhost:10003"
153 human: IdentityEntry = {"type": "human", "handle": "gabriel"}
154 agent: IdentityEntry = {"type": "agent", "handle": "agentception-abc", "provisioned_by": "gabriel"}
155
156 save_identity(hub, human)
157 save_identity(hub, agent, agent_id="agentception-abc")
158
159 cleared = clear_identity(hub, agent_id="agentception-abc")
160 assert cleared is True
161
162 assert load_identity(hub) is not None # human still present
163 assert load_identity(hub, agent_id="agentception-abc") is None # agent gone
164
165 def test_provisioned_by_roundtrip(
166 self, isolated_identity: pathlib.Path
167 ) -> None:
168 """provisioned_by survives a save/load cycle."""
169 hub = "http://localhost:10003"
170 agent: IdentityEntry = {
171 "type": "agent",
172 "handle": "bot-001",
173 "provisioned_by": "gabriel",
174 "algorithm": "ed25519",
175 "fingerprint": "c" * 64,
176 }
177 save_identity(hub, agent, agent_id="bot-001")
178 loaded = load_identity(hub, agent_id="bot-001")
179 assert loaded is not None
180 assert loaded.get("provisioned_by") == "gabriel"
181
182 def test_list_all_includes_compound_keys(
183 self, isolated_identity: pathlib.Path
184 ) -> None:
185 hub = "http://localhost:10003"
186 save_identity(hub, {"type": "human", "handle": "gabriel"})
187 save_identity(hub, {"type": "agent", "handle": "bot"}, agent_id="bot")
188
189 all_ids = list_all_identities()
190 assert "localhost:10003" in all_ids
191 assert "localhost:10003#bot" in all_ids
192
193
194 # ---------------------------------------------------------------------------
195 # keypair agent-specific paths
196 # ---------------------------------------------------------------------------
197
198
199 class TestAgentKeyPaths:
200 def test_human_key_path(self) -> None:
201 p = key_path_for("localhost:10003")
202 assert "__" not in p.name
203 assert p.name == "localhost_10003.pem"
204
205 def test_agent_key_path_uses_double_underscore(self) -> None:
206 p = key_path_for("localhost:10003", "agentception-abc")
207 assert p.name == "localhost_10003__agentception-abc.pem"
208
209 def test_generate_agent_keypair_creates_distinct_file(
210 self, isolated_keys: pathlib.Path
211 ) -> None:
212 pub_human, fp_human = generate_keypair("localhost:10003")
213 pub_agent, fp_agent = generate_keypair("localhost:10003", "agentception-abc")
214
215 assert fp_human != fp_agent # different keys
216 assert (isolated_keys / "localhost_10003.pem").is_file()
217 assert (isolated_keys / "localhost_10003__agentception-abc.pem").is_file()
218
219 def test_load_private_key_with_agent_id(
220 self, isolated_keys: pathlib.Path
221 ) -> None:
222 generate_keypair("localhost:10003", "bot-42")
223 key = load_private_key("localhost:10003", "bot-42")
224 assert key is not None
225 assert isinstance(key, Ed25519PrivateKey)
226
227 def test_load_private_key_agent_id_not_found_returns_none(
228 self, isolated_keys: pathlib.Path
229 ) -> None:
230 # Human key exists but no agent key
231 generate_keypair("localhost:10003")
232 key = load_private_key("localhost:10003", "nonexistent-agent")
233 assert key is None
234
235
236 # ---------------------------------------------------------------------------
237 # resolve_signing_identity — agent key → fallback chain
238 # ---------------------------------------------------------------------------
239
240
241 class TestResolveSigningIdentity:
242 def test_agent_key_used_when_registered(
243 self,
244 isolated_identity: pathlib.Path,
245 isolated_keys: pathlib.Path,
246 ) -> None:
247 hub = "http://localhost:10003"
248 hostname = hostname_from_url(hub)
249 agent_id = "agentception-abc"
250
251 # Generate two distinct keys
252 generate_keypair(hostname)
253 generate_keypair(hostname, agent_id)
254
255 agent_key_path = str(key_path_for(hostname, agent_id))
256 save_identity(
257 hub,
258 {"type": "agent", "handle": agent_id, "key_path": agent_key_path, "algorithm": "ed25519"},
259 agent_id=agent_id,
260 )
261
262 result = resolve_signing_identity(hub, agent_id=agent_id)
263 assert result is not None
264 handle, private_key = result
265 assert handle == agent_id
266 assert isinstance(private_key, Ed25519PrivateKey)
267
268 def test_falls_back_to_human_when_no_agent_key(
269 self,
270 isolated_identity: pathlib.Path,
271 isolated_keys: pathlib.Path,
272 ) -> None:
273 hub = "http://localhost:10003"
274 hostname = hostname_from_url(hub)
275
276 # Only human key registered
277 generate_keypair(hostname)
278 human_key_path = str(key_path_for(hostname))
279 save_identity(
280 hub,
281 {"type": "human", "handle": "gabriel", "key_path": human_key_path, "algorithm": "ed25519"},
282 )
283
284 # Ask for agent signing but no agent entry → falls back to human
285 result = resolve_signing_identity(hub, agent_id="unregistered-agent")
286 assert result is not None
287 handle, _ = result
288 assert handle == "gabriel"
289
290 def test_no_identity_returns_none(
291 self, isolated_identity: pathlib.Path
292 ) -> None:
293 assert resolve_signing_identity("http://localhost:10003") is None
294
295 def test_human_resolve_without_agent_id(
296 self,
297 isolated_identity: pathlib.Path,
298 isolated_keys: pathlib.Path,
299 ) -> None:
300 hub = "http://localhost:10003"
301 hostname = hostname_from_url(hub)
302 generate_keypair(hostname)
303 save_identity(
304 hub,
305 {
306 "type": "human",
307 "handle": "gabriel",
308 "key_path": str(key_path_for(hostname)),
309 "algorithm": "ed25519",
310 },
311 )
312 result = resolve_signing_identity(hub)
313 assert result is not None
314 handle, _ = result
315 assert handle == "gabriel"
316
317
318 # ---------------------------------------------------------------------------
319 # load_private_key_from_pem (MUSE_AGENT_KEY env var parsing)
320 # ---------------------------------------------------------------------------
321
322
323 class TestLoadPrivateKeyFromPem:
324 def _make_pem(self) -> bytes:
325 key = Ed25519PrivateKey.generate()
326 return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
327
328 def test_valid_pem_returns_key(self) -> None:
329 pem = self._make_pem()
330 key = load_private_key_from_pem(pem)
331 assert isinstance(key, Ed25519PrivateKey)
332
333 def test_invalid_pem_returns_none(self) -> None:
334 assert load_private_key_from_pem(b"not a pem") is None
335
336 def test_empty_bytes_returns_none(self) -> None:
337 assert load_private_key_from_pem(b"") is None
338
339
340 # ---------------------------------------------------------------------------
341 # get_signing_identity — MUSE_AGENT_KEY env var
342 # ---------------------------------------------------------------------------
343
344
345 class TestGetSigningIdentityEnvVar:
346 def _make_pem(self) -> str:
347 key = Ed25519PrivateKey.generate()
348 return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
349
350 def test_muse_agent_key_takes_priority(
351 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
352 ) -> None:
353 from muse.cli.config import get_signing_identity
354
355 pem_str = self._make_pem()
356 monkeypatch.setenv("MUSE_AGENT_KEY", pem_str)
357 monkeypatch.setenv("MUSE_AGENT_HANDLE", "agentception-xyz")
358
359 # Even with no hub configured, should succeed via env var
360 result = get_signing_identity(repo_root=tmp_path, agent_id="agentception-xyz")
361 assert result is not None
362 assert result.handle == "agentception-xyz"
363 assert isinstance(result.private_key, Ed25519PrivateKey)
364
365 def test_muse_agent_key_uses_agent_id_as_fallback_handle(
366 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
367 ) -> None:
368 from muse.cli.config import get_signing_identity
369
370 pem_str = self._make_pem()
371 monkeypatch.setenv("MUSE_AGENT_KEY", pem_str)
372 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
373
374 result = get_signing_identity(repo_root=tmp_path, agent_id="my-agent")
375 assert result is not None
376 assert result.handle == "my-agent"
377
378 def test_muse_agent_key_default_handle_is_agent(
379 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
380 ) -> None:
381 from muse.cli.config import get_signing_identity
382
383 pem_str = self._make_pem()
384 monkeypatch.setenv("MUSE_AGENT_KEY", pem_str)
385 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
386
387 result = get_signing_identity(repo_root=tmp_path)
388 assert result is not None
389 assert result.handle == "agent" # default when no handle provided
390
391 def test_invalid_muse_agent_key_falls_through(
392 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
393 ) -> None:
394 from muse.cli.config import get_signing_identity
395
396 monkeypatch.setenv("MUSE_AGENT_KEY", "not-valid-pem")
397
398 # Falls through to identity store; no hub configured → None
399 result = get_signing_identity(repo_root=tmp_path)
400 assert result is None
401
402 def test_no_env_var_and_no_identity_returns_none(
403 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
404 ) -> None:
405 from muse.cli.config import get_signing_identity
406
407 monkeypatch.delenv("MUSE_AGENT_KEY", raising=False)
408
409 result = get_signing_identity(repo_root=tmp_path)
410 assert result is None
411
412
413 # ---------------------------------------------------------------------------
414 # End-to-end: commit signing uses agent key when --agent-id is set
415 # ---------------------------------------------------------------------------
416
417
418 class TestCommitSigningWithAgentKey:
419 """Verify that muse commit --sign --agent-id uses the agent's dedicated key."""
420
421 def _write_commit_env(
422 self,
423 tmp_path: pathlib.Path,
424 monkeypatch: pytest.MonkeyPatch,
425 *,
426 agent_pem: str,
427 agent_handle: str,
428 ) -> "tuple[str, str, str]":
429 """Run commit signing logic via MUSE_AGENT_KEY env and return sig/pubkey/key_id."""
430 from muse.core.provenance import sign_commit_record
431
432 monkeypatch.setenv("MUSE_AGENT_KEY", agent_pem)
433 monkeypatch.setenv("MUSE_AGENT_HANDLE", agent_handle)
434
435 from muse.cli.config import get_signing_identity
436 signing = get_signing_identity(repo_root=tmp_path, agent_id=agent_handle)
437 assert signing is not None
438
439 result = sign_commit_record(
440 "commit-abc123",
441 agent_handle,
442 signing.private_key,
443 author="gabriel",
444 model_id="claude-sonnet-4-6",
445 toolchain_id="agentception/v1",
446 prompt_hash="",
447 )
448 assert result is not None
449 return result
450
451 def test_signing_produces_valid_ed25519_signature(
452 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
453 ) -> None:
454 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
455 from cryptography.hazmat.primitives.serialization import (
456 Encoding, PrivateFormat, NoEncryption,
457 )
458
459 key = Ed25519PrivateKey.generate()
460 pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
461
462 sig, pub_b64, fp = self._write_commit_env(
463 tmp_path, monkeypatch, agent_pem=pem, agent_handle="agentception-xyz"
464 )
465 assert len(sig) == 86 # Ed25519 = 64 bytes → 86 base64url chars
466 assert len(pub_b64) == 43 # 32 bytes → 43 base64url chars
467 assert len(fp) > 0
468
469 def test_signing_key_fingerprint_matches_public_key(
470 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
471 ) -> None:
472 import base64
473 import hashlib
474 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
475 from cryptography.hazmat.primitives.serialization import (
476 Encoding, PrivateFormat, NoEncryption, PublicFormat,
477 )
478
479 key = Ed25519PrivateKey.generate()
480 pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
481 pub_raw = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
482 expected_fp = hashlib.sha256(pub_raw).hexdigest()[:16]
483
484 _, _, fp = self._write_commit_env(
485 tmp_path, monkeypatch, agent_pem=pem, agent_handle="agent-001"
486 )
487 # signer_key_id is the first 16 chars of the SHA-256 fingerprint
488 assert fp == expected_fp
489
490 def test_agent_signature_verifies_with_embedded_public_key(
491 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
492 ) -> None:
493 import base64
494 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
495 from cryptography.hazmat.primitives.serialization import (
496 Encoding, PrivateFormat, NoEncryption,
497 )
498 from muse.core.provenance import provenance_payload, verify_commit_ed25519
499
500 key = Ed25519PrivateKey.generate()
501 pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
502 handle = "agentception-verify-test"
503
504 sig, pub_b64, _ = self._write_commit_env(
505 tmp_path, monkeypatch, agent_pem=pem, agent_handle=handle
506 )
507
508 # Reconstruct the signed payload the same way sign_commit_record does
509 payload = provenance_payload(
510 "commit-abc123",
511 author="gabriel",
512 agent_id=handle,
513 model_id="claude-sonnet-4-6",
514 toolchain_id="agentception/v1",
515 prompt_hash="",
516 )
517
518 # Decode the embedded public key and verify
519 padded = pub_b64 + "=" * (4 - len(pub_b64) % 4)
520 pub_bytes = base64.urlsafe_b64decode(padded)
521 assert verify_commit_ed25519(payload, sig, pub_bytes)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago