Skip to content

Commit 0a27d43

Browse files
authored
fix(agt-evidence-anchor): NFC-normalize agent_id/action_type/scope before hashing (#52)
mycelium_evidence_anchor.py's _compute_action_ref builds its own domain-separated hash space (mycelium-evidence-anchor:v1: prefix) for the community AGT EvidenceAnchor plugin, with agent_id/action_type/scope taken from caller-supplied metadata. Unlike the canonical action_ref profile (plugins/agt_evidence_anchor/action_ref.py), this path has no ASCII-only Domain check, so two byte-different but visually identical strings (NFC vs. NFD composition of the same value) hashed to two different action_ref values for the same logical identity. Flagged by Henri Sirkkavaara (SCITT list, 2026-08-16), raised as a general NFC-vs-NFD-before-JCS point in an unrelated ARP/Certisyn thread (Joel Hillier). Verified the canonical profile is unaffected: action_ref.py's _validate_domain already rejects any non-ASCII value in agent_id/action_type/scope with OutOfProfileDomainError before hashing, which makes NFC/NFD divergence structurally impossible there (the two forms only differ for non-ASCII code points). Added two symmetric conformance vectors (nfc-001/nfc-002) confirming both forms of the same identifier are rejected identically on the canonical path, and documented the distinction between the two profiles in docs/spec/action-ref.md. No production adopters of this plugin (not in ADOPTERS.md/PROVIDERS.md, not called from the main production path), no prior anchored data affected -- this is the plugin's second commit since domain separation was added (3710af2). Changes: - mycelium_evidence_anchor.py: unicodedata.normalize("NFC", ...) on the three text fields before building the preimage - tests/test_mycelium_evidence_anchor.py: 3 new tests (NFC/NFD give the same action_ref for agent_id, scope, action_type) - examples/conformance/action-ref-v1-domain-negative/*.fixture.json: nfc-001/nfc-002 vectors (canonical profile rejects both forms identically) - docs/spec/action-ref.md: note explaining ASCII-only Domain closes this class of ambiguity by design for the canonical profile, and why the plugin needed the separate NFC fix Verified: full suite green in a fresh clone of main (133 passed, tests/ + plugins/agt_evidence_anchor/tests/), all 9 domain-negative conformance vectors passed.
1 parent a72c741 commit 0a27d43

4 files changed

Lines changed: 88 additions & 1 deletion

File tree

docs/spec/action-ref.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
**Version:** 1.2 | **Published:** 2026-05-23 | **Updated:** 2026-06-03 (×2), 2026-07-29 (version negotiation, Domain enforcement) | **Stable ref (v1):** [`action-ref-v1.0`](https://github.com/giskard09/argentum-core/blob/action-ref-v1.0/docs/spec/action-ref.md) | **Stable ref (v2, domain separation):** [`action-ref-v2.0`](https://github.com/giskard09/argentum-core/blob/action-ref-v2.0/docs/spec/action-ref.md) | **Latest commit:** [96931c9](https://github.com/giskard09/argentum-core/commit/96931c9)
44

5+
**2026-08-16:** ASCII-only Domain enforcement (2026-07-29, above) closes Unicode
6+
normalization ambiguity (NFC vs. NFD) by design for this canonical profile — NFC and
7+
NFD only diverge on non-ASCII code points, and any non-ASCII value in `agent_id`,
8+
`action_type`, or `scope` is already rejected with `OUT_OF_PROFILE_DOMAIN` before
9+
hashing, so the two forms can never reach the preimage differently. This is an
10+
intentional consequence of the ASCII-only Domain, not a gap pending a fix — see the
11+
Domain paragraph below. Flagged for verification by Henri Sirkkavaara (SCITT list,
12+
2026-08-16, general NFC/NFD-before-JCS point raised in an unrelated ARP/Certisyn
13+
thread). Separately, the community plugin
14+
[`mycelium_evidence_anchor.py`](../../mycelium_evidence_anchor.py) computes its own
15+
domain-separated hash (`mycelium-evidence-anchor:v1:` prefix, distinct hash space, no
16+
production adopters) without this repo's ASCII-only Domain restriction — there,
17+
NFC/NFD divergence was a real gap, closed by normalizing `agent_id`/`action_type`/
18+
`scope` to NFC before hashing (unrelated to and does not change this canonical
19+
`action_ref` profile).
20+
521
**2026-07-30:** Tagged `action-ref-v2.0` — gate condition met, the two production
622
adopters (SafeAgent/azender1, CTEF/kenneives) were briefed on the domain-separation
723
gap by email the same day. v1 (bare 64-hex) is untouched and permanently valid; v2 is

examples/conformance/action-ref-v1-domain-negative/action-ref-v1-domain-negative.fixture.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@
9292
"scope": "",
9393
"timestamp": "2026-05-24T10:30:00.000Z"
9494
}
95+
},
96+
{
97+
"id": "nfc-001",
98+
"description": "Unicode normalization symmetry check, prompted by Henri Sirkkavaara (SCITT list, 2026-08-16) raising NFC-vs-NFD-before-JCS ambiguity in an unrelated ARP/Certisyn thread. agent_id in NFC form: 'agent-café', precomposed U+00E9 (LATIN SMALL LETTER E WITH ACUTE). Non-ASCII, so the Domain paragraph rejects it -- same as its NFD twin below, confirming the ASCII-only profile closes NFC/NFD divergence by rejecting both forms identically rather than accepting one and not the other.",
99+
"expect_valid": false,
100+
"expect_error_field": "agent_id",
101+
"preimage": {
102+
"agent_id": "agent-café",
103+
"action_type": "trail.anchor",
104+
"scope": "mycelium:av-neg",
105+
"timestamp": "2026-05-24T10:30:00.000Z"
106+
}
107+
},
108+
{
109+
"id": "nfc-002",
110+
"description": "Same logical agent_id as nfc-001, NFD form: 'agent-café', base 'e' (U+0065) plus COMBINING ACUTE ACCENT (U+0301). Two code points, byte-different from nfc-001's single precomposed code point, but the same visible identifier. Must be rejected the same way (same field, same error) -- if this vector's outcome ever diverged from nfc-001's, NFC/NFD would silently produce two different treatments for one identity, exactly the gap this profile is designed to close.",
111+
"expect_valid": false,
112+
"expect_error_field": "agent_id",
113+
"preimage": {
114+
"agent_id": "agent-café",
115+
"action_type": "trail.anchor",
116+
"scope": "mycelium:av-neg",
117+
"timestamp": "2026-05-24T10:30:00.000Z"
118+
}
95119
}
96120
]
97121
}

mycelium_evidence_anchor.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import hashlib
2121
import json
2222
import time
23+
import unicodedata
2324
from abc import ABC, abstractmethod
2425
from dataclasses import dataclass, field
2526
from enum import Enum
@@ -250,7 +251,7 @@ def _format_timestamp_rfc3339(timestamp_s: int) -> str:
250251
def _compute_action_ref(agent_id: str, action_type: str, scope: str, timestamp: int) -> str:
251252
"""Domain-separated, JCS-canonicalized action_ref for this plugin's own hash space.
252253
253-
Two fixes over the prior implementation:
254+
Three fixes over the prior implementation:
254255
(1) domain separation — "mycelium-evidence-anchor:v1:" prefix, so this plugin's
255256
hashes can never collide with the canonical action-ref.md preimage (a bare
256257
4-field JCS object with no prefix) or with any other protocol using the same
@@ -262,7 +263,20 @@ def _compute_action_ref(agent_id: str, action_type: str, scope: str, timestamp:
262263
an alias for the canonical action_ref — it does not touch or reuse action-ref.md's
263264
preimage, which has real production adopters (see ADOPTERS.md) and is not
264265
being changed here.
266+
(3) Unicode normalization — agent_id/action_type/scope here come from caller-supplied
267+
`metadata` with no ASCII-only Domain restriction (unlike action_ref.py's
268+
`_validate_domain`, which rejects any non-ASCII value outright before hashing —
269+
that makes NFC vs NFD moot on the canonical path). This plugin genuinely allows
270+
non-ASCII values, so two byte-different but visually-identical strings (NFC vs
271+
NFD composition of the same agent_id) would otherwise hash to two different
272+
action_ref values for the same logical identity. Flagged by Henri Sirkkavaara
273+
(SCITT list, 2026-08-16) against RFC 8785/JCS generally; this is the one site in
274+
this repo where it was a live gap rather than already closed by the ASCII-only
275+
Domain check. 2026-08-16.
265276
"""
277+
agent_id = unicodedata.normalize("NFC", agent_id)
278+
action_type = unicodedata.normalize("NFC", action_type)
279+
scope = unicodedata.normalize("NFC", scope)
266280
payload = {
267281
"agent_id": agent_id,
268282
"action_type": action_type,

tests/test_mycelium_evidence_anchor.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for MyceliumAnchor — EvidenceAnchor community plugin."""
22

33
import hashlib
4+
import unicodedata
45
from unittest.mock import MagicMock, patch
56

67
import pytest
@@ -50,6 +51,38 @@ def test_hex_sha256(self):
5051
assert len(ref) == 64
5152
int(ref, 16) # must be valid hex
5253

54+
# NFC vs NFD: precomposed accented char (single code point) vs base char
55+
# + combining mark (two code points) — visually identical, byte-different.
56+
# Built via unicodedata.normalize rather than relying on a source-literal
57+
# glyph, since editors/tooling commonly normalize typed unicode to NFC on
58+
# save, which would silently defeat the "these are really different
59+
# bytes" premise the test depends on.
60+
_RAW_ACCENTED = "café" # "café", NFC form (single U+00E9)
61+
62+
def test_nfc_nfd_agent_id_same_ref(self):
63+
nfc = unicodedata.normalize("NFC", self._RAW_ACCENTED)
64+
nfd = unicodedata.normalize("NFD", self._RAW_ACCENTED)
65+
assert nfc != nfd # sanity: the two forms really are different bytes
66+
a = _compute_action_ref(f"agent-{nfc}", "agt:evidence_anchor", "agt-evidence", 1000)
67+
b = _compute_action_ref(f"agent-{nfd}", "agt:evidence_anchor", "agt-evidence", 1000)
68+
assert a == b
69+
70+
def test_nfc_nfd_scope_same_ref(self):
71+
nfc = unicodedata.normalize("NFC", self._RAW_ACCENTED)
72+
nfd = unicodedata.normalize("NFD", self._RAW_ACCENTED)
73+
assert nfc != nfd
74+
a = _compute_action_ref("agent-1", "agt:evidence_anchor", f"scope-{nfc}", 1000)
75+
b = _compute_action_ref("agent-1", "agt:evidence_anchor", f"scope-{nfd}", 1000)
76+
assert a == b
77+
78+
def test_nfc_nfd_action_type_same_ref(self):
79+
nfc = unicodedata.normalize("NFC", self._RAW_ACCENTED)
80+
nfd = unicodedata.normalize("NFD", self._RAW_ACCENTED)
81+
assert nfc != nfd
82+
a = _compute_action_ref("agent-1", f"op-{nfc}", "agt-evidence", 1000)
83+
b = _compute_action_ref("agent-1", f"op-{nfd}", "agt-evidence", 1000)
84+
assert a == b
85+
5386

5487
# ---------------------------------------------------------------------------
5588

0 commit comments

Comments
 (0)