Skip to content

Commit 322b6c2

Browse files
committed
Adversarial verification pass: fix §5.8 throw paths and cross-impl parity gaps
Three adversarial refuters (JCS byte-identity, port fidelity, degradation/ hostile-daemon) plus an addenda-test porter ran against both SDKs. Confirmed findings, all fixed with pinned regressions: - TS DaemonClient: new URL() on a garbage endpoint escaped the §5.8 catch and poisoned the connecting state; URL parsing now degrades. - TS/Python anchor sets: null/non-object entries, non-string endpoints, and unparseable endpoint URLs now warn-and-skip instead of throwing (including the emitInProcess submission-queue throw a bad anchor could reach); Python now matches TS in skipping any PRESENT anchor_type other than 'atrib-log', including non-strings and null. - Python mirror reader: a single invalid-UTF-8 byte anywhere in the file raised UnicodeDecodeError through both verbs; lines now decode individually and malformed ones skip per §5.9.5. - Python normalize_server_url rebuilt for WHATWG parity: IPv6 brackets, special-scheme slash collapsing, empty-host fallback, edge C0/space stripping, opaque-host case preservation, scheme-only-colon URLs. - is_valid_event_type_uri length now counts UTF-16 code units like the JS reference; timestamp validation accepts integral floats like Number.isInteger over identical wire bytes. - evidence.ts dedup key had a NUL byte where a space belonged (write-tool artifact), diverging from Python; fixed and repo-scanned. - Receipt parsing tightened: all-wrong-typed receipts read as absent, array record/receipt values dropped; a daemon emit result without a record_hash now falls through to in-process signing instead of reporting silent all-null success. - Documented, deliberate boundary (not routed around): non-I-JSON content (ints ≥ 2^53, lone surrogates) canonicalizes lossily in JS but raises in Python; rejection contract pinned in test_port_parity.py and documented in python/README.md. Suites: TS 117 passed + 1 skip; Python 251 passed + 6 skips incl. the 62-case cross-impl determinism judge; mypy strict clean; doc-sync green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZsFiTVkgLp3BoBXQiJ3ba
1 parent 7837354 commit 322b6c2

17 files changed

Lines changed: 948 additions & 66 deletions

packages/sdk/src/attribution.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,22 @@ export function parseAttributionReceiptBlock(meta: unknown): AttributionReceiptB
6565
const out: AttributionReceiptBlock = {}
6666
if (typeof block['token'] === 'string') out.token = block['token']
6767
const rawReceipt = block['receipt']
68-
if (typeof rawReceipt === 'object' && rawReceipt !== null) {
68+
if (typeof rawReceipt === 'object' && rawReceipt !== null && !Array.isArray(rawReceipt)) {
6969
const receipt: AttributionReceipt = {}
70+
let kept = 0
7071
for (const field of RECEIPT_STRING_FIELDS) {
7172
const value = (rawReceipt as Record<string, unknown>)[field]
72-
if (typeof value === 'string') receipt[field] = value
73+
if (typeof value === 'string') {
74+
receipt[field] = value
75+
kept += 1
76+
}
7377
}
74-
out.receipt = receipt
78+
// A receipt where every field was wrong-typed conveys nothing; treat
79+
// it as absent rather than surfacing an empty object.
80+
if (kept > 0) out.receipt = receipt
7581
}
7682
const rawRecord = block['record']
77-
if (typeof rawRecord === 'object' && rawRecord !== null) {
83+
if (typeof rawRecord === 'object' && rawRecord !== null && !Array.isArray(rawRecord)) {
7884
out.record = rawRecord as AtribRecord
7985
}
8086
return out.token !== undefined || out.receipt !== undefined || out.record !== undefined

packages/sdk/src/client.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,22 @@ export function createAtribClient(config: AtribClientConfig = {}): AtribClient {
110110

111111
if (daemon) {
112112
const outcome = await daemon.callTool('emit', args)
113-
if (outcome.ok && typeof outcome.value === 'object' && outcome.value !== null) {
114-
const result = attestResultFromEmitOutput(
115-
outcome.value as EmitOutputLike,
116-
'daemon',
117-
warnings,
118-
)
119-
return outcome.attribution !== undefined
120-
? { ...result, attribution_receipt: outcome.attribution }
113+
const attribution = outcome.ok ? outcome.attribution : undefined
114+
const emitOutput =
115+
outcome.ok && typeof outcome.value === 'object' && outcome.value !== null
116+
? (outcome.value as EmitOutputLike)
117+
: null
118+
// A structurally-garbage daemon result (no record_hash string) is a
119+
// daemon FAILURE, not a silent all-null success — fall through so
120+
// the in-process path can still sign.
121+
if (emitOutput !== null && typeof emitOutput.record_hash === 'string') {
122+
const result = attestResultFromEmitOutput(emitOutput, 'daemon', warnings)
123+
return attribution !== undefined
124+
? { ...result, attribution_receipt: attribution }
121125
: result
122126
}
123127
const reason = outcome.ok
124-
? 'daemon returned a non-object emit result'
128+
? 'daemon returned an emit result without a record_hash'
125129
: outcome.reason
126130
warnings.push(`atrib: daemon attest failed: ${reason}`)
127131
if (daemonMode === 'require') {

packages/sdk/src/config.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,35 @@ export function resolveAnchorSet(anchors: AnchorSpec[] | undefined): ResolvedAnc
122122
}
123123
const atribLogEndpoints: string[] = []
124124
for (const spec of anchors) {
125-
const endpoint = typeof spec === 'string' ? spec : spec.endpoint
126-
const anchorType = typeof spec === 'string' ? undefined : spec.anchor_type
125+
// Hostile/malformed entries warn-and-skip, never throw (§5.8): the
126+
// documented anchor posture is "warning, not an error".
127+
let endpoint: unknown
128+
let anchorType: unknown
129+
if (typeof spec === 'string') {
130+
endpoint = spec
131+
} else if (typeof spec === 'object' && spec !== null) {
132+
endpoint = (spec as { endpoint?: unknown }).endpoint
133+
anchorType = (spec as { anchor_type?: unknown }).anchor_type
134+
} else {
135+
warnings.push(`atrib: anchor entry ${String(spec)} is not a string or object; skipping`)
136+
continue
137+
}
138+
if (typeof endpoint !== 'string') {
139+
warnings.push('atrib: anchor entry without a string endpoint; skipping')
140+
continue
141+
}
127142
if (anchorType !== undefined && anchorType !== 'atrib-log') {
128143
warnings.push(
129-
`atrib: anchor_type '${anchorType}' (${endpoint}) is not supported yet (upgrade-path step 1); skipping this anchor`,
144+
`atrib: anchor_type '${String(anchorType)}' (${endpoint}) is not supported yet (upgrade-path step 1); skipping this anchor`,
130145
)
131146
continue
132147
}
148+
try {
149+
new URL(endpoint)
150+
} catch {
151+
warnings.push(`atrib: anchor endpoint '${endpoint}' is not a valid URL; skipping`)
152+
continue
153+
}
133154
atribLogEndpoints.push(endpoint)
134155
}
135156
if (atribLogEndpoints.length > 1) {

packages/sdk/src/daemon.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,24 +134,28 @@ export class DaemonClient {
134134
return null
135135
}
136136
this.connecting = (async () => {
137-
// The SDK's concrete transport declares `sessionId: string | undefined`
138-
// while the Transport interface under exactOptionalPropertyTypes wants
139-
// an optional property; the runtime shapes are identical.
140-
const transport = new StreamableHTTPClientTransport(
141-
new URL(this.endpoint),
142-
) as unknown as Transport
143-
const client = new Client(SDK_CLIENT_INFO)
137+
let client: Client | null = null
144138
try {
139+
// URL parsing stays INSIDE the try: a garbage endpoint is an
140+
// operational failure that must degrade (§5.8), never throw.
141+
const url = new URL(this.endpoint)
142+
// The SDK's concrete transport declares `sessionId: string | undefined`
143+
// while the Transport interface under exactOptionalPropertyTypes wants
144+
// an optional property; the runtime shapes are identical.
145+
const transport = new StreamableHTTPClientTransport(url) as unknown as Transport
146+
client = new Client(SDK_CLIENT_INFO)
145147
await withTimeout(client.connect(transport), this.connectTimeoutMs, 'daemon connect')
146148
this.client = client
147149
this.lastFailureAt = 0
148150
return client
149151
} catch (error) {
150152
this.lastFailureAt = Date.now()
151-
try {
152-
await client.close()
153-
} catch {
154-
// Ignore close failures on a connection that never established.
153+
if (client) {
154+
try {
155+
await client.close()
156+
} catch {
157+
// Ignore close failures on a connection that never established.
158+
}
155159
}
156160
const reason = error instanceof Error ? error.message : String(error)
157161
console.warn(`atrib: daemon connect failed (${this.endpoint}): ${reason}`)

packages/sdk/src/evidence.ts

0 Bytes
Binary file not shown.

packages/sdk/test/addenda.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* Post-spawn addenda surfaces of the consolidated SDK:
5+
*
6+
* - `dev.atrib/attribution` receipt parsing (P049 draft): lenient extraction
7+
* from a tool result's `_meta`; anything malformed yields null, wrong-typed
8+
* fields are dropped, never a throw.
9+
* - Anchor-set resolution (P043 headroom): today's single-atrib-log posture
10+
* with warn-never-error handling of unsupported anchor types and fan-out.
11+
* - Evidence envelope helpers (P042 draft): dedup key `(profile, payload.hash)`
12+
* and the four-value tier ladder.
13+
*/
14+
15+
import { describe, it, expect } from 'vitest'
16+
import {
17+
ATTRIBUTION_EXTENSION_KEY,
18+
evidenceEnvelopeKey,
19+
evidenceTierRank,
20+
parseAttributionReceiptBlock,
21+
resolveAnchorSet,
22+
type AtribRecord,
23+
type EvidenceEnvelope,
24+
} from '../src/index.js'
25+
26+
const RECORD: AtribRecord = {
27+
spec_version: 'atrib/1.0',
28+
content_id: `sha256:${'ab'.repeat(32)}`,
29+
creator_key: '0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc',
30+
chain_root: `sha256:${'12'.repeat(32)}`,
31+
event_type: 'https://atrib.dev/v1/types/observation',
32+
context_id: 'a'.repeat(32),
33+
timestamp: 1700000000000,
34+
signature: 'sig',
35+
}
36+
37+
const FULL_RECEIPT = {
38+
record_hash: `sha256:${'cd'.repeat(32)}`,
39+
creator_key: '0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc',
40+
context_id: 'a'.repeat(32),
41+
event_type: 'https://atrib.dev/v1/types/observation',
42+
chain_root: `sha256:${'12'.repeat(32)}`,
43+
log_submission: 'queued',
44+
}
45+
46+
function metaWith(block: unknown): Record<string, unknown> {
47+
return { [ATTRIBUTION_EXTENSION_KEY]: block }
48+
}
49+
50+
describe('parseAttributionReceiptBlock (P049 receipts)', () => {
51+
it('exposes the extension key constant', () => {
52+
expect(ATTRIBUTION_EXTENSION_KEY).toBe('dev.atrib/attribution')
53+
})
54+
55+
it('parses a valid full block: token + receipt + record', () => {
56+
const block = parseAttributionReceiptBlock(
57+
metaWith({ token: 'abc.def', receipt: FULL_RECEIPT, record: RECORD }),
58+
)
59+
expect(block).not.toBeNull()
60+
expect(block?.token).toBe('abc.def')
61+
expect(block?.receipt).toEqual(FULL_RECEIPT)
62+
expect(block?.record).toEqual(RECORD)
63+
})
64+
65+
it('parses a token-only block', () => {
66+
const block = parseAttributionReceiptBlock(metaWith({ token: 'abc.def' }))
67+
expect(block).toEqual({ token: 'abc.def' })
68+
expect(block?.receipt).toBeUndefined()
69+
expect(block?.record).toBeUndefined()
70+
})
71+
72+
it('parses a receipt-only block', () => {
73+
const block = parseAttributionReceiptBlock(metaWith({ receipt: FULL_RECEIPT }))
74+
expect(block).toEqual({ receipt: FULL_RECEIPT })
75+
expect(block?.token).toBeUndefined()
76+
expect(block?.record).toBeUndefined()
77+
})
78+
79+
it('ignores unknown extra fields at both block and receipt level', () => {
80+
const block = parseAttributionReceiptBlock(
81+
metaWith({
82+
token: 'abc.def',
83+
receipt: { ...FULL_RECEIPT, surprise: 'yes', tier: 3 },
84+
future_field: { nested: true },
85+
}),
86+
)
87+
expect(block).not.toBeNull()
88+
expect(block?.token).toBe('abc.def')
89+
// Only the known string fields survive; unknown keys never leak through.
90+
expect(block?.receipt).toEqual(FULL_RECEIPT)
91+
expect(block && 'future_field' in block).toBe(false)
92+
})
93+
94+
it('drops wrong-typed fields instead of throwing', () => {
95+
const block = parseAttributionReceiptBlock(
96+
metaWith({
97+
token: 42,
98+
receipt: {
99+
record_hash: 42,
100+
creator_key: null,
101+
context_id: ['a'.repeat(32)],
102+
event_type: { uri: 'x' },
103+
chain_root: true,
104+
log_submission: 'queued',
105+
},
106+
}),
107+
)
108+
expect(block).not.toBeNull()
109+
// token was a number → absent
110+
expect(block?.token).toBeUndefined()
111+
// receipt object survives with only the correctly-typed field
112+
expect(block?.receipt).toEqual({ log_submission: 'queued' })
113+
expect(block?.receipt?.record_hash).toBeUndefined()
114+
})
115+
116+
it('accepts unknown future log_submission statuses as strings', () => {
117+
const block = parseAttributionReceiptBlock(
118+
metaWith({ receipt: { log_submission: 'replicated' } }),
119+
)
120+
expect(block?.receipt?.log_submission).toBe('replicated')
121+
})
122+
123+
it('returns null when the dev.atrib/attribution key is missing', () => {
124+
expect(parseAttributionReceiptBlock({})).toBeNull()
125+
expect(parseAttributionReceiptBlock({ 'other.ext/key': { token: 'x' } })).toBeNull()
126+
})
127+
128+
it('returns null for non-object meta values', () => {
129+
expect(parseAttributionReceiptBlock(null)).toBeNull()
130+
expect(parseAttributionReceiptBlock(undefined)).toBeNull()
131+
expect(parseAttributionReceiptBlock(42)).toBeNull()
132+
expect(parseAttributionReceiptBlock('x')).toBeNull()
133+
})
134+
135+
it('returns null for a non-object or empty extension block', () => {
136+
expect(parseAttributionReceiptBlock(metaWith('token'))).toBeNull()
137+
expect(parseAttributionReceiptBlock(metaWith(null))).toBeNull()
138+
// Object with none of token/receipt/record recognized → null
139+
expect(parseAttributionReceiptBlock(metaWith({}))).toBeNull()
140+
expect(parseAttributionReceiptBlock(metaWith({ token: 42, record: 'x' }))).toBeNull()
141+
})
142+
})
143+
144+
describe('resolveAnchorSet (P043 anchor headroom)', () => {
145+
const LOG_A = 'https://log.atrib.dev/v1/entries'
146+
const LOG_B = 'https://log-b.example.dev/v1/entries'
147+
const REKOR = 'https://rekor.example.dev'
148+
149+
it('resolves undefined to no endpoint and no warnings', () => {
150+
expect(resolveAnchorSet(undefined)).toEqual({
151+
primaryLogEndpoint: undefined,
152+
warnings: [],
153+
})
154+
})
155+
156+
it('resolves an empty set to no endpoint and no warnings', () => {
157+
expect(resolveAnchorSet([])).toEqual({
158+
primaryLogEndpoint: undefined,
159+
warnings: [],
160+
})
161+
})
162+
163+
it('accepts a single bare-string anchor as an atrib-log endpoint', () => {
164+
expect(resolveAnchorSet([LOG_A])).toEqual({
165+
primaryLogEndpoint: LOG_A,
166+
warnings: [],
167+
})
168+
})
169+
170+
it('accepts the object form without anchor_type', () => {
171+
expect(resolveAnchorSet([{ endpoint: LOG_A }])).toEqual({
172+
primaryLogEndpoint: LOG_A,
173+
warnings: [],
174+
})
175+
})
176+
177+
it("accepts an explicit anchor_type of 'atrib-log'", () => {
178+
expect(resolveAnchorSet([{ endpoint: LOG_A, anchor_type: 'atrib-log' }])).toEqual({
179+
primaryLogEndpoint: LOG_A,
180+
warnings: [],
181+
})
182+
})
183+
184+
it('skips unsupported anchor types with a warning naming the type', () => {
185+
const resolved = resolveAnchorSet([{ endpoint: REKOR, anchor_type: 'rekor' }])
186+
expect(resolved.primaryLogEndpoint).toBeUndefined()
187+
expect(resolved.warnings).toHaveLength(1)
188+
expect(resolved.warnings[0]).toContain("'rekor'")
189+
expect(resolved.warnings[0]).toContain(REKOR)
190+
})
191+
192+
it('picks the first of two atrib-log anchors and warns about fan-out', () => {
193+
const resolved = resolveAnchorSet([LOG_A, { endpoint: LOG_B }])
194+
expect(resolved.primaryLogEndpoint).toBe(LOG_A)
195+
expect(resolved.warnings).toHaveLength(1)
196+
expect(resolved.warnings[0]).toContain('multi-anchor fan-out')
197+
})
198+
199+
it('chooses the atrib-log endpoint when it follows a skipped anchor', () => {
200+
const resolved = resolveAnchorSet([{ endpoint: REKOR, anchor_type: 'rekor' }, LOG_A])
201+
expect(resolved.primaryLogEndpoint).toBe(LOG_A)
202+
// One skip warning; no fan-out warning for a single usable anchor.
203+
expect(resolved.warnings).toHaveLength(1)
204+
expect(resolved.warnings[0]).toContain("'rekor'")
205+
})
206+
})
207+
208+
describe('evidence envelope helpers (P042 draft)', () => {
209+
const PAYLOAD_HASH = `sha256:${'ef'.repeat(32)}`
210+
211+
function envelope(tier: EvidenceEnvelope['tier']): EvidenceEnvelope {
212+
return {
213+
envelope: 1,
214+
profile: 'https://atrib.dev/v1/evidence/oauth2',
215+
profile_version: '1.0.0',
216+
tier,
217+
payload: { hash: PAYLOAD_HASH },
218+
}
219+
}
220+
221+
it('derives the dedup key from profile and payload hash', () => {
222+
expect(evidenceEnvelopeKey(envelope('verified'))).toBe(
223+
`https://atrib.dev/v1/evidence/oauth2 ${PAYLOAD_HASH}`,
224+
)
225+
})
226+
227+
it('keys are tier-independent (same profile + hash collide)', () => {
228+
expect(evidenceEnvelopeKey(envelope('declared'))).toBe(
229+
evidenceEnvelopeKey(envelope('verified')),
230+
)
231+
})
232+
233+
it('ranks the tier ladder declared < shape < attested < verified', () => {
234+
expect(evidenceTierRank('declared')).toBe(0)
235+
expect(evidenceTierRank('shape')).toBe(1)
236+
expect(evidenceTierRank('attested')).toBe(2)
237+
expect(evidenceTierRank('verified')).toBe(3)
238+
expect(evidenceTierRank('declared')).toBeLessThan(evidenceTierRank('shape'))
239+
expect(evidenceTierRank('shape')).toBeLessThan(evidenceTierRank('attested'))
240+
expect(evidenceTierRank('attested')).toBeLessThan(evidenceTierRank('verified'))
241+
})
242+
})

0 commit comments

Comments
 (0)