# Security posture

The rules the shell keeps when it runs untrusted code next to the user's identity. Each rule has a scenario in `packages/*/features/` or a check in `scripts/`; a rule with neither is a wish.

## Mount rules

1. **`srcdoc`, never `src`.** The applet document is the verified bytes with the shell prelude and CSP injected outside the signed content. A `src` URL would give the applet an origin and a network path the shell does not mediate.
2. **`sandbox="allow-scripts"` only.** `mountApplet` sets the attribute to exactly `allow-scripts`, replacing any tokens the host set. Never `allow-same-origin`, never `allow-top-navigation`, never `allow-popups`. The frame has an opaque origin and cannot reach the shell's storage or DOM.
3. **The guard's CSP, unchanged by profile.** `createAppletCsp(profile)` is applied through `prepareNappletSrcdoc`, with `experimental.nestedContextLock` on, so kehto adds `require-trusted-types-for 'script'; trusted-types default` to the CSP meta it renders; `createAppletCsp` returns the same policy for every profile. No grant, profile or setting alters it. See [Guard](https://kernl.run/docs/concepts/guard.md#why-csp-never-loosens).
4. **No controllers.** On the attached, still empty frame, before any applet document exists, `mountApplet` calls kehto's `denyNappletControllerAccess(frame)`, which rewrites the frame's `allow` attribute (its Permissions Policy) to end in `gamepad 'none'; hid 'none'; usb 'none'` and drops any entry the host had set for those three features. A frame without `getAttribute` gets the same value as `CONTROLLER_DENY_POLICY`. This rule is about game controllers and USB or HID devices only. Workers, service workers included, are refused by the CSP's `worker-src 'none'` (rule 3), not by this policy.
5. **Order: attached empty frame → harden → register → set environment → observe frame → assign `srcdoc`.** The host attaches the iframe to the document first, with no `src` or `srcdoc` (`mountApplet` throws `invalid:frame-detached` without a `contentWindow`). Then `mountApplet` sets `sandbox` and the controller policy, `originRegistry.register(contentWindow, windowId, identity)`, then `originRegistry.setEnvironment(contentWindow, environment)`, then `bridge.observeFrame(frame, windowId)`, then `frame.srcdoc = plan.srcdoc`. Assigning `srcdoc` first would let the document's first message arrive before the shell knows who it is.
6. **The host page forbids frame navigation.** The HTML the server sends carries `KERNL_FRAME_POLICY_META`, kehto's `<meta http-equiv="Content-Security-Policy" content="frame-src 'none'">`, in its `<head>` before any script runs, as `apps/web/src/pages/app/index.astro` does. Applet frames still load, because their bytes arrive as `srcdoc`; a frame that tries to load or navigate to a URL is blocked, so an applet cannot replace its verified document before kehto sees its first load. kehto warns in the console when the policy is missing. See [Embed kernl core](https://kernl.run/docs/how-tos/embed-kernl-core.md).
7. **Remove the iframe on every revocation reason.** `window-message`, `second-hello`, `invalid-hello`, `second-load`, `port-closed` and `host` all end with the frame gone and the guard's state for the window forgotten. A revoked window's identity is never re-bound to the same frame.
8. **No frame in the applet's markup.** `planMount` calls kehto's `assertNappletHasNoFrame` on the verified bytes before they become a `srcdoc`, and refuses markup containing an `iframe`, `frame`, `frameset`, `object`, `embed`, `portal` or `fencedframe` element with `denied:nested-frame`. kehto parses with the host's `DOMParser` when there is one and otherwise matches lexically, which may over-reject ([D-014](https://kernl.run/docs/decisions.md#d-014)).
9. **The frame lock runs before applet code.** `planMount` passes `experimental: { nestedContextLock: true }` to `prepareNappletSrcdoc` (`@kehto/shell` 0.30.0). kehto's prelude then installs the Trusted Types `default` policy, which refuses frame markup at every HTML string sink, and pins `createElement`, `createElementNS` and `createDocument` so they refuse a frame tag (`TypeError: kehto: napplets may not create nested browsing contexts`). Because frames can also be minted without a factory, `planMount` inserts kernl's `renderInsertionLockScript()` immediately after kehto's prelude: it pins every DOM insertion method to refuse a node that is or contains a frame element (`TypeError: denied:nested-frame`), refuses custom elements that extend frames, and removes `XSLTProcessor`, so a frame never becomes connected and never gets a realm. When kehto's CSP meta and prelude are not where `planMount` expects them, the mount is refused with `unavailable:frame-lock` rather than run without the lock. The insertion lock stays until kehto guards insertion too ([D-014](https://kernl.run/docs/decisions.md#d-014)).

## Identity rules

10. **Verified bytes come from `@kehto/nip/5d` `resolveNapplet`.** Gateways and caches are accelerators; the hash is the identity. Cached bytes are re-verified on every read.
11. **Identity is `(dTag, artifactHash)` plus one port.** Grants are keyed by that identity; a new build is a new identity with no grants.
12. **Restrictive by default and by narrowing.** The runtime ACL is restrictive (`aclDefaultPolicy: 'restrictive'`, [D-020](https://kernl.run/docs/decisions.md#d-020)): an applet holds only the capabilities the grants layer wrote for it. `resolveEnvironment` returns only granted domains; kehto intersects, so the hook can only remove.

## Key rules

13. **Keys live only inside applesauce signer instances.** `ExtensionSigner`, `NostrConnectSigner`, `PasswordAccount`'s wrapped signer. kernl never holds raw key bytes and never serialises a private key outside `PasswordAccount`.
14. **The shell never gives an applet `window.nostr`.** kehto's prelude installs `window.napplet` and nothing that signs; the shell does not forward the page's signer, and every signature an applet gets goes through rule 15. kernl does not control browser extensions: one that injects scripts into every frame can still reach into an applet's frame and expose its own signing object there, which no rule on this page governs. The guarantee is about what the shell provides, not about what the user's extensions add.
15. **Destructive signatures wait for the user.** An applet can ask for a signature only through a granted domain. When an applet window asks the shell to sign a destructive kind (every replaceable kind: 0, 3 and 10000–19999; every addressable kind, 30000–39999; a deletion, kind 5; a request to vanish, kind 62), `@kernlize/core`'s consent gate looks up the version it replaces, in the event store and then over the outbox and discovery relays for up to `CURRENT_VERSION_TIMEOUT_MS` (4 s), shows the change as a semantic diff through `host.presentConsent` and waits. When no current version is found, the summary says so first ("Current version: could not be loaded; this replaces it entirely") rather than reading as a harmless first version; a denial fails with `denied:consent` and nothing is signed or published. Both routes pass the gate: `relay.publish` through the signer kernl returns from `ShellAdapter.auth.getSigner(windowId)`, and `outbox.publish` in kernl's outbox service wrapper, because kehto's outbox service signs without naming the window. The shell's own signing, with no window, is not gated. Routine kinds are signed without a prompt, and answers are not remembered. kehto's runtime has no such step; this rule is kernl's.

## Network rules

16. **No applet network path exists outside the shell.** All bytes arrive through the `resource` domain and a bridge. An applet frame has no network of its own, and cannot make a child frame that would have one: kehto's prelude pins every `RTC*` global (and the `webkit`/`moz` variants) to `undefined`, read-only and non-configurable; rules 8 and 9 stop the applet from creating a child realm where those globals still work; and the CSP refuses `fetch`, WebSocket, images, frame loads and beacons. `tests/e2e/applet-network-sealed.spec.ts` proves this in a real browser: 15 frame-creation routes throw, no child realm and no peer-connection constructor exists, and no `srflx` or `relay` candidate is gathered. When the host page can reach a public STUN server, the spec asserts that it does and that the applet produces no such candidate; otherwise that control is skipped. The HTTP bridge enforces `https` only, 10 MiB, 20 s and a private-address block over IPv4 and fully parsed IPv6 literals ([Bridges](https://kernl.run/docs/concepts/bridges.md#the-http-bridge)). In Node and on servers it follows up to 3 redirects by hand and re-checks every hop; in a browser a redirect is opaque to it and is refused with `denied:redirect-unverifiable`. Hostnames are checked as literals only: no resolver is wired, so a public name that resolves to a private address is not caught. Browser fetches are also subject to CORS: a request the browser blocks is `unavailable:network Failed to fetch`, and any other status-0 answer is `unavailable:network <type> response`.
17. **One `RelayPool`.** No service, provider or bridge opens its own connection. Adapters over the shared pool satisfy kehto's contracts.
18. **Hidden windows cannot act, but stay closable.** Port traffic is decided before kehto's runtime sees it, through `filterNappletMessage` ([D-020](https://kernl.run/docs/decisions.md#d-020)). While a window is not visible every non-control envelope is held, up to `deferQueue`, and released with `releaseDeferred` in order once the window is visible; past the queue it is dropped. Control envelopes (`shell.*`, `inc.*`, `theme.*`, closes) are answered until the window holds a request, after which kehto queues them behind it; closing a window is the shell's action and never waits. A `resource.*` request past the hidden allowance quarantines the window, that request is not dispatched, and what the window held is discarded.

## Log rules

19. **Secrets never appear in logs, guard events or consent summaries.** `GuardEvent.reason` is a stable prefixed string; `NappletRevocation.detail` never includes message payloads; `describeChange` output names fields, not key material.
20. **Guard events are shown to the user**, with the applet and the reason. They are not sent anywhere.

## Dependency rules

21. **`nostr-tools` is never imported by kernl source.** It is a direct peer dependency of `@kernlize/core`, because kehto and applesauce require it, but no file under `packages/*/src` imports it.
22. **Published packages have zero framework dependencies.** `svelte`, `react`, `vue`, `preact`, `solid-js` and `lit` are forbidden in every `packages/*/package.json`.
23. **Leaf packages do not import `@kehto/*`**, except `@kernlize/guard`, which imports `@kehto/firewall`. Only `@kernlize/core` composes kehto.

Rules 21 to 23 are enforced by `scripts/check-dependency-rules.mjs` (`pnpm check:deps`). Rules 1 to 20 are scenarios in `packages/*/features/` (the root `features/support/` only wires their step definitions into vitest), and the mount rules are additionally exercised in the browser by the Playwright suite.

## Reporting

A posture violation is a bug of the highest severity in this repository, above any feature. Report it to the maintainers with the rule number; the source repository is private during the alpha. If it involves a kehto seam, the issue is filed upstream too, and `DECISIONS.md` records the gap.
