test_recursive_validation_artifacts.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import json
  2. import unittest
  3. from pydantic import ValidationError
  4. from cyber_agent.core.artifacts import (
  5. ArtifactAccessDenied,
  6. ArtifactNotFound,
  7. ArtifactRef,
  8. ArtifactTemporarilyUnavailable,
  9. ValidationMaterial,
  10. extract_artifact_refs,
  11. material_content_hash,
  12. resolve_artifact_refs,
  13. )
  14. from cyber_agent.tools.models import ToolResult
  15. from cyber_agent.tools.registry import ToolRegistry
  16. CONTENT = {"script": "official value is 12%", "revision": 2}
  17. CONTENT_HASH = material_content_hash(CONTENT)
  18. REF = ArtifactRef(
  19. artifact_id="script:42",
  20. version="v2",
  21. content_hash=CONTENT_HASH,
  22. kind="script",
  23. mime_type="application/json",
  24. )
  25. def material(**overrides):
  26. values = {
  27. **REF.model_dump(mode="json"),
  28. "root_trace_id": "root-1",
  29. "uid": "user-1",
  30. "content": CONTENT,
  31. }
  32. values.update(overrides)
  33. return ValidationMaterial.model_validate(values)
  34. class FakeResolver:
  35. def __init__(self, value):
  36. self.value = value
  37. self.calls = []
  38. async def resolve(self, ref, root_trace_id, uid):
  39. self.calls.append((ref, root_trace_id, uid))
  40. if isinstance(self.value, Exception):
  41. raise self.value
  42. return self.value
  43. class ArtifactProtocolTest(unittest.IsolatedAsyncioTestCase):
  44. def test_ref_is_strict_and_hash_is_lowercase_sha256(self):
  45. with self.assertRaises(ValidationError):
  46. ArtifactRef.model_validate({
  47. **REF.model_dump(),
  48. "content_hash": "A" * 64,
  49. })
  50. with self.assertRaises(ValidationError):
  51. ArtifactRef.model_validate({**REF.model_dump(), "extra": True})
  52. def test_hash_is_stable_for_json_key_order(self):
  53. self.assertEqual(
  54. material_content_hash({"b": 2, "a": 1}),
  55. material_content_hash({"a": 1, "b": 2}),
  56. )
  57. def test_extract_only_reads_top_level_standard_metadata(self):
  58. self.assertEqual([], extract_artifact_refs({
  59. "result": {"artifact_refs": [REF.model_dump()]},
  60. }))
  61. refs = extract_artifact_refs({
  62. "artifact_refs": [REF.model_dump(), REF.model_dump()],
  63. "untrusted": {"artifact_refs": []},
  64. })
  65. self.assertEqual([REF], refs)
  66. def test_malformed_artifact_metadata_fails_closed(self):
  67. for payload in (
  68. {"artifact_refs": "not-an-array"},
  69. {"artifact_refs": [{"artifact_id": "missing-fields"}]},
  70. ):
  71. with self.subTest(payload=payload), self.assertRaises(
  72. (ValueError, ValidationError)
  73. ):
  74. extract_artifact_refs(payload)
  75. async def test_valid_resolver_material_is_accepted(self):
  76. resolver = FakeResolver(material())
  77. materials, issues = await resolve_artifact_refs(
  78. [REF],
  79. resolver=resolver,
  80. root_trace_id="root-1",
  81. uid="user-1",
  82. )
  83. self.assertEqual([], issues)
  84. self.assertEqual([material()], materials)
  85. self.assertEqual((REF, "root-1", "user-1"), resolver.calls[0])
  86. async def test_missing_resolver_is_unknown(self):
  87. materials, issues = await resolve_artifact_refs(
  88. [REF],
  89. resolver=None,
  90. root_trace_id="root-1",
  91. uid="user-1",
  92. )
  93. self.assertEqual([], materials)
  94. self.assertEqual("unknown", issues[0].outcome)
  95. async def test_explicit_not_found_is_failed(self):
  96. _, issues = await resolve_artifact_refs(
  97. [REF],
  98. resolver=FakeResolver(ArtifactNotFound("gone")),
  99. root_trace_id="root-1",
  100. uid="user-1",
  101. )
  102. self.assertEqual("failed", issues[0].outcome)
  103. async def test_temporary_failure_is_unknown(self):
  104. _, issues = await resolve_artifact_refs(
  105. [REF],
  106. resolver=FakeResolver(ArtifactTemporarilyUnavailable("retry later")),
  107. root_trace_id="root-1",
  108. uid="user-1",
  109. )
  110. self.assertEqual("unknown", issues[0].outcome)
  111. async def test_access_denied_and_cross_tree_are_errors(self):
  112. for value in (
  113. ArtifactAccessDenied("denied"),
  114. material(root_trace_id="other-root"),
  115. material(uid="other-user"),
  116. ):
  117. with self.subTest(value=value):
  118. _, issues = await resolve_artifact_refs(
  119. [REF],
  120. resolver=FakeResolver(value),
  121. root_trace_id="root-1",
  122. uid="user-1",
  123. )
  124. self.assertEqual("error", issues[0].outcome)
  125. async def test_version_or_metadata_mismatch_is_error(self):
  126. changed = material(version="v3")
  127. _, issues = await resolve_artifact_refs(
  128. [REF],
  129. resolver=FakeResolver(changed),
  130. root_trace_id="root-1",
  131. uid="user-1",
  132. )
  133. self.assertEqual("error", issues[0].outcome)
  134. async def test_content_hash_mismatch_is_failed(self):
  135. forged = ValidationMaterial.model_validate({
  136. **REF.model_dump(mode="json"),
  137. "root_trace_id": "root-1",
  138. "uid": "user-1",
  139. "content": {"script": "forged 30%"},
  140. })
  141. _, issues = await resolve_artifact_refs(
  142. [REF],
  143. resolver=FakeResolver(forged),
  144. root_trace_id="root-1",
  145. uid="user-1",
  146. )
  147. self.assertEqual("failed", issues[0].outcome)
  148. async def test_untyped_resolver_exception_is_unknown(self):
  149. _, issues = await resolve_artifact_refs(
  150. [REF],
  151. resolver=FakeResolver(RuntimeError("database unavailable")),
  152. root_trace_id="root-1",
  153. uid="user-1",
  154. )
  155. self.assertEqual("unknown", issues[0].outcome)
  156. async def test_tool_result_preserves_only_standard_artifact_refs(self):
  157. registry = ToolRegistry()
  158. async def produce_script():
  159. return ToolResult(
  160. title="script",
  161. output="created",
  162. metadata={
  163. "artifact_refs": [REF.model_dump(mode="json")],
  164. "private": "must-not-leak",
  165. },
  166. )
  167. registry.register(produce_script)
  168. result = await registry.execute("produce_script", {})
  169. self.assertEqual([REF.model_dump(mode="json")], result["artifact_refs"])
  170. self.assertNotIn("private", json.dumps(result))
  171. if __name__ == "__main__":
  172. unittest.main()