mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
fix(acp): unify sentinel byte bounds across producer and parser
The permission-request sentinel bounded string fields inconsistently: the harness truncated labels by Rust char scalars and left sessionId, turnId, nonce, and total content unbounded, while the desktop parser bounded sessionId/turnId/labels by JavaScript UTF-16 code units. An oversized adapter-supplied sessionId (or a 200-code-point multibyte label) produced a card the harness published but the parser rejected, rendering as raw JSON until timeout. Freeze one limit set in UTF-8 bytes on both sides: every string leaf at 200 bytes and total serialized content at 4096 bytes. Labels truncate on a char boundary; every other field fails closed (synchronous deny, zero card events). Couple the cross-language contract test to a single checked-in fixture the Rust builder asserts byte-equal and the desktop boundary test parses, so a producer-side wire change breaks the test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -3660,11 +3660,76 @@ fn new_permission_nonce() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Maximum length of a label string in a sentinel card.
|
||||
/// Frozen sentinel byte bounds, shared verbatim with the Desktop parser
|
||||
/// (`MAX_STRING_BYTES` / `MAX_CONTENT_BYTES` in `permissionRequest.ts`).
|
||||
///
|
||||
/// Matches the D6 frozen schema: labels come from untrusted agent-supplied ACP
|
||||
/// options and must be capped before embedding in the Nostr event content.
|
||||
const SENTINEL_LABEL_MAX: usize = 200;
|
||||
/// The producer and parser MUST agree on both the values AND the unit — UTF-8
|
||||
/// bytes — so a card the harness emits always parses and a card the parser
|
||||
/// accepts is always one the harness could emit. Measuring in Rust `char`
|
||||
/// scalars vs JavaScript UTF-16 code units (the prior split) let a producer-
|
||||
/// valid multibyte label be rejected by the parser, publishing a card the
|
||||
/// desktop renders as raw JSON until timeout.
|
||||
///
|
||||
/// `SENTINEL_STRING_MAX_BYTES` bounds every untrusted string leaf: labels,
|
||||
/// each `optionId`, `requestNonce`, `sessionId`, `turnId`, and `chosenOptionId`.
|
||||
/// `SENTINEL_CONTENT_MAX_BYTES` bounds the total serialized sentinel content.
|
||||
const SENTINEL_STRING_MAX_BYTES: usize = 200;
|
||||
const SENTINEL_CONTENT_MAX_BYTES: usize = 4096;
|
||||
|
||||
/// Truncate `s` to at most `max_bytes` UTF-8 bytes on a char boundary.
|
||||
///
|
||||
/// Labels are lossy display strings, so an over-long one is truncated (not
|
||||
/// rejected). Truncating on a char boundary guarantees valid UTF-8 and a byte
|
||||
/// length the Desktop parser — which bounds the same field in bytes — accepts.
|
||||
fn truncate_to_bytes(s: &str, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// Enforce the frozen sentinel string bound on one field.
|
||||
///
|
||||
/// Returns `None` (fail closed) when `value` exceeds `SENTINEL_STRING_MAX_BYTES`
|
||||
/// UTF-8 bytes. `sessionId` is the load-bearing case: it comes straight from the
|
||||
/// adapter's unbounded `session/new` response, so an oversized adapter session
|
||||
/// ID must abort sentinel construction rather than publish a card the Desktop
|
||||
/// parser rejects (which would render as raw JSON until timeout). Labels are the
|
||||
/// exception — they are truncated at the source, never passed here.
|
||||
fn check_sentinel_field(field: &str, value: &str) -> Option<()> {
|
||||
if value.len() > SENTINEL_STRING_MAX_BYTES {
|
||||
tracing::warn!(
|
||||
target: "acp::permission",
|
||||
"sentinel field {field} exceeds {SENTINEL_STRING_MAX_BYTES} bytes ({}) — failing closed",
|
||||
value.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Serialize a sentinel payload and enforce the total-content byte bound.
|
||||
///
|
||||
/// Returns `None` (fail closed) when serialization fails or the serialized
|
||||
/// content exceeds `SENTINEL_CONTENT_MAX_BYTES`. This is the single total-size
|
||||
/// gate the Desktop parser mirrors (`MAX_CONTENT_BYTES`), so producer and parser
|
||||
/// can never disagree on whether a given card is admissible.
|
||||
fn serialize_bounded_sentinel(payload: &serde_json::Value) -> Option<String> {
|
||||
let content = serde_json::to_string(payload).ok()?;
|
||||
if content.len() > SENTINEL_CONTENT_MAX_BYTES {
|
||||
tracing::warn!(
|
||||
target: "acp::permission",
|
||||
"sentinel content exceeds {SENTINEL_CONTENT_MAX_BYTES} bytes ({}) — failing closed",
|
||||
content.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(content)
|
||||
}
|
||||
|
||||
/// The two card actions surfaced to the owner: the validated `allow_once` and
|
||||
/// `reject_once` options, in that fixed order. Built by [`select_card_actions`]
|
||||
@@ -3713,7 +3778,7 @@ fn sentinel_option_fields(actions: &CardActions) -> (Vec<serde_json::Value>, ser
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let name = opt.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let capped: String = name.chars().take(SENTINEL_LABEL_MAX).collect();
|
||||
let capped = truncate_to_bytes(name, SENTINEL_STRING_MAX_BYTES);
|
||||
option_ids.push(serde_json::Value::String(id.to_string()));
|
||||
labels.insert(id.to_string(), serde_json::Value::String(capped));
|
||||
}
|
||||
@@ -3734,6 +3799,11 @@ fn build_sentinel_pending_payload(
|
||||
session_id: Option<&str>,
|
||||
turn_id: &str,
|
||||
) -> Option<String> {
|
||||
check_sentinel_field("requestNonce", nonce)?;
|
||||
check_sentinel_field("turnId", turn_id)?;
|
||||
if let Some(sid) = session_id {
|
||||
check_sentinel_field("sessionId", sid)?;
|
||||
}
|
||||
let (option_ids, labels) = sentinel_option_fields(actions);
|
||||
let payload = serde_json::json!({
|
||||
"v": 1,
|
||||
@@ -3745,7 +3815,7 @@ fn build_sentinel_pending_payload(
|
||||
"optionIds": option_ids,
|
||||
"labels": labels,
|
||||
});
|
||||
serde_json::to_string(&payload).ok()
|
||||
serialize_bounded_sentinel(&payload)
|
||||
}
|
||||
|
||||
/// Build the JSON payload for a kind-40003 RESOLVED sentinel card edit.
|
||||
@@ -3760,6 +3830,14 @@ fn build_sentinel_resolved_payload(
|
||||
outcome: &str,
|
||||
chosen_option_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
check_sentinel_field("requestNonce", nonce)?;
|
||||
check_sentinel_field("turnId", turn_id)?;
|
||||
if let Some(sid) = session_id {
|
||||
check_sentinel_field("sessionId", sid)?;
|
||||
}
|
||||
if let Some(chosen) = chosen_option_id {
|
||||
check_sentinel_field("chosenOptionId", chosen)?;
|
||||
}
|
||||
let (option_ids, labels) = sentinel_option_fields(actions);
|
||||
let payload = serde_json::json!({
|
||||
"v": 1,
|
||||
@@ -3774,7 +3852,7 @@ fn build_sentinel_resolved_payload(
|
||||
"outcome": outcome,
|
||||
"chosenOptionId": chosen_option_id,
|
||||
});
|
||||
serde_json::to_string(&payload).ok()
|
||||
serialize_bounded_sentinel(&payload)
|
||||
}
|
||||
|
||||
/// Build and sign a kind-9 sentinel card event.
|
||||
@@ -3865,12 +3943,6 @@ fn select_unique_option_id(options: &[serde_json::Value], kind: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum length of an `optionId` string forwarded into a sentinel card.
|
||||
///
|
||||
/// ACP option IDs are short opaque tokens; this bounds an adversarial adapter
|
||||
/// from embedding an oversized ID that inflates the card content or DOM.
|
||||
const SENTINEL_OPTION_ID_MAX: usize = 200;
|
||||
|
||||
/// Select the exactly-two card actions from a permission request's options:
|
||||
/// the unique `allow_once` and the unique `reject_once`. Returns their option
|
||||
/// objects (with `kind`/`name`/`optionId`) so the caller can build a card that
|
||||
@@ -3883,11 +3955,11 @@ fn select_card_actions(options: &[serde_json::Value]) -> Result<CardActions, Str
|
||||
let allow_id = select_allow_once(options)?;
|
||||
let reject_id = select_reject_once(options)?;
|
||||
for id in [&allow_id, &reject_id] {
|
||||
if id.len() > SENTINEL_OPTION_ID_MAX {
|
||||
if id.len() > SENTINEL_STRING_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"optionId exceeds {SENTINEL_OPTION_ID_MAX} chars: {} > {}",
|
||||
"optionId exceeds {SENTINEL_STRING_MAX_BYTES} bytes: {} > {}",
|
||||
id.len(),
|
||||
SENTINEL_OPTION_ID_MAX
|
||||
SENTINEL_STRING_MAX_BYTES
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -7176,7 +7248,7 @@ mod tests {
|
||||
fn select_card_actions_fails_closed_on_oversized_option_id() {
|
||||
// An adversarial adapter embedding an oversized optionId must be
|
||||
// rejected before it can inflate the sentinel/DOM.
|
||||
let big = "x".repeat(SENTINEL_OPTION_ID_MAX + 1);
|
||||
let big = "x".repeat(SENTINEL_STRING_MAX_BYTES + 1);
|
||||
let opts = serde_json::json!([
|
||||
{"optionId": big, "kind": "allow_once", "name": "Allow"},
|
||||
{"optionId": "r", "kind": "reject_once", "name": "Reject"},
|
||||
@@ -7184,7 +7256,128 @@ mod tests {
|
||||
let opts = opts.as_array().unwrap().clone();
|
||||
assert!(
|
||||
select_card_actions(&opts).is_err(),
|
||||
"an optionId over SENTINEL_OPTION_ID_MAX must fail closed"
|
||||
"an optionId over SENTINEL_STRING_MAX_BYTES must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
// ── F3: frozen sentinel byte bounds (producer side) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_sentinel_pending_fails_closed_on_oversized_session_id() {
|
||||
// The adapter-supplied sessionId is unbounded upstream. An oversized one
|
||||
// must abort sentinel construction — never publish a card the Desktop
|
||||
// parser rejects (which renders as raw JSON until timeout).
|
||||
let actions = test_card_actions();
|
||||
let big_session = "s".repeat(SENTINEL_STRING_MAX_BYTES + 1);
|
||||
let out = build_sentinel_pending_payload(
|
||||
"nonce-abc",
|
||||
&actions,
|
||||
1_700_000_300,
|
||||
Some(&big_session),
|
||||
"turn-xyz",
|
||||
);
|
||||
assert!(
|
||||
out.is_none(),
|
||||
"an over-limit sessionId must fail closed (no sentinel)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sentinel_pending_fails_closed_on_oversized_nonce() {
|
||||
let actions = test_card_actions();
|
||||
let big_nonce = "n".repeat(SENTINEL_STRING_MAX_BYTES + 1);
|
||||
let out = build_sentinel_pending_payload(
|
||||
&big_nonce,
|
||||
&actions,
|
||||
1_700_000_300,
|
||||
Some("sess"),
|
||||
"turn-xyz",
|
||||
);
|
||||
assert!(out.is_none(), "an over-limit nonce must fail closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sentinel_pending_at_session_id_limit_succeeds() {
|
||||
// Exactly at the limit must succeed — the gate is not over-tight.
|
||||
let actions = test_card_actions();
|
||||
let session = "s".repeat(SENTINEL_STRING_MAX_BYTES);
|
||||
let out = build_sentinel_pending_payload(
|
||||
"nonce-abc",
|
||||
&actions,
|
||||
1_700_000_300,
|
||||
Some(&session),
|
||||
"turn-xyz",
|
||||
);
|
||||
assert!(
|
||||
out.is_some(),
|
||||
"a sessionId exactly at SENTINEL_STRING_MAX_BYTES must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_label_truncated_to_byte_limit_on_char_boundary() {
|
||||
// A multibyte label over the byte limit is truncated on a char boundary,
|
||||
// yielding valid UTF-8 within SENTINEL_STRING_MAX_BYTES that the Desktop
|
||||
// byte-bounded parser accepts.
|
||||
let big_label = "😀".repeat(60); // 240 UTF-8 bytes
|
||||
let opts = serde_json::json!([
|
||||
{"optionId":"a","kind":"allow_once","name": big_label},
|
||||
{"optionId":"r","kind":"reject_once","name":"Reject"},
|
||||
]);
|
||||
let opts = opts.as_array().unwrap().clone();
|
||||
let actions = select_card_actions(&opts).expect("two actions");
|
||||
let (_, labels) = sentinel_option_fields(&actions);
|
||||
let label = labels["a"].as_str().unwrap();
|
||||
assert!(
|
||||
label.len() <= SENTINEL_STRING_MAX_BYTES,
|
||||
"label must be truncated to <= {SENTINEL_STRING_MAX_BYTES} bytes, got {}",
|
||||
label.len()
|
||||
);
|
||||
// Every 😀 is 4 bytes, so a byte-boundary truncation at 200 keeps 50 of
|
||||
// them (200 bytes) — never a split scalar.
|
||||
assert!(
|
||||
label.chars().all(|c| c == '😀'),
|
||||
"truncation must land on a char boundary (no mojibake)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sentinel_fails_closed_on_oversized_total_content() {
|
||||
// A label that individually fits but inflates the serialized total past
|
||||
// SENTINEL_CONTENT_MAX_BYTES cannot happen through select_card_actions
|
||||
// (labels are byte-capped), so drive serialize_bounded_sentinel directly
|
||||
// to prove the total-content gate rejects an oversized payload.
|
||||
let mut labels = serde_json::Map::new();
|
||||
labels.insert("a".into(), serde_json::json!("x".repeat(200)));
|
||||
let payload = serde_json::json!({
|
||||
"v": 1,
|
||||
"state": "pending",
|
||||
"pad": "y".repeat(SENTINEL_CONTENT_MAX_BYTES),
|
||||
"labels": labels,
|
||||
});
|
||||
assert!(
|
||||
serialize_bounded_sentinel(&payload).is_none(),
|
||||
"total content over SENTINEL_CONTENT_MAX_BYTES must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sentinel_resolved_fails_closed_on_oversized_chosen_option_id() {
|
||||
let actions = test_card_actions();
|
||||
let big_chosen = "c".repeat(SENTINEL_STRING_MAX_BYTES + 1);
|
||||
let out = build_sentinel_resolved_payload(
|
||||
"nonce-abc",
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
&actions,
|
||||
1_700_000_300,
|
||||
Some("sess"),
|
||||
"turn-xyz",
|
||||
"applied",
|
||||
Some(&big_chosen),
|
||||
);
|
||||
assert!(
|
||||
out.is_none(),
|
||||
"an over-limit chosenOptionId must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9464,6 +9657,18 @@ mod tests {
|
||||
build_sentinel_pending_payload(nonce, &actions, expiry_unix_secs, session_id, turn_id)
|
||||
.expect("build_sentinel_pending_payload must succeed");
|
||||
|
||||
// Fixture coupling: the producer output MUST be byte-identical to the
|
||||
// checked-in fixture the Desktop boundary test parses. A producer-side
|
||||
// change to the wire shape breaks THIS assertion, forcing the fixture
|
||||
// (and the desktop test that consumes it) to be updated in lockstep.
|
||||
const FIXTURE: &str = include_str!("../tests/fixtures/sentinel_pending.json");
|
||||
assert_eq!(
|
||||
content, FIXTURE,
|
||||
"producer output must be byte-equal to the shared cross-language fixture \
|
||||
(crates/buzz-acp/tests/fixtures/sentinel_pending.json); if this diff is \
|
||||
intentional, regenerate the fixture and the desktop boundary test"
|
||||
);
|
||||
|
||||
// Print the canonical fixture string for the Desktop fixture.
|
||||
println!("kind-9 content fixture:\n{content}");
|
||||
|
||||
|
||||
1
crates/buzz-acp/tests/fixtures/sentinel_pending.json
vendored
Normal file
1
crates/buzz-acp/tests/fixtures/sentinel_pending.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"expiresAt":1700000300,"labels":{"opt-allow":"Allow once","opt-reject":"Reject"},"optionIds":["opt-allow","opt-reject"],"requestNonce":"test-nonce-fixture-abc123","sessionId":"sess-fixture-001","state":"pending","turnId":"turn-fixture-xyz","v":1}
|
||||
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const mod = await import("./permissionRequest.js").catch(
|
||||
() => import("./permissionRequest.ts"),
|
||||
@@ -355,6 +357,68 @@ describe("extractPermissionRequest — rejection cases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Byte-unit boundary (producer/parser agreement) ──────────────────────────
|
||||
// These pin the shared unit: UTF-8 bytes, identical to the harness
|
||||
// (`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES`). A prior split
|
||||
// (Rust char scalars vs JS UTF-16 code units) let a producer-valid multibyte
|
||||
// label be rejected here, publishing a card the desktop renders as raw JSON.
|
||||
|
||||
describe("extractPermissionRequest — byte-unit boundaries", () => {
|
||||
// "😀" is 1 JS char pair (length 2 as UTF-16 units it counts as 2), 4 UTF-8 bytes.
|
||||
// 50 of them = 200 UTF-8 bytes: exactly at the limit, must be ACCEPTED.
|
||||
it("test_multibyte_label_at_200_bytes_parses", () => {
|
||||
const label = "😀".repeat(50); // 50 * 4 = 200 UTF-8 bytes
|
||||
assert.equal(new TextEncoder().encode(label).length, 200);
|
||||
const ok = {
|
||||
...PENDING_NORMAL,
|
||||
labels: { "opt-allow": label, "opt-deny": "Deny" },
|
||||
};
|
||||
const result = extractPermissionRequest(raw(ok));
|
||||
assert.ok(result !== null, "a 200-UTF-8-byte label must be accepted");
|
||||
assert.equal(result.labels["opt-allow"], label);
|
||||
});
|
||||
|
||||
it("test_multibyte_label_over_200_bytes_returns_null", () => {
|
||||
const label = "😀".repeat(51); // 204 UTF-8 bytes
|
||||
const bad = {
|
||||
...PENDING_NORMAL,
|
||||
labels: { "opt-allow": label, "opt-deny": "Deny" },
|
||||
};
|
||||
assert.equal(extractPermissionRequest(raw(bad)), null);
|
||||
});
|
||||
|
||||
it("test_sessionId_over_200_bytes_returns_null", () => {
|
||||
// The adapter-supplied sessionId is the load-bearing hole: an oversized one
|
||||
// must be rejected, not published as an unrenderable card.
|
||||
const bad = { ...PENDING_NORMAL, sessionId: "s".repeat(201) };
|
||||
assert.equal(extractPermissionRequest(raw(bad)), null);
|
||||
});
|
||||
|
||||
it("test_turnId_over_200_bytes_returns_null", () => {
|
||||
const bad = { ...PENDING_NORMAL, turnId: "t".repeat(201) };
|
||||
assert.equal(extractPermissionRequest(raw(bad)), null);
|
||||
});
|
||||
|
||||
it("test_total_content_over_max_bytes_returns_null", () => {
|
||||
// A structurally-valid sentinel padded past MAX_CONTENT_BYTES via an extra
|
||||
// (ignored) field must be rejected before parsing — the total-content gate.
|
||||
const bad = { ...PENDING_NORMAL, pad: "x".repeat(5000) };
|
||||
const serialized = raw(bad);
|
||||
assert.ok(
|
||||
new TextEncoder().encode(serialized).length > 4096,
|
||||
"fixture must exceed MAX_CONTENT_BYTES to exercise the gate",
|
||||
);
|
||||
assert.equal(extractPermissionRequest(serialized), null);
|
||||
});
|
||||
|
||||
it("test_content_just_under_max_bytes_parses", () => {
|
||||
// A label sized so total content stays within MAX_CONTENT_BYTES must parse,
|
||||
// proving the total-content gate is not over-tight.
|
||||
const result = extractPermissionRequest(raw(PENDING_NORMAL));
|
||||
assert.ok(result !== null, "a normal-sized sentinel must parse");
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPermissionRequestSentinel ───────────────────────────────────────────────
|
||||
|
||||
describe("isPermissionRequestSentinel", () => {
|
||||
@@ -384,15 +448,23 @@ describe("isPermissionRequestSentinel", () => {
|
||||
});
|
||||
|
||||
// ── Harness integration fixture ───────────────────────────────────────────────
|
||||
// This exact string is produced by `build_sentinel_pending_payload` in
|
||||
// crates/buzz-acp/src/acp.rs (captured by `kind9_content_fixture_structural_invariants`)
|
||||
// from a request carrying allow_once + reject_once + allow_always: the card
|
||||
// surfaces ONLY the two ruled actions. serde_json serializes object keys in
|
||||
// sorted (BTreeMap) order. It validates that the Desktop parser accepts the
|
||||
// exact bytes the harness emits.
|
||||
// The exact bytes below live in the shared, checked-in fixture
|
||||
// `crates/buzz-acp/tests/fixtures/sentinel_pending.json`. The Rust test
|
||||
// `kind9_content_fixture_structural_invariants` asserts `build_sentinel_pending_payload`
|
||||
// output is byte-equal to that file; this test asserts the Desktop parser accepts
|
||||
// the same file. Because both sides consume ONE file, a producer-side wire change
|
||||
// breaks the Rust byte-equality assertion — the literal can no longer silently drift.
|
||||
// (serde_json serializes object keys in sorted BTreeMap order.)
|
||||
describe("harness integration fixture", () => {
|
||||
const HARNESS_KIND9_CONTENT =
|
||||
'{"expiresAt":1700000300,"labels":{"opt-allow":"Allow once","opt-reject":"Reject"},"optionIds":["opt-allow","opt-reject"],"requestNonce":"test-nonce-fixture-abc123","sessionId":"sess-fixture-001","state":"pending","turnId":"turn-fixture-xyz","v":1}';
|
||||
const HARNESS_KIND9_CONTENT = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../../crates/buzz-acp/tests/fixtures/sentinel_pending.json",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("test_harness_kind9_content_parses_to_pending_payload", () => {
|
||||
const result = extractPermissionRequest(HARNESS_KIND9_CONTENT);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* interpreted as ACP kinds by the renderer.
|
||||
* - Labels come from `labels[optionId]` — harness-provided display strings,
|
||||
* not raw ACP kind names.
|
||||
* - All untrusted display strings are size-bounded (≤ 200 chars) and
|
||||
* - All untrusted display strings are size-bounded (≤ 200 UTF-8 bytes) and
|
||||
* HTML-escaped by React at render time.
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* Pending sentinel — the card is actionable.
|
||||
*
|
||||
* `requestNonce` and `expiresAt` are trusted as unsigned ints from the harness.
|
||||
* `labels` values are untrusted display strings (capped at 200 chars).
|
||||
* `labels` values are untrusted display strings (capped at 200 UTF-8 bytes).
|
||||
*/
|
||||
export type PermissionRequestPending = {
|
||||
v: 1;
|
||||
@@ -46,7 +46,7 @@ export type PermissionRequestPending = {
|
||||
* `allow_always`), so any other cardinality is a malformed sentinel.
|
||||
*/
|
||||
optionIds: string[];
|
||||
/** Harness-provided display labels keyed by optionId. Each ≤ 200 chars. */
|
||||
/** Harness-provided display labels keyed by optionId. Each ≤ 200 UTF-8 bytes. */
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -78,8 +78,29 @@ export type PermissionRequestPayload =
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum character length for any untrusted display string in the sentinel. */
|
||||
const MAX_LABEL_CHARS = 200;
|
||||
/**
|
||||
* Frozen sentinel byte bounds — shared verbatim with the harness producer
|
||||
* (`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES` in
|
||||
* `crates/buzz-acp/src/acp.rs`).
|
||||
*
|
||||
* Both the values AND the unit — UTF-8 bytes — must match the producer. The
|
||||
* prior split (harness truncated labels by Rust `char` scalars while this parser
|
||||
* bounded by JavaScript `.length` UTF-16 code units) let a producer-valid
|
||||
* multibyte label be rejected here, publishing a card the desktop renders as raw
|
||||
* JSON until timeout. Measuring in bytes on both sides closes that gap.
|
||||
*
|
||||
* `MAX_STRING_BYTES` bounds every untrusted string leaf: labels, each
|
||||
* `optionId`, `requestNonce`, `sessionId`, `turnId`, and `chosenOptionId`.
|
||||
* `MAX_CONTENT_BYTES` bounds the total serialized sentinel content.
|
||||
*/
|
||||
const MAX_STRING_BYTES = 200;
|
||||
const MAX_CONTENT_BYTES = 4096;
|
||||
|
||||
/** UTF-8 byte length of a string — the shared measurement unit. */
|
||||
const UTF8 = new TextEncoder();
|
||||
function byteLength(s: string): number {
|
||||
return UTF8.encode(s).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact number of option IDs in a sentinel. The card is a two-action contract:
|
||||
@@ -89,13 +110,6 @@ const MAX_LABEL_CHARS = 200;
|
||||
*/
|
||||
const OPTION_IDS_COUNT = 2;
|
||||
|
||||
/**
|
||||
* Maximum character length of an opaque `optionId` or `requestNonce`. Matches
|
||||
* the harness bound (`SENTINEL_OPTION_ID_MAX`). Bounds an adversarial adapter
|
||||
* from embedding an oversized token that inflates the card content or DOM.
|
||||
*/
|
||||
const MAX_ID_CHARS = 200;
|
||||
|
||||
/** Regex for a valid 64-character lowercase hex Nostr event ID. */
|
||||
const HEX64_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
@@ -128,9 +142,14 @@ const VALID_OUTCOMES = new Set([
|
||||
export function extractPermissionRequest(
|
||||
content: string,
|
||||
): PermissionRequestPayload | null {
|
||||
const trimmed = content.trim();
|
||||
// Total-content byte bound — the single size gate mirrored by the harness
|
||||
// (`SENTINEL_CONTENT_MAX_BYTES`). Reject before parsing so an oversized signed
|
||||
// payload can never allocate an outsized DOM/control value.
|
||||
if (byteLength(trimmed) > MAX_CONTENT_BYTES) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content.trim());
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -152,7 +171,7 @@ export function isPermissionRequestSentinel(content: string): boolean {
|
||||
// ── Type guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isSafeString(v: unknown): v is string {
|
||||
return typeof v === "string" && v.length <= MAX_LABEL_CHARS;
|
||||
return typeof v === "string" && byteLength(v) <= MAX_STRING_BYTES;
|
||||
}
|
||||
|
||||
function isNullableString(v: unknown): v is string | null {
|
||||
@@ -173,7 +192,7 @@ function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload {
|
||||
if (
|
||||
typeof p.requestNonce !== "string" ||
|
||||
p.requestNonce.length === 0 ||
|
||||
p.requestNonce.length > MAX_ID_CHARS
|
||||
byteLength(p.requestNonce) > MAX_STRING_BYTES
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -194,7 +213,9 @@ function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload {
|
||||
p.optionIds.length !== OPTION_IDS_COUNT ||
|
||||
!p.optionIds.every(
|
||||
(id) =>
|
||||
typeof id === "string" && id.length > 0 && id.length <= MAX_ID_CHARS,
|
||||
typeof id === "string" &&
|
||||
id.length > 0 &&
|
||||
byteLength(id) <= MAX_STRING_BYTES,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -237,7 +258,7 @@ function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload {
|
||||
if (
|
||||
typeof p.chosenOptionId !== "string" ||
|
||||
p.chosenOptionId.length === 0 ||
|
||||
p.chosenOptionId.length > MAX_ID_CHARS ||
|
||||
byteLength(p.chosenOptionId) > MAX_STRING_BYTES ||
|
||||
!optionIds.includes(p.chosenOptionId)
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -387,9 +387,37 @@ forwarded — if the two ruled actions are not both present and unambiguous, the
|
||||
harness fails closed and posts no card. The read loop accepts an owner decision
|
||||
only when it matches one of those two snapshotted actions, not on mere membership
|
||||
in the adapter's original option list. Because no durable rule can ever be
|
||||
offered, there is no durable-rule disclosure. Label values in the sentinel come
|
||||
directly from the ACP options' `name` fields, capped at 200 characters; render
|
||||
them verbatim.
|
||||
offered, there is no durable-rule disclosure.
|
||||
|
||||
### D6 frozen sentinel size limits
|
||||
|
||||
All untrusted string leaves and the total serialized content are bounded in
|
||||
**UTF-8 bytes**, enforced identically on both the harness producer
|
||||
(`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES` in
|
||||
`crates/buzz-acp/src/acp.rs`) and the Desktop parser (`MAX_STRING_BYTES` /
|
||||
`MAX_CONTENT_BYTES` in `permissionRequest.ts`). The producer and parser MUST
|
||||
agree on both the values AND the unit; a Rust-char-scalar vs JS-UTF-16-code-unit
|
||||
split would let a producer-valid card be rejected by Desktop and rendered as raw
|
||||
JSON until timeout.
|
||||
|
||||
| Field | Limit | Over-limit behavior |
|
||||
|-------|-------|---------------------|
|
||||
| `requestNonce` | 200 UTF-8 bytes | fail closed (no card) |
|
||||
| `sessionId` | 200 UTF-8 bytes | fail closed (no card) |
|
||||
| `turnId` | 200 UTF-8 bytes | fail closed (no card) |
|
||||
| each `optionId` | 200 UTF-8 bytes | fail closed (no card) |
|
||||
| `chosenOptionId` | 200 UTF-8 bytes | fail closed (no edit) |
|
||||
| each label value | 200 UTF-8 bytes | truncated on a char boundary at the producer |
|
||||
| total serialized content | 4096 UTF-8 bytes | fail closed (no card) |
|
||||
|
||||
`sessionId` is the load-bearing case: it comes straight from the adapter's
|
||||
unbounded `session/new` response, so an oversized adapter session ID aborts
|
||||
sentinel construction (synchronous deny, zero card events) rather than publishing
|
||||
an unrenderable card. Labels are lossy display strings and are the only field
|
||||
truncated rather than rejected; truncation lands on a UTF-8 char boundary so the
|
||||
result is always valid UTF-8 within the byte limit the Desktop parser accepts.
|
||||
Label values in the sentinel come directly from the ACP options' `name` fields;
|
||||
render them verbatim.
|
||||
|
||||
### Sentinel authenticity
|
||||
|
||||
|
||||
Reference in New Issue
Block a user