Appearance
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
srcdoc, neversrc. The applet document is the verified bytes with the shell prelude and CSP injected outside the signed content. AsrcURL would give the applet an origin and a network path the shell does not mediate.sandbox="allow-scripts"only.mountAppletsets the attribute to exactlyallow-scripts, replacing any tokens the host set. Neverallow-same-origin, neverallow-top-navigation, neverallow-popups. The frame has an opaque origin and cannot reach the shell's storage or DOM.- The guard's CSP, unchanged by profile.
createAppletCsp(profile)is applied throughprepareNappletSrcdoc, withexperimental.nestedContextLockon, so kehto addsrequire-trusted-types-for 'script'; trusted-types defaultto the CSP meta it renders;createAppletCspreturns the same policy for every profile. No grant, profile or setting alters it. See Guard. - No controllers. On the attached, still empty frame, before any applet document exists,
mountAppletcalls kehto'sdenyNappletControllerAccess(frame), which rewrites the frame'sallowattribute (its Permissions Policy) to end ingamepad 'none'; hid 'none'; usb 'none'and drops any entry the host had set for those three features. A frame withoutgetAttributegets the same value asCONTROLLER_DENY_POLICY. This rule is about game controllers and USB or HID devices only. Workers, service workers included, are refused by the CSP'sworker-src 'none'(rule 3), not by this policy. - Order: attached empty frame → harden → register → set environment → observe frame → assign
srcdoc. The host attaches the iframe to the document first, with nosrcorsrcdoc(mountAppletthrowsinvalid:frame-detachedwithout acontentWindow). ThenmountAppletsetssandboxand the controller policy,originRegistry.register(contentWindow, windowId, identity), thenoriginRegistry.setEnvironment(contentWindow, environment), thenbridge.observeFrame(frame, windowId), thenframe.srcdoc = plan.srcdoc. Assigningsrcdocfirst would let the document's first message arrive before the shell knows who it is. - 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, asapps/web/src/pages/app/index.astrodoes. Applet frames still load, because their bytes arrive assrcdoc; 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. - Remove the iframe on every revocation reason.
window-message,second-hello,invalid-hello,second-load,port-closedandhostall 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. - No frame in the applet's markup.
planMountcalls kehto'sassertNappletHasNoFrameon the verified bytes before they become asrcdoc, and refuses markup containing aniframe,frame,frameset,object,embed,portalorfencedframeelement withdenied:nested-frame. kehto parses with the host'sDOMParserwhen there is one and otherwise matches lexically, which may over-reject (D-014). - The frame lock runs before applet code.
planMountpassesexperimental: { nestedContextLock: true }toprepareNappletSrcdoc(@kehto/shell0.30.0). kehto's prelude then installs the Trusted Typesdefaultpolicy, which refuses frame markup at every HTML string sink, and pinscreateElement,createElementNSandcreateDocumentso they refuse a frame tag (TypeError: kehto: napplets may not create nested browsing contexts). Because frames can also be minted without a factory,planMountinserts kernl'srenderInsertionLockScript()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 removesXSLTProcessor, so a frame never becomes connected and never gets a realm. When kehto's CSP meta and prelude are not whereplanMountexpects them, the mount is refused withunavailable:frame-lockrather than run without the lock. The insertion lock stays until kehto guards insertion too (D-014).
Identity rules
- Verified bytes come from
@kehto/nip/5dresolveNapplet. Gateways and caches are accelerators; the hash is the identity. Cached bytes are re-verified on every read. - Identity is
(dTag, artifactHash)plus one port. Grants are keyed by that identity; a new build is a new identity with no grants. - Restrictive by default and by narrowing. The runtime ACL is restrictive (
aclDefaultPolicy: 'restrictive', D-020): an applet holds only the capabilities the grants layer wrote for it.resolveEnvironmentreturns only granted domains; kehto intersects, so the hook can only remove.
Key rules
- 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 outsidePasswordAccount. - The shell never gives an applet
window.nostr. kehto's prelude installswindow.nappletand 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. - 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 toCURRENT_VERSION_TIMEOUT_MS(4 s), shows the change as a semantic diff throughhost.presentConsentand 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 withdenied:consentand nothing is signed or published. Both routes pass the gate:relay.publishthrough the signer kernl returns fromShellAdapter.auth.getSigner(windowId), andoutbox.publishin 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
- No applet network path exists outside the shell. All bytes arrive through the
resourcedomain 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 everyRTC*global (and thewebkit/mozvariants) toundefined, 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 refusesfetch, WebSocket, images, frame loads and beacons.tests/e2e/applet-network-sealed.spec.tsproves this in a real browser: 15 frame-creation routes throw, no child realm and no peer-connection constructor exists, and nosrflxorrelaycandidate 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 enforceshttpsonly, 10 MiB, 20 s and a private-address block over IPv4 and fully parsed IPv6 literals (Bridges). 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 withdenied: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 isunavailable:network Failed to fetch, and any other status-0 answer isunavailable:network <type> response. - One
RelayPool. No service, provider or bridge opens its own connection. Adapters over the shared pool satisfy kehto's contracts. - Hidden windows cannot act, but stay closable. Port traffic is decided before kehto's runtime sees it, through
filterNappletMessage(D-020). While a window is not visible every non-control envelope is held, up todeferQueue, and released withreleaseDeferredin 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. Aresource.*request past the hidden allowance quarantines the window, that request is not dispatched, and what the window held is discarded.
Log rules
- Secrets never appear in logs, guard events or consent summaries.
GuardEvent.reasonis a stable prefixed string;NappletRevocation.detailnever includes message payloads;describeChangeoutput names fields, not key material. - Guard events are shown to the user, with the applet and the reason. They are not sent anywhere.
Dependency rules
nostr-toolsis never imported by kernl source. It is a direct peer dependency of@kernlize/core, because kehto and applesauce require it, but no file underpackages/*/srcimports it.- Published packages have zero framework dependencies.
svelte,react,vue,preact,solid-jsandlitare forbidden in everypackages/*/package.json. - Leaf packages do not import
@kehto/*, except@kernlize/guard, which imports@kehto/firewall. Only@kernlize/corecomposes 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.