NAC3 -- security model

Spec version: 2.2 stable (+ v2.3 interop preview).

Threat model

NAC3 sits between agents and your UI. It is a contract layer, not an authentication layer. Several distinct trust boundaries exist; this doc names them so you can think clearly about which ones NAC3 protects and which ones it does not.

Boundary A: User -> UI

Out of scope for NAC. Use your existing auth (sessions, OAuth, SSO, MFA). Once a user is authenticated, NAC3 assumes any action the user could take in the UI is allowed.

Boundary B: User-controlled agent -> UI

A user grants an agent permission to drive their browser session. Examples: a voice assistant, a screen reader, an LLM chat client embedded in the same page. NAC3's job here:

without scraping coordinates.

clicks for security-sensitive verbs (payment, deletion, role grants). The agent cannot forge isTrusted=true; only a real user gesture sets it.

without re-reading the DOM.

  1. Provide the agent with stable element names so it can act
  2. Surface event.isTrusted so the host can refuse synthetic
  3. Provide event-level acks so the agent knows what completed

NAC3 does NOT protect against an agent that the user explicitly trusted misusing that trust. That is a user-experience problem (consent prompts before sensitive verbs) handled by your app, not by NAC.

Boundary C: External service -> UI (LLM intermediary)

If a user's voice prompt is shipped to a remote LLM that returns NAC3 actions, the LLM becomes a trust principal. NAC3's job here:

snapshot + the registered manifests). It does not see the user's auth tokens, cookies, or DOM internals beyond what the manifest declares.

action; the chat client validates it (does the nac_id exist? is the verb allowed?) before dispatching.

in the snapshot it sent (prevents prompt injection that smuggles in arbitrary ids).

  1. The LLM only sees what NAC.describe() exposes (the tree
  2. The LLM cannot directly cause a click. It returns a structured
  3. The chat client SHOULD reject actions whose nac_id was not

NAC3 does NOT prescribe the LLM's prompt template, rate limits, or filtering. See guides/LLM_WIRING.md for recommendations.

Boundary D: Tenant -> Tenant (multi-tenant deployments)

Multi-tenant SaaS where tenants share a runtime but not data. NAC3 protects this with HMAC-signed manifests:

over a stable serialisation, using a per-tenant secret stored server-side.

secret it expects for the active tenant. If the signature mismatches, the manifest is rejected.

without the signing secret.

  1. Each tenant ships its manifest with an HMAC signature computed
  2. The runtime, on NAC.register(), recomputes the HMAC using the
  3. A malicious tenant cannot forge another tenant's manifest

NAC3 does NOT prevent a tenant from registering an excessively large or malformed manifest beyond a basic size cap; rate-limit manifest registration server-side if you accept untrusted ones.

Boundary E: Malicious script -> Page

A page that includes attacker-controlled JS (XSS, supply chain compromise) is already lost. NAC3 cannot help here; the attacker can call NAC.click(...) directly. Mitigate via CSP, SRI, and your usual web security stack.

Provenance signals

is_trusted in success events

Every action success event detail carries is_trusted: boolean. A host can require this for sensitive verbs:

document.addEventListener('nac:action:succeeded', function (e) {
  var d = e.detail;
  if (d.action_id === 'invoice.delete' && !d.is_trusted) {
    console.warn('[security] refused synthetic delete click');
    e.preventDefault();
  }
});

The reference demo example-v20-full.php includes a button pair (v20_panel.istrusted_real and v20_panel.istrusted_fake) that demonstrates the distinction in the panel output.

HMAC manifest signing

Server-side, generate the signature:

import hmac, hashlib, json
manifest_body = json.dumps(manifest, sort_keys=True, separators=(',', ':'))
sig = hmac.new(
    tenant_secret.encode('utf-8'),
    manifest_body.encode('utf-8'),
    hashlib.sha256
).hexdigest()
manifest['provenance'] = {
    'signed_at': now_iso8601(),
    'signed_by': tenant_slug,
    'signature': sig
}

Client-side:

NAC.set_provenance_secret(SECRET_FROM_AUTHED_RESPONSE);
NAC.register(manifest);
// runtime recomputes hmac, rejects if mismatch

The secret MUST come from an authenticated server response; never embed it in JS source. Rotate per-session if the threat model demands it.

Reporting a vulnerability

Email nac@yujin.dev with:

  1. Description of the vulnerability.
  2. Reproduction steps or proof-of-concept.
  3. Affected NAC3 version(s).
  4. Suggested mitigation if you have one.

Do NOT open a public GitHub issue. We commit to:

Critical issues affecting the public spec ship with a patch release within 30 days; lower severity within 90.

What NAC3 explicitly does NOT do

page; if you want isolation, run separate pages).

tracked as v3.0 candidate.