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:
Duncan
2026-08-25 17:47:02 -04:00
parent 6e7c6975e3
commit 926a02108c
5 changed files with 373 additions and 46 deletions

View File

@@ -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}");

View 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}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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