diff --git a/.changeset/kehto-seams-core.md b/.changeset/kehto-seams-core.md new file mode 100644 index 0000000..b82faab --- /dev/null +++ b/.changeset/kehto-seams-core.md @@ -0,0 +1,12 @@ +--- +"@kernl/core": minor +--- + +Adopt the host seams released in `@kehto/shell` 0.32.0, `@kehto/runtime` 0.28.0 and `@kehto/firewall` 0.7.0 (D-020). + +- Port traffic is decided through `ShellAdapter.filterNappletMessage`. `GuardPipeline.observe` now returns kehto's `NappletMessageDecision` (`dispatch`, `defer` or `drop`). Breaking: `refuseCurrentDispatch` is removed, and what the guard will not let through is dropped with no reply instead of refused with a `firewall:` error. +- A hidden window's non-control requests are held and released with `ShellBridge.releaseDeferred` when it becomes visible (`GuardPipelineOptions.onRelease`), instead of refused. The `deferred-overflow` revocation removes the frame like every other reason. +- The adapter sets `aclDefaultPolicy: 'restrictive'`; `KernlGrants.ensure` grants what the record needs and revokes only what the stored entry holds beyond it. +- Breaking: `ShellAdapterDeps` replaces `onNappletMessage` with `filterNappletMessage`, `getFocusContext` and `runtimeFirewall`. +- New `applyRuntimeFirewallProfile`, `createRuntimeFirewallHooks`, `toGuardEvent` and `RUNTIME_FIREWALL_STORAGE_KEY`: the runtime firewall gets the profile's unfocused multiplier and startup burst on boot and on every profile change, open windows get the new rates, runtime refusals become guard events without the applet's message, and the runtime firewall's configuration is kept at `kernl:firewall:v1`. +- Requires `@kehto/services` ^0.30.1, `@napplet/core` >=0.38.0 <0.39.0 and `@napplet/nap` >=0.37.0 <0.38.0. diff --git a/.changeset/kehto-seams-guard.md b/.changeset/kehto-seams-guard.md new file mode 100644 index 0000000..51c2494 --- /dev/null +++ b/.changeset/kehto-seams-guard.md @@ -0,0 +1,5 @@ +--- +"@kernl/guard": patch +--- + +Depend on `@kehto/firewall` ^0.7.0 and accept `@napplet/core` 0.38. The `hidden-deferral` mitigation now says a hidden applet's requests are held until it is visible, and `focus-tightening` is provided by both kehto and kernl, because the runtime firewall applies the unfocused multiplier too (D-020). diff --git a/.changeset/kehto-seams-messaging.md b/.changeset/kehto-seams-messaging.md new file mode 100644 index 0000000..d93da83 --- /dev/null +++ b/.changeset/kehto-seams-messaging.md @@ -0,0 +1,5 @@ +--- +"@kernl/messaging": patch +--- + +Accept `@napplet/core` 0.38 as a peer. diff --git a/.planning/HANDOFF.md b/.planning/HANDOFF.md index f83a9ac..893ec5f 100644 --- a/.planning/HANDOFF.md +++ b/.planning/HANDOFF.md @@ -80,3 +80,11 @@ All three agents are done. Ship: re-run `pnpm check` and `pnpm exec playwright t ## Shipped — 2026-11-04 Commit `c9cb0a7` on `main` (pushed over HTTPS with the gh login; SSH push was refused with "Permission denied (publickey)" — the owner should check their SSH key). Site republished. CI on GitHub: build/test/docs/gates success, browser success. Live amiagentready 90 (11 pass, 3 warn, 0 fail). Playwright against production 43/43. Nothing in flight. Re-verified at `e3519d4` (2026-11-04): `pnpm check` green (887/887, 100% coverage, agent-ready, vocabulary clean, slop 100); Playwright 43/43 locally and 43/43 against production; live amiagentready 90 (12 pass, 3 warn, 1 info, 0 fail); CI on `7cecea2` and `76be6dd` success; `pnpm site:publish` re-run (agent-ready 22/22, published https://kernlize.github.io/). + +## 2026-11-04 (later): kehto 0.32 adoption, kernl.run, kehto #336 + +- **kehto seams adopted** on branch `feat/kehto-0-32`: `@kehto/shell` 0.32.0, `@kehto/runtime` 0.28.0, `@kehto/firewall` 0.7.0 (kehto/web #404, released in #409; issues #397–#400 closed). `filterNappletMessage` replaces `refuseCurrentDispatch`; hidden windows' requests are held and released on visibility; `aclDefaultPolicy: 'restrictive'`; focus and profile budgets reach the runtime. D-020. Behaviour changes: dropped requests get no reply (spec gap), strict startup burst is 8 ops in the first second. +- **MUST TRACK kehto/web #336** (`feat/firewall-approvals`): Firewall becomes the shared approval authority; rate limits move to `runtime.permissions.setRateLimit`/`onRateLimit`; `setUnfocusedMultiplier`/`setBurstGuard`/`onFirewallEvent`/`firewallPersistence` become legacy-only on configured hosts. All of kernl's uses sit in `packages/core/src/guard/runtime-rules.ts`. When it merges and releases, migrate that file, drop `kernl:firewall:v1`, decide how admission approvals meet the restrictive ACL, update D-020 and the roadmap. +- **kernl.run** on bunny.net: PR #2 merged (`47c13b7`). Site `kernl` exists, secrets set by `scripts/setup-bunny-secrets.sh`. Owner: point the `kernl.run` apex (Porkbun) at `sites-kernl-0bmwsx.b-cdn.net` with ALIAS, then `bunny sites domains ssl kernl.run kernl`. Follow-up: port napplet's `edge/` Markdown-negotiation middleware (expected amiagentready 90 → 92). +- **Release:** `@kernl/*` 0.2.0 versioned on `main` (PR #1); not on npm until a `v*` tag or a manual `release.yml` run. +- **Open from the kehto adoption:** offline preview's guard never holds (only quarantines); README guard screenshot and alt text show the old behaviour; `packages/core/features/steps/kernl.ts` is 652 lines (over the 600 guideline, not flagged by the slop gate). diff --git a/DECISIONS.md b/DECISIONS.md index 37ac298..2e0d202 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -19,6 +19,8 @@ Format: `D-NNN · title` → Context, Decision, Consequences, Reversal cost. **Reversal cost.** Low. Both local layers sit behind interfaces in `@kernl/guard` and `@kernl/core` and can be deleted when upstream provides the hook. +**Superseded in part by D-020.** All four gaps are closed in `@kehto/shell` 0.32.0, `@kehto/runtime` 0.28.0 and `@kehto/firewall` 0.7.0. The restrictive posture now comes from `aclDefaultPolicy`, and the runtime firewall receives focus, the profile's multiplier and burst guard, and an event sink. The focus-aware firewall in `@kernl/guard` stays, for the reasons D-020 gives. + ## D-002 · applesauce v7 is the only event layer **Context.** kehto's reference runtime (Paja) uses `nostr-tools` `SimplePool`. kernl's first opinion is that applesauce is the event layer. @@ -41,6 +43,8 @@ Format: `D-NNN · title` → Context, Decision, Consequences, Reversal cost. **Reversal cost.** A one-line profile change for a user; a `SOUL.md` amendment for the project. +**Superseded in part by D-020.** A profile change now reinstalls the runtime rates of every open window and sets the runtime's unfocused multiplier and startup burst guard, so open windows no longer keep the rates they were mounted with. + ## D-004 · Four packages, one app, one docs site **Context.** The repository needs a shape that lets docs, tests and publishing stay per-package without forcing the shell UI into a framework-free constraint. @@ -148,6 +152,8 @@ The window-level `hello` is the one message kernl can hold or drop before kehto **Reversal cost.** Low; the pipeline sits behind `createGuardPipeline` and shrinks when kehto exposes a hook that can hold or drop an envelope. `refuseCurrentDispatch` goes when kehto offers that hook and its runtime can measure the envelopes and focus it cannot see today. +**Superseded in part by D-020.** kehto now offers that hook. Port traffic is decided by `ShellAdapter.filterNappletMessage`: what kernl's guard will not let through is dropped rather than refused, a hidden window's requests are held and released when it is visible, and `refuseCurrentDispatch` is deleted. The runtime firewall now applies the unfocused multiplier and reports its refusals to the guard log. The size, burst, focus and quarantine layers described above remain kernl's. + ## D-013 · The site is served at a domain root **Context.** D-007 sets an agent-readiness score of 84 or higher. Measured with amiagentready on 2026-11-03, the scanner works on the **origin**: it scores the domain's homepage whatever path it is given, and reads `robots.txt` and `llms.txt` from the domain root. The scoring is reproducible from its response: each scored check contributes its weight, pass at full, warn at half, fail at zero. @@ -254,3 +260,24 @@ NZB, Soulseek and mail stay hand-off. Running them inside kernl needs something **Consequences.** Without the secret the workflow builds, audits and stops with a notice, as before. Rolling back is `bunny sites deployments publish --previous --force`. The account API key in the secret can manage every bunny.net resource on the account, not only this site; bunny.net offers no narrower key for site deploys. The pull zone does not yet answer `Accept: text/markdown` (D-013's 92 needs a middleware Edge Script implementing `scripts/serve-pages.mjs`'s rule), so the expected score at kernl.run is 90 until then. `kernlize.github.io` is no longer updated and can be retired. **Reversal cost.** Low; only deployment configuration and the default origin. + +## D-020 · kehto's host seams replace kernl's workarounds; kernl's guard still decides first + +**Context.** D-001 recorded four kehto gaps (kehto/web #397, #398, #399, #400) and D-012 built kernl's enforcement around them. kehto/web PR #409 closed all four. `@kehto/shell` 0.32.0 forwards `getFocusContext`, `onFirewallEvent` and `firewallPersistence` through `ShellAdapter`, takes `aclDefaultPolicy`, and adds `filterNappletMessage`, which answers `dispatch`, `drop` or `defer` for each port envelope, with `ShellBridge.releaseDeferred(windowId)` and a new revocation reason, `deferred-overflow`, for a window holding more than `MAX_DEFERRED_MESSAGES` (1024). `@kehto/runtime` 0.28.0 lets a policy the host sets replace the stored one on load. `@kehto/firewall` 0.7.0 adds `setUnfocusedMultiplier` and `setBurstGuard`, also on `FirewallStateContainer`. A second change is pending: [kehto/web#336](https://github.com/kehto/web/pull/336) makes the firewall a shared approval authority and moves rate limiting into a separate runtime budget. On a host that configures `permissions` it ignores `setUnfocusedMultiplier`, `setBurstGuard`, `onFirewallEvent` and `firewallPersistence`, reads only `focused` from `getFocusContext`, and keeps `aclDefaultPolicy`, `filterNappletMessage` and `releaseDeferred` unchanged. + +**Decision.** kernl depends on the released packages: `@kehto/shell` ^0.32.0, `@kehto/runtime` ^0.28.0, `@kehto/firewall` ^0.7.0, `@kehto/acl` ^0.20.3 and `@kehto/services` ^0.30.1, which requires `@napplet/core` 0.38 and `@napplet/nap` 0.37. It builds first on the seams that survive #336: + +- **Port traffic is decided before the runtime sees it.** `createShellAdapter` passes `filterNappletMessage`, which runs `GuardPipeline.observe` and returns kehto's decision. `refuseCurrentDispatch` and kernl's use of `onNappletMessage` are deleted. What kernl's guard will not let through is dropped: an oversized download or query, a burst above the per-second rate, a request over the focus firewall's budget, and the envelope that triggers a quarantine. A dropped request gets no reply, which is kehto's documented spec gap (NAP defines no answer for a request a host drops); before, the applet got a `firewall:` error. +- **Hidden windows are held.** A non-control envelope from a window that is not visible is deferred, reported as a `defer` guard event `hidden:`, and counted against `deferQueue`; past it, it is dropped with `defer:queue-full`. When the window becomes visible the pipeline calls `onRelease`, and kernl calls `bridge.releaseDeferred(windowId)`; kehto dispatches the held envelopes in order through the ACL and the runtime firewall. A hidden download past `hiddenResourceAllowance` still quarantines, and kehto discards what the window held. `deferQueue` (200 in `strict`, 600 in `relaxed`) is below `MAX_DEFERRED_MESSAGES`, so kernl drops before kehto would revoke; `deferred-overflow` is still handled the way every revocation is, by removing the frame. kehto queues every later envelope behind held ones, so once a hidden window holds anything its control requests wait too. +- **The ACL is restrictive.** The adapter sets `aclDefaultPolicy: 'restrictive'`. `KernlGrants.ensure` grants the baseline and what the user allowed, and revokes only what the applet's stored entry holds beyond that (a domain the user took back, or an entry kept from the permissive posture), instead of writing every capability. The environment resolver still narrows what is advertised. Nothing is granted until the user grants it. +- **The runtime firewall knows focus and the profile.** `getFocusContext` returns `{ focused }` from the pipeline's visibility; it does not send `msSinceFocusGain`, which #336 ignores. On boot and on every profile change, `applyRuntimeFirewallProfile` sets the profile's `unfocusedMultiplier` and `burstGuard` (`{ windowMs: 1000, maxOps: ingressBurst, action: 'block' }`) and kernl reinstalls the rates of every open window. The runtime's startup burst is now the profile's, 8 operations in a window's first second in `strict`, instead of kehto's default of 20 in 3 s. +- **Runtime refusals reach the guard log without the message.** `createRuntimeFirewallHooks` turns each `onFirewallEvent` into a `GuardEvent` (`rate:`, `flood:startup`, or `runtime:`) and drops the applet message the runtime attaches, so nothing the applet sent reaches the log. It keeps the runtime firewall's configuration under `kernl:firewall:v1` in the host's storage. These report only what the runtime itself refuses: the startup burst, and held envelopes over budget at release. Everything kernl decides is reported by kernl's own layers, so the log does not depend on these hooks. +- **The seams #336 retires live in one module.** Every call into the runtime firewall's configuration (`setRateLimit`, `setGlobalRate`, `setUnfocusedMultiplier`, `setBurstGuard`, `persist`) and both hooks are in `packages/core/src/guard/runtime-rules.ts`. + +**What kernl still puts in front of kehto, and why.** The ingress guard, the visibility gate and the focus firewall stay. kehto's runtime measures only an envelope's `event`, so size caps on downloads and queries are kernl's. It knows nothing of visibility, so holding hidden windows and the hidden download allowance are kernl's. Flood quarantine is kernl's. The window-level `hello` still passes the pipeline before `bridge.handleMessage`. The focus firewall now overlaps the runtime, which applies the same rates and multiplier, and it stays for three reasons: it has a separate budget for deletions, which the runtime counts as `relay:write`; its matchers can use `msSinceFocusGain`, which #336's budgets ignore; and once #336 lands a configured host gets no runtime event for a stored deny and only `onRateLimit` for budgets, so kernl's own decisions must stay a complete source of guard events. + +**When kehto/web#336 lands.** In `runtime-rules.ts`, `applyRuntimeFirewallRules` and `applyRuntimeFirewallProfile` become one call to `runtime.permissions.setRateLimit`, built from the current `rateLimit`; `onFirewallEvent` becomes `onRateLimit`; `firewallPersistence` and `kernl:firewall:v1` go. `getFocusContext`, `aclDefaultPolicy`, `filterNappletMessage` and `releaseDeferred` stay as they are. kernl will also have to decide how admission approvals meet the restrictive ACL, which #336 treats as a ceiling. + +**Consequences.** An applet whose request kernl drops waits for a reply that does not come, and has to time out. Because the focus firewall counts the same rates before the runtime does, a request over a class budget is normally dropped by kernl rather than refused by the runtime with a `firewall:` error; the runtime's own refusals are met mostly in a window's first second and for held requests released over budget. A hidden applet no longer has to retry. Held requests meet the runtime firewall at release, at the window's focus at that moment. A real applet that sends more than 8 operations, control included, in its first second under `strict` has the excess refused by the runtime with `firewall:` errors. `@kernl/core` drops `refuseCurrentDispatch` and changes `GuardPipeline.observe` and `ShellAdapterDeps`, which is a minor release on 0.x. + +**Reversal cost.** Low. The decisions sit behind `GuardPipeline.observe` and `createShellAdapter`; the runtime firewall calls sit in one module. diff --git a/README.md b/README.md index 84edbc3..ed3b55c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The full reasoning is in [`SOUL.md`](./SOUL.md). Each concrete choice is in [`DE - **Runs applets in a sealed frame.** Verified bytes run in a `srcdoc` frame with `sandbox="allow-scripts"`, a zero-network CSP, no WebRTC and a lock against frames of their own ([D-014](./DECISIONS.md)). A browser spec tries every way out; when the host page can reach a public STUN server, it asserts that it does and that the applet produces no such candidate ([Security posture](./docs/policies/security-posture.md)). - **Shares your account only when you say so.** An applet is not told which account is signed in until you grant "your account", and every network domain is a separate grant, keyed to the verified build ([Identity and grants](./docs/concepts/identity-and-grants.md)). - **Asks before it signs anything destructive.** A write that replaces or erases part of your public record is shown as before → after and waits for your answer; routine writes are signed without a prompt ([Consent](./docs/concepts/identity-and-grants.md#consent-for-destructive-writes)). -- **Guards by default.** Eleven protections ship on in the strict profile: kehto's runtime firewall refuses over-rate requests before dispatch, kernl has it refuse oversized requests, bursts, requests over the smaller budget of an applet you are not using and anything but control messages from a hidden applet, and kernl quarantines an applet that floods ([Guard](./docs/concepts/guard.md)). The hosted shell boots strict and only you loosen it; an embedding host may choose the starting profile, which changes limits only. +- **Guards by default.** Eleven protections ship on in the strict profile: kernl drops oversized requests, bursts and requests over the smaller budget of an applet you are not using before they are handled, holds a hidden applet's requests until you can see it, and quarantines an applet that floods; kehto's runtime firewall refuses over-rate requests on top ([Guard](./docs/concepts/guard.md)). The hosted shell boots strict and only you loosen it; an embedding host may choose the starting profile, which changes limits only. - **Messages across providers.** One messenger over several private-message providers, each described by its properties and none ranked. Private, legacy and ratchet messages work in the live shell when you sign in with an account whose signer can encrypt (browser extension, remote signer, local account). Ratchet sessions are experimental: your correspondent needs an app that speaks them, and the session keys are kept on this device, locked by your account ([Alpha status](./docs/alpha-status.md#messaging-providers-in-the-live-shell)). Group sessions are experimental too: everyone in the group needs an app that speaks them, and the group's keys change as it talks and as people join or leave ([Messaging](./docs/concepts/messaging.md#protocol-inventory)). - **Bridges other networks.** Web fetch works behind an SSRF policy (`https` only, private addresses blocked, redirects refused in the browser). Magnet links download inside the shell page over WebRTC, with a Downloads panel to pause, resume, remove and save, or go to an app on your device if you choose that in Settings ([D-017](./DECISIONS.md)). NZB downloads, sharing and mail hand the link to an app on your device after you confirm leaving kernl, because a browser page cannot open the raw TCP sockets their protocols need; kernl connects to nothing for them ([Bridges](./docs/concepts/bridges.md#hand-off-backends)). - **Previews without an account.** `/app/?preview=1` runs the shell offline with the core's own consent rule, kernl's guard in strict and two sample applets. Refusals before dispatch need kehto's runtime, so they happen only in the live shell ([Run the shell](./docs/tutorials/run-the-shell.md)). @@ -140,7 +140,7 @@ Feature: Visibility gate # … seven more scenarios: visible windows, draining, the full queue, forgetting a window ``` -The scenarios specify the gate's own API. In the composed shell the gate holds only an applet's first window-level message; the rest of its traffic travels over a port kehto owns and is observed just before dispatch, without being held. +The scenarios specify the gate's own API. In the composed shell an applet's traffic travels over a port kehto owns; kernl's pipeline decides each envelope before the runtime sees it, and kehto holds a hidden window's requests until kernl releases them. Features live in `packages/*/features/` and run inside vitest through [quickpickle](https://github.com/dnotes/quickpickle). Coverage thresholds are 100% across statements, branches, functions and lines. @@ -169,7 +169,7 @@ The docs are a product. Start at [`docs/index.md`](./docs/index.md), or on the s ## Relationship to the ecosystem -- [kehto](https://github.com/kehto/web) supplies the runtime, shell adapter, services, ACL, firewall and NIP-5D verification. kernl composes them and records upstream gaps filed upstream as kehto/web #397–#400 instead of forking; see [`DECISIONS.md`](./DECISIONS.md) D-001. +- [kehto](https://github.com/kehto/web) supplies the runtime, shell adapter, services, ACL, firewall and NIP-5D verification. kernl composes them and takes gaps upstream instead of forking; the first four (kehto/web #397–#400) are released and in use. See [`DECISIONS.md`](./DECISIONS.md) D-001 and D-020. - The applet format is [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) with per-domain [NAPs](https://github.com/napplet/naps). Applet-side packages and the format's own site live at [napplet.run](https://napplet.run). - [applesauce](https://applesauce.build) is the event layer. diff --git a/SOUL.md b/SOUL.md index bf60ca1..034c1a1 100644 --- a/SOUL.md +++ b/SOUL.md @@ -42,8 +42,8 @@ If kehto ships an experimental privacy or security mitigation, kernl enables it Concretely: -- The behavioural firewall ships enabled, in its strict profile. Focus-aware tightening is refused: an applet you are not using that goes over its smaller budget is turned away before its request is handled (D-012). -- Hidden and minimised applets cannot act: their requests are refused until you can see them, though you can still close or theme them, and one that keeps downloading past its allowance is closed. (Holding their requests instead of refusing them needs a hook kehto does not yet offer; see D-012.) +- The behavioural firewall ships enabled, in its strict profile. Focus-aware tightening is enforced: an applet you are not using that goes over its smaller budget is turned away before its request is handled (D-012, D-020). +- Hidden and minimised applets cannot act: their requests are held until you can see them, though you can still close them, and one that keeps downloading past its allowance is closed (D-020). - Replay and cross-window identity checks are on. - Artifact bytes are cached only after hash verification, and re-verified on every read. - In the hosted shell, only the user loosens a mitigation, never a developer flag. Today that is one shell-wide profile; per-applet loosening is planned (D-003, roadmap). An embedding host may choose the starting profile, which changes limits only; the sandbox, the frame lock and consent are fixed. diff --git a/apps/web/package.json b/apps/web/package.json index 4e812d1..fd27971 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "check": "astro check" }, "dependencies": { - "@kehto/shell": "^0.30.0", + "@kehto/shell": "^0.32.0", "@kernl/bridges": "workspace:^", "@kernl/core": "workspace:^", "@kernl/guard": "workspace:^", diff --git a/apps/web/src/pages/guard/index.astro b/apps/web/src/pages/guard/index.astro index 47b7c03..31159b2 100644 --- a/apps/web/src/pages/guard/index.astro +++ b/apps/web/src/pages/guard/index.astro @@ -30,7 +30,7 @@ const description =
  • Old or repeated posts are not sent

    When the shell posts for an applet, it refuses a post it has already sent in the last half minute, one dated too far in the past, or one dated in the future. A message from a window other than the applet's own is ignored.

  • Behavioural rate limits

    Reading, publishing, messaging, storage and downloads each have an allowance per applet, set before it starts. A request over its allowance is refused until the allowance refills. Deletions count against publishing, and are also counted on their own so you can see an applet that deletes a lot.

  • Focus-aware tightening

    An applet you are not looking at is measured against a smaller budget. A request over it is refused before it is handled, and the guard tells you; the applet can try again once you switch to it. The budget above still applies. The shell decides what is focused; the applet is never asked.

  • -
  • Hidden applets cannot act until you can see them

    While an applet is minimised or hidden, the shell refuses its requests before they are handled, except the messages that let you close, theme or reach it; the applet can try again once it is visible. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, and the shell closes it and tells you.

  • +
  • Hidden applets cannot act until you can see them

    While an applet is minimised or hidden, the shell holds its requests and handles them, in order, once you can see it again; you can still close it. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, the requests it held are discarded, and the shell closes it and tells you.

  • Flood control and quarantine

    The shell counts every message an applet sends. Requests in a burst above the per-second rate are refused before they are handled, and the shell tells you; messages that keep the window controllable still get through. One that keeps flooding is quarantined: the message that crossed the limit is dropped, and the shell closes the applet and tells you. You can open it again when you choose.

  • Oversized requests are refused

    Requests for files and long-running searches are measured against a maximum size. One that is too large is refused before it is handled, so nothing it asked for is fetched, and the shell tells you.

  • Device permissions denied

    Camera, microphone, location and the rest are off for every applet. There is no prompt to be tricked into.

  • @@ -45,7 +45,7 @@ const description =

    What you see

    What it reports, it explains.

    -

    The guard records what it reports, refuses or quarantines, each with a stable reason you can read. Some refusals made inside the runtime that runs applets are not shown yet; they will be listed as soon as the runtime reports them.

    +

    The guard records what it holds, refuses or quarantines, each with a stable reason you can read, including refusals made inside the runtime that runs applets. What an applet sent is never part of the record.

    Loosening it

    diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 153f845..0577738 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -117,7 +117,7 @@ const pages = [{ path: '', label: 'Home' }, ...NAV].map((page) => ({

    Watched

    A network it reaches through you

    -

    Everything an applet sends or fetches goes through the shell. The guard shows you what it reports, refuses or quarantines; some refusals made deeper inside the shell are not listed yet. You can block an applet, take back any grant, or make the guard stricter for the whole shell, at any time.

    +

    Everything an applet sends or fetches goes through the shell. The guard shows you what it holds, refuses or quarantines, including refusals made deeper inside the shell. You can block an applet, take back any grant, or make the guard stricter for the whole shell, at any time.

    diff --git a/apps/web/src/pages/why/index.astro b/apps/web/src/pages/why/index.astro index 9219c45..530b94d 100644 --- a/apps/web/src/pages/why/index.astro +++ b/apps/web/src/pages/why/index.astro @@ -58,7 +58,7 @@ const description =

    When the toolkit offers a privacy or safety mitigation, kernl turns it on, in its strict setting, and documents how to ease it. You matter more than the applet developer does. Within reason: an applet interface so tight that nothing can be written protects nobody.

    • The guard ships strict, with focus-aware tightening.
    • -
    • Hidden and minimised applets are measured against a smaller budget, and one that keeps downloading while hidden is stopped.
    • +
    • Hidden and minimised applets wait until you can see them, and one that keeps downloading while hidden is stopped.
    • A protection is loosened by you, never by a developer or a flag. Today that choice covers the whole shell until you reload; loosening one applet at a time is planned.
    diff --git a/apps/web/src/shell/GuardPanel.svelte b/apps/web/src/shell/GuardPanel.svelte index 11fe18a..74c9fe5 100644 --- a/apps/web/src/shell/GuardPanel.svelte +++ b/apps/web/src/shell/GuardPanel.svelte @@ -70,7 +70,7 @@ {/each} {:else} -

    Nothing yet. The guard records what it flags, refuses or quarantines here. Some refusals made inside the runtime are not shown yet.

    +

    Nothing yet. The guard records what it holds, flags, refuses or quarantines here.

    {/if} diff --git a/apps/web/src/shell/guard-log.svelte.ts b/apps/web/src/shell/guard-log.svelte.ts index b901cd6..19d19b4 100644 --- a/apps/web/src/shell/guard-log.svelte.ts +++ b/apps/web/src/shell/guard-log.svelte.ts @@ -28,6 +28,7 @@ const VERBS: Readonly> = { function because(event: GuardEvent): string { const { reason, opClass } = event; if (reason === 'flood:burst') return 'a burst of messages'; + if (reason === 'flood:startup') return 'a burst of messages as it started'; if (reason === 'flood:queue-full' || reason === 'defer:queue-full') return 'its queue is full'; if (reason === 'flood:quarantine') return 'it kept flooding the shell'; if (reason === 'flood:hidden-resource') return 'too many web fetches while hidden'; @@ -37,8 +38,9 @@ function because(event: GuardEvent): string { return reason; } -/** "Applet Notes went over its allowance: too many network reads". kernl reports these; the rates that refuse requests run inside the runtime. */ +/** "Applet Notes went over a limit: too many network reads". A hidden applet's held requests read as waiting, not as a limit. */ export function describeGuardEvent(event: GuardEvent, title: string): string { + if (event.reason.startsWith('hidden:')) return `Applet ${title} is waiting until you can see it: its ${OP_WORDS[event.opClass]} are held`; return `Applet ${title} ${VERBS[event.disposition]}: ${because(event)}`; } diff --git a/apps/web/src/shell/preview-kernl.ts b/apps/web/src/shell/preview-kernl.ts index 93b00e2..236a3d6 100644 --- a/apps/web/src/shell/preview-kernl.ts +++ b/apps/web/src/shell/preview-kernl.ts @@ -359,8 +359,8 @@ export function createPreviewKernl(host: KernlHost, torrent?: TorrentBackend): S if (!entry || !isPreviewMessage(event.data)) return; const envelope = ENVELOPES[event.data.kernlPreview] ?? { type: 'generic' }; const visibility = host.getVisibility(entry.plan.windowId); - // Observe-only, as the live shell is for applet traffic (D-012): the guard records what it - // sees and holds nothing back; only a quarantine stops the applet. + // The preview's sample applets talk over window messages, not a bound port, so its guard + // records what it sees and holds nothing back; only a quarantine stops the applet (D-011). const disposition = guard.ingress().admit(entry.plan.windowId, entry.plan.summary.identity.dTag, envelope, visibility); if (disposition === 'quarantine') entry.revoke('quarantine'); else perform(entry, event.data); diff --git a/docs/alpha-status.md b/docs/alpha-status.md index d2a9b53..29a1ac0 100644 --- a/docs/alpha-status.md +++ b/docs/alpha-status.md @@ -45,12 +45,16 @@ The [Roadmap](./roadmap.md) orders this list. ## Upstream gaps -kernl composes kehto rather than forking it ([D-001](./decisions.md#d-001)). Four seams in kehto are narrower than kernl wants. All four are filed upstream against `kehto/web` rather than patched locally ([#397](https://github.com/kehto/web/issues/397), [#398](https://github.com/kehto/web/issues/398), [#399](https://github.com/kehto/web/issues/399), [#400](https://github.com/kehto/web/issues/400)). Each has a kernl layer that shrinks when it lands ([D-012](./decisions.md#d-012)): +kernl composes kehto rather than forking it ([D-001](./decisions.md#d-001)). The four seams kernl filed against `kehto/web` ([#397](https://github.com/kehto/web/issues/397), [#398](https://github.com/kehto/web/issues/398), [#399](https://github.com/kehto/web/issues/399), [#400](https://github.com/kehto/web/issues/400)) are closed in `@kehto/shell` 0.32.0, `@kehto/runtime` 0.28.0 and `@kehto/firewall` 0.7.0, and kernl uses them ([D-020](./decisions.md#d-020)): -1. **`ShellAdapter` does not forward `getFocusContext`, `onFirewallEvent` or `firewallPersistence` to the runtime.** Without `getFocusContext` the runtime firewall treats every window as focused. kernl runs its focus-aware firewall in `@kernl/guard`, in front of the bridge for the window-level `hello` and as an observer of port traffic, using the pure `evaluate()` engine with visibility supplied by the host; what it rejects on port traffic is refused before dispatch (gap 3). Because that layer never supplies `initElapsedMs`, kehto's init-burst guard does not fire there, and the guard config's `burstGuard` is unused. The runtime does supply it, so the runtime's own startup burst guard (kehto's default, 20 operations in the first 3 s, `block`) refuses before dispatch. Without `onFirewallEvent` the runtime's refusals reach the applet as `firewall:` errors but not the host, so the guard panel shows kernl's own count of the same rates. ([kehto/web#397](https://github.com/kehto/web/issues/397)) -2. **`createRuntime` hardcodes the ACL default policy to `'permissive'`.** kernl produces its restrictive posture with `KernlGrants.ensure`, which writes an explicit grant or revoke for every capability of every applet it plans, plus `capabilities.resolveEnvironment`, which narrows the advertised domains to the baseline and the user's grants. The permissive default is never reached for an applet kernl runs. ([kehto/web#398](https://github.com/kehto/web/issues/398)) -3. **There is no hook that can hold or delay port traffic.** kehto calls `onNappletMessage` synchronously before it dispatches each envelope, but ignores its answer. kernl closes the refusal half of this gap itself: whenever its ingress guard, focus firewall or hidden-window check will not let an envelope through (a size cap, a burst above the per-second rate, the smaller unfocused budget, or a non-control request from a window that is not visible), it sets the applet's runtime policy to `deny` for that one synchronous dispatch (`refuseCurrentDispatch`), so kehto refuses the envelope with a `firewall:` error. Quarantine revokes the binding inside the hook, so kehto finds no session and drops the envelope that crossed the limit. Control envelopes are never refused. What remains is the gentler half: a hidden window's requests are refused, and the applet must retry once visible, where a hook that let the shell defer them would hold them instead. See [Guard](./concepts/guard.md#where-enforcement-happens). ([kehto/web#399](https://github.com/kehto/web/issues/399)) -4. **The runtime firewall never applies an unfocused multiplier.** Because of gap 1 the runtime sees every window as focused, so its multiplier, whatever the value, never applies; and `FirewallStateContainer` has no setter for `unfocusedMultiplier`, so kernl could not install the profile's value even if focus were forwarded. The profile's value is applied only by kernl's focus firewall, whose overruns kernl refuses through `refuseCurrentDispatch`. ([kehto/web#400](https://github.com/kehto/web/issues/400)) +1. **Firewall hooks through `ShellAdapter` (#397).** kernl passes `getFocusContext` (only `focused`), `onFirewallEvent`, which feeds the guard log without the applet's message, and `firewallPersistence`, which keeps the runtime firewall's configuration under `kernl:firewall:v1` in the host storage. +2. **ACL default policy (#398).** The adapter sets `aclDefaultPolicy: 'restrictive'`. `KernlGrants.ensure` grants the baseline and the user's domains and revokes only what an applet's stored entry holds beyond them. +3. **A hook that can hold or drop port traffic (#399).** `filterNappletMessage` runs kernl's guard pipeline for every port envelope. What the guard will not let through is dropped, with no reply to the applet. A hidden window's requests are held and released with `releaseDeferred` when it is visible. See [Guard](./concepts/guard.md#where-enforcement-happens). +4. **Unfocused multiplier and burst guard setters (#400).** On boot and on every profile change kernl sets the profile's `unfocusedMultiplier` and `burstGuard` on the runtime firewall and reinstalls the rates of open windows. + +kernl's own layers stay in front of kehto where kehto cannot see what they see: envelope size for downloads and queries, window visibility, a separate deletion budget, and flood quarantine. The focus-aware firewall overlaps the runtime now and stays so that kernl's own decisions remain a complete source of guard events ([D-020](./decisions.md#d-020)). + +One change upstream is pending. [kehto/web#336](https://github.com/kehto/web/pull/336) makes kehto's firewall a shared approval authority and moves rate limiting into a separate runtime budget. On a host that configures it, the multiplier and burst setters, `onFirewallEvent` and `firewallPersistence` are ignored, and `getFocusContext` is read for `focused` only. kernl keeps every use of those in one module, `packages/core/src/guard/runtime-rules.ts`, and will move it to `permissions.setRateLimit` and `onRateLimit` when #336 is released. `aclDefaultPolicy`, `filterNappletMessage` and `releaseDeferred` are unchanged by #336. A fifth observation, not a gap kernl needs closed: the transport internals `createNip5dTransport` and `createShellReadyState` are not exported, so kernl uses `createShellBridge` unchanged. @@ -60,20 +64,22 @@ One mitigation goes beyond kehto: `@kehto/shell` 0.30.0 ships `experimental.nest - **Rate enforcement (corrected 2026-11-03).** Earlier drafts of these docs, the README and D-012 said kehto's runtime firewall refused envelopes over the profile's rates. It did not: `applyRuntimeFirewallRules` also set each applet's runtime policy to `allow`, and kehto's `evaluate()` returns `pass` for an `allow` policy before it looks at any rate, matcher or burst. No rate was refused before dispatch; only kernl's reports and quarantine applied. The policy call is removed, the rates and kehto's startup burst guard now refuse before dispatch, and D-012 is rewritten to match. - **Size caps (corrected 2026-11-03).** The guard's size matchers were described as able to report `matcher:size:` in the shell. They cannot fire there: the ingress guard rejects the same envelopes first. Oversized downloads and queries were reported but still handled; they are now refused before dispatch. -- **Focus tightening, bursts and hidden windows (changed 2026-11-04).** These were reported to the user but still handled. They are now refused before dispatch the same way size caps are (`refuseCurrentDispatch`): a request over the smaller unfocused budget, a request in a burst above the per-second rate, and any non-control request from a hidden window get a `firewall:` error. Control envelopes are never refused. See [D-012](./decisions.md#d-012). +- **Focus tightening, bursts and hidden windows (changed 2026-11-04).** These were reported to the user but still handled. They were then refused before dispatch the same way size caps were (`refuseCurrentDispatch`): a request over the smaller unfocused budget, a request in a burst above the per-second rate, and any non-control request from a hidden window got a `firewall:` error. Control envelopes were never refused. See [D-012](./decisions.md#d-012). +- **Dropped and held, not refused (changed with kehto 0.32.0).** kernl now decides each port envelope through kehto's `filterNappletMessage`. Oversized requests, bursts and requests over the unfocused budget are dropped and get no reply; a hidden window's requests are held and answered once it is visible. The runtime's startup burst is the profile's (8 operations in a window's first second in `strict`) rather than kehto's default of 20 in 3 s. See [D-020](./decisions.md#d-020). ## Versions this draft is built against | Dependency | Range | | --- | --- | -| `@kehto/acl` | `^0.20.2` | -| `@kehto/firewall` | `^0.6.3` | -| `@kehto/runtime` | `^0.27.2` | -| `@kehto/shell` | `^0.30.0` | -| `@kehto/services` | `^0.29.0` | +| `@kehto/acl` | `^0.20.3` | +| `@kehto/firewall` | `^0.7.0` | +| `@kehto/runtime` | `^0.28.0` | +| `@kehto/shell` | `^0.32.0` | +| `@kehto/services` | `^0.30.1` | | `@kehto/nip` | `^0.8.0` | | `@kehto/wm` | `^0.0.2` | | `applesauce-*` | `^7.0.x` | -| `@napplet/core` | `>=0.37.0 <0.38.0` | +| `@napplet/core` | `>=0.38.0 <0.39.0` (`@kernl/core`); `>=0.37.0 <0.39.0` (`@kernl/guard`, `@kernl/messaging`) | +| `@napplet/nap` | `>=0.37.0 <0.38.0` | The protocol itself is a draft. NIP-5D is [nostr-protocol/nips PR #2303](https://github.com/nostr-protocol/nips/pull/2303) and the NAP documents live at [napplet/naps](https://github.com/napplet/naps). kernl keeps no local copy of either; see [Spec conformance](./policies/spec-conformance.md). diff --git a/docs/concepts/applets-under-kernl.md b/docs/concepts/applets-under-kernl.md index 49eb55d..07c6053 100644 --- a/docs/concepts/applets-under-kernl.md +++ b/docs/concepts/applets-under-kernl.md @@ -27,36 +27,36 @@ Put a domain in `requires` only if your applet is useless without it. The launch ## Where the guard acts on your traffic -kehto's bridge owns the port your applet talks over. kernl sees each envelope synchronously, just before kehto dispatches it, but cannot hold or drop it ([D-012](../decisions.md#d-012)). Two layers follow from that, and they behave differently: +kehto's bridge owns the port your applet talks over. kehto asks kernl about each envelope before its runtime sees it, and kernl answers dispatch, hold or drop ([D-020](../decisions.md#d-020)). Two layers follow from that, and they behave differently: -- **kehto's runtime firewall refuses.** It enforces the profile's per-class rates, which `kernl.mount` installs for your applet's `dTag`, and kehto's own startup burst guard. An over-budget envelope is refused by the runtime with an error that starts `firewall:`. kernl's size caps are refused the same way: kernl measures the envelope and has the runtime refuse that one dispatch. -- **kernl's guard refuses, and quarantines.** It judges the same traffic with the shell's knowledge of visibility and focus and records guard events. It cannot hold an envelope, but whatever it will not let through (an oversized request, a burst above 8 per second, a request over the unfocused budget, or anything but a control message while your window is hidden) is refused for that one dispatch with a `firewall:` error. Control messages (`shell.*`, `inc.*`, `theme.*`, closes) are never refused. When an envelope pushes your applet over a quarantine limit, kernl revokes the binding inside that observation, so that envelope is not dispatched and your applet is closed. +- **kernl's guard drops, holds and quarantines.** It judges your traffic with the shell's knowledge of visibility and focus and records guard events. Whatever it will not let through (an oversized request, a burst above 8 per second, a request over a class budget, smaller while you are not focused) is dropped: it is not handled and you get no reply, so give every request a timeout. Anything but a control message while your window is hidden is held and answered once you are visible. Control messages (`shell.*`, `inc.*`, `theme.*`, closes) are dispatched. When an envelope pushes your applet over a quarantine limit, kernl revokes the binding inside that decision, so that envelope is not dispatched and your applet is closed. +- **kehto's runtime firewall refuses.** It enforces the profile's per-class rates, which `kernl.mount` installs for your applet's `dTag`, at the window's focus, and the profile's startup burst guard. An envelope it refuses is answered with an error that starts `firewall:`. Because kernl counts the same rates first, you mostly meet the runtime's refusals in your first second and for held requests released over budget. -The window-level `hello` is the one message kernl filters before kehto sees it. +The window-level `hello` is filtered by kernl before kehto sees it. ## What the strict profile means for you The shell boots in `strict`. The numbers are in [Guard](./guard.md#profiles); the behaviours you will notice: -**Hidden windows cannot act.** While your window is minimised or the page is hidden, every request other than a control message is refused with a `firewall:` error; nothing is queued for later. Retry when you are visible again. Control messages are still answered, so the shell can close or theme you. Resource requests also count against the hidden allowance below, which quarantines. The visibility gate holds only a window-level `hello` sent while hidden. A hook that would defer your requests instead of refusing them is requested upstream ([Alpha status](../alpha-status.md#upstream-gaps)). +**Hidden windows wait.** While your window is minimised or the page is hidden, every request other than a control message is held, and answered in order once you are visible again; do not retry it. Control messages are answered while nothing is held; once a request is held, later messages, control ones included, queue behind it. A hidden window holds at most 200 requests in `strict`; past that, requests are dropped without a reply. Resource requests also count against the hidden allowance below, which quarantines ([D-020](../decisions.md#d-020)). **Hidden resource allowance.** A hidden window may make 8 `resource.*` requests; the ninth quarantines it with `flood:hidden-resource`, and that ninth request is not dispatched. The count resets as soon as the window is visible again. Fetch what you need while visible. -**Rate limits by operation class.** Each class has its own token bucket, keyed by your applet's `dTag`, so two windows of your applet share one budget. In `strict`: 30 `relay.subscribe` and 20 `relay.publish` per minute, 20 `dm.send` and 10 `intent.*` per minute, 120 `resource.*` and 60 `storage.set`/`storage.remove` per 10 seconds. These are the limits the runtime enforces before dispatch, at full capacity whether or not your window is focused: kehto's `ShellAdapter` does not forward focus to the runtime, so its unfocused multiplier never applies. Deletions (kind 5) count against the `relay.publish` budget there and have no bucket of their own. kernl's focus firewall counts the same classes again, plus 5 deletions per minute, at 0.25 of capacity while your window is not focused, and refuses each overrun with a `firewall:` error and a guard event such as `rate:relay:publish` or `rate:relay:publish:delete`. +**Rate limits by operation class.** Each class has its own token bucket, keyed by your applet's `dTag`, so two windows of your applet share one budget. In `strict`: 30 `relay.subscribe` and 20 `relay.publish` per minute, 20 `dm.send` and 10 `intent.*` per minute, 120 `resource.*` and 60 `storage.set`/`storage.remove` per 10 seconds. While your window is not focused, each budget is 0.25 of its capacity. kernl's focus firewall counts these classes first, plus 5 deletions per minute, and drops an overrun without a reply, with a guard event such as `rate:relay:publish` or `rate:relay:publish:delete`. The runtime enforces the same budgets, focus included, on what it handles, which matters for requests held while you were hidden: those over budget when released are refused with a `firewall:` error. Deletions (kind 5) count against the `relay.publish` budget in the runtime. -**Ingress flood control.** kernl counts 8 messages per second per window. Above that, non-control requests are refused with a `firewall:` error and a `flood:burst` event; nothing is queued, so retry later. The hard limit is real: the 300th message inside any rolling 10 seconds quarantines the window with `flood:quarantine`, and that message is not dispatched. Batch your writes and coalesce your subscriptions. +**Ingress flood control.** kernl counts 8 messages per second per window. Above that, non-control requests are dropped without a reply, with a `flood:burst` event; nothing is queued, so time out and retry later. The hard limit is real: the 300th message inside any rolling 10 seconds quarantines the window with `flood:quarantine`, and that message is not dispatched. Batch your writes and coalesce your subscriptions. **Quarantine stops your applet.** A quarantined window has its binding revoked and its frame removed. The user sees "This applet was stopped." and can open your applet again in a fresh window; nothing resumes on its own. -**Envelope caps.** `resource.*` envelopes over 64 KiB and `relay.subscribe`/`outbox.subscribe` (including `outbox.query`) envelopes over 256 KiB are refused before dispatch: your applet receives a `firewall:` error, nothing is fetched, and the user sees a `size:` guard event. Large filters are a smell; split them. +**Envelope caps.** `resource.*` envelopes over 64 KiB and `relay.subscribe`/`outbox.subscribe` (including `outbox.query`) envelopes over 256 KiB are dropped before dispatch: your applet gets no reply, nothing is fetched, and the user sees a `size:` guard event. Large filters are a smell; split them. -**A startup burst limit.** kehto's runtime counts every operation in your window's first 3 seconds and refuses everything past the 20th with a `firewall:` error. kernl also counts 8 messages per second from the first message, and reports bursts above that. Load lazily: request the first screen, render, then fetch the rest. +**A startup burst limit.** kehto's runtime counts every operation in your window's first second, control messages included, and refuses everything past the 8th in `strict` (12 in `balanced`, 24 in `relaxed`) with a `firewall:` error. kernl also counts 8 messages per second from the first message, and drops non-control requests above that. Load lazily: request the first screen, render, then fetch the rest. **No frames of your own.** kernl refuses to mount an applet whose markup contains an `iframe`, `frame`, `frameset`, `object`, `embed`, `portal` or `fencedframe` element (`denied:nested-frame`), and inside the window every way of creating one throws `TypeError: denied:nested-frame`: `createElement`, `createElementNS`, `createDocument`, and any HTML string with a frame tag written to `innerHTML`, `insertAdjacentHTML`, `document.write` or a parser. A child frame would be a fresh realm with a network path the shell cannot see ([D-014](../decisions.md#d-014)). Render into your own document instead. Two side effects to know: a string written to an HTML sink that merely mentions a frame tag, in text or an attribute, is refused too, so escape such text or set it with `textContent`; and the window's CSP carries `require-trusted-types-for 'script'; trusted-types default`, so your applet must not register a Trusted Types policy of its own, `default` or any other name. Libraries that create one need a fallback. **Consent for destructive kinds.** When your applet asks the shell to sign a replaceable event (kind 0, 3 or 10000–19999), an addressable event (30000–39999), a deletion (5) or a request to vanish (62), through `relay.publish` or `outbox.publish`, kernl looks up the version it replaces (for up to 4 seconds on the network), shows the user a summary of what changes and waits. Expect the publish to take as long as a person takes to read. If they refuse, signing fails with `denied:consent` and nothing is published; you receive the normal error envelope. Every other kind is signed without a prompt, and nothing is remembered: the next destructive write asks again. -None of this changes the wire format. A refusal is a normal NAP error envelope; a guard event is visible to the user, not to your applet. +None of this changes the wire format. A refusal is a normal NAP error envelope; a dropped request gets no envelope at all, because NAP defines none for it, so give every request a timeout. A guard event is visible to the user, not to your applet. ## Test against the guard diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 4fdc8e7..0ecd026 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -31,15 +31,15 @@ kehto is the runtime authority for how a NIP-5D shell composes. kernl uses these | kehto package | What kernl takes from it | | --- | --- | -| `@kehto/shell` | `createShellBridge`, `buildShellCapabilities`, `resolveShellEnvironment`, `prepareNappletSrcdoc`, `denyNappletControllerAccess`, `renderNappletFramePolicyMeta`, `originRegistry`, the `ShellAdapter` hooks and the revocation model. | -| `@kehto/runtime` | The protocol engine behind the bridge: dispatch, the ACL gate (`bridge.runtime.aclState`, `ALL_CAPABILITIES`), the runtime firewall (`bridge.runtime.firewallState`), the `ServiceRegistry` and `ConsentRequest`. | +| `@kehto/shell` | `createShellBridge`, `buildShellCapabilities`, `resolveShellEnvironment`, `prepareNappletSrcdoc`, `denyNappletControllerAccess`, `renderNappletFramePolicyMeta`, `originRegistry`, the `ShellAdapter` hooks (including `filterNappletMessage`, `getFocusContext`, `onFirewallEvent`, `firewallPersistence` and `aclDefaultPolicy`), `ShellBridge.releaseDeferred` and the revocation model. | +| `@kehto/runtime` | The protocol engine behind the bridge: dispatch, the ACL gate (`bridge.runtime.aclState`), the runtime firewall (`bridge.runtime.firewallState`, with `setUnfocusedMultiplier` and `setBurstGuard`), the `ServiceRegistry` and `ConsentRequest`. | | `@kehto/acl` | The grant store the runtime enforces, keyed by `dTag:hash`. kernl reaches it only through `bridge.runtime.aclState`. | | `@kehto/firewall` | The pure `evaluate()` engine `@kernl/guard` runs, and the `NappletRules` and `RateLimit` types `runtimeFirewallRules` translates into. | | `@kehto/services` | `createRelayPoolService`, `createIdentityService`, `createThemeService`, `createNotifyService`, `createLinkService`, `createResourceService`, `createDmService`, `createOutboxService` and `createRelayPoolOutboxRouter`. | | `@kehto/nip` | `resolveNapplet`, `openNappletArtifactCache` and `NAPPLET_KINDS` from `@kehto/nip/5d`. | | `@kehto/wm` | `LayoutStrategy`, `WindowState`, `Rect` and `WindowPlacement`, which the layouts implement. | -kernl never re-implements a kehto primitive. When a seam is too narrow, kernl composes in front of it and takes the gap upstream; the four gaps found so far, filed upstream as kehto/web #397–#400, are in [Alpha status](../alpha-status.md#upstream-gaps). +kernl never re-implements a kehto primitive. When a seam is too narrow, kernl composes in front of it and takes the gap upstream. The four gaps found first, filed as kehto/web #397–#400, are released in `@kehto/shell` 0.32.0 and in use ([D-020](../decisions.md#d-020)); what is still open is in [Alpha status](../alpha-status.md#upstream-gaps). ## One event layer diff --git a/docs/concepts/guard.md b/docs/concepts/guard.md index ca443bc..d473239 100644 --- a/docs/concepts/guard.md +++ b/docs/concepts/guard.md @@ -4,7 +4,7 @@ The guard is everything that stands between an applet and the user. Some of it i The rule from `SOUL.md`: if kehto ships a mitigation, kernl enables it by default and documents how to loosen it. The hosted shell boots `strict`, and there only the user loosens it ([D-003](../decisions.md#d-003)). An embedding host may choose the starting profile (`KernlOptions.guard.profile`), which changes limits only; the sandbox, the CSP, the frame lock and consent are fixed. In this draft the profile is shell-wide and resets to `strict` on every boot; per-applet profiles are planned ([Roadmap](../roadmap.md#_6-guard)). -**What refuses, what reports, what stops.** For an applet's port traffic, which is everything after the handshake, kehto's runtime firewall refuses an envelope before dispatch when it goes over the per-class rates kernl installs at mount, or over kehto's own startup burst. kernl's own layers refuse before dispatch too. Its ingress guard and focus firewall see each envelope synchronously, just before kehto dispatches it; they cannot hold it, but whenever one of them will not let it through (an oversized request, a burst above kernl's per-second rate, a request over the smaller unfocused budget, or any non-control request from a hidden window) kernl has the runtime firewall refuse that one dispatch with `refuseCurrentDispatch`. The applet gets a `firewall:` error and can retry; a hidden window can retry once it is visible. Control envelopes (`shell.*`, `inc.*`, `theme.*`, closes and the other `control` classes) are never refused, so a window always stays controllable. Every refusal is still a guard event. Quarantine goes further. When an envelope crosses the flood threshold, or a hidden window's download crosses its allowance, kernl revokes the binding inside the observation, so that envelope is not dispatched and the applet is closed ([Where enforcement happens](#where-enforcement-happens)). The visibility gate holds only the window-level `hello`. Signing consent is a third path, in the signer itself. +**What drops, what holds, what refuses, what stops.** For an applet's port traffic, which is everything after the handshake, kernl decides each envelope before kehto's runtime sees it, through kehto's `filterNappletMessage` ([D-020](../decisions.md#d-020)). An oversized request, a burst above kernl's per-second rate and a request over the smaller unfocused budget are dropped: nothing is handled, the applet gets no reply, and the user sees a guard event. A hidden window's non-control requests are held and handled, in order, once it is visible. Control envelopes (`shell.*`, `inc.*`, `theme.*`, closes and the other `control` classes) are dispatched, so a window stays controllable; once a hidden window holds a request, kehto queues its later envelopes behind it. kehto's runtime firewall then refuses, with a `firewall:` error, an envelope over the per-class rates kernl installs, over the profile's startup burst, or over the unfocused budget it now applies with the focus kernl reports; held envelopes meet it when they are released. Its refusals are guard events too. Quarantine goes further. When an envelope crosses the flood threshold, or a hidden window's download crosses its allowance, kernl revokes the binding inside the filter, so that envelope is not dispatched, what the window held is discarded, and the applet is closed ([Where enforcement happens](#where-enforcement-happens)). Signing consent is a third path, in the signer itself. ## The eleven mitigations @@ -16,15 +16,15 @@ The rule from `SOUL.md`: if kehto ships a mitigation, kernl enables it by defaul | 2 | `bound-identity` | An applet is only trusted while its connection and code match | A reloaded or substituted document inheriting grants, or one frame speaking for another. Identity is `(dTag, artifactHash)` plus the one `MessagePort` transferred in `napplet.hello`. Any other window-level message, a second `hello`, a `hello` without exactly one port, a second load or a closed port revokes the binding. | `kehto` | none | | 3 | `verified-artifacts` | Applet code is verified before it runs, and again before reuse | A gateway or cache serving different code than the signed manifest names. Bytes are fetched by hash through `@kehto/nip/5d`, cached only after verification and re-verified on every read. | `kehto` | none | | 4 | `replay-detection` | Old or repeated posts are not sent | The same signed post sent twice, or one dated to slip past a time window. kehto's replay detector runs on `relay.publish` after the shell signs: it refuses an event dated more than the replay window (30 s by default) in the past, more than 10 s in the future, or whose id it already saw inside the window. `outbox.publish` is not checked. A message from a window other than the applet's own is a port-binding matter, covered by `bound-identity`. | `kehto` | none | -| 5 | `rate-limits` | Applets have a budget for how often they can act | Publishing, subscribing or spending quota at a rate no human interaction explains. Refused before dispatch: kehto's runtime firewall refuses an over-budget envelope until the allowance refills, using the per-class rates kernl installs at mount, where deletions count against `relay:write`. The same firewall applies kehto's startup burst guard: more than 20 operations in a window's first 3 s are refused. kernl's focus firewall counts the classes again as each envelope arrives, including a separate `relay:publish:delete` bucket, and reports overruns as `rate:` events. | `both` | `publish 20 per 60s, delete 5 per 60s, messages 20 per 60s` | -| 6 | `focus-tightening` | Applets you are not using are measured against a smaller budget | Background windows spending a foreground budget. Refused before dispatch: kernl's focus firewall counts an unfocused window at `unfocusedMultiplier` of capacity as each envelope arrives; one over it emits a guard event (`rate:`) and kernl has the runtime firewall refuse that dispatch. Once the window is focused, the full budget applies again. Focus is observed by the shell, never self-reported. The runtime firewall itself applies no unfocused multiplier: kehto's `ShellAdapter` does not forward `getFocusContext` ([upstream gaps 1 and 4](../alpha-status.md#upstream-gaps)), so the tightening is kernl's. The rate-limits budget above still applies in full. | `kernl` | `25% of the focused budget` | -| 7 | `hidden-deferral` | Hidden applets cannot act until you can see them | Hidden or minimised applets acting where the user cannot see them. While a window is not visible, every non-control port envelope is refused before dispatch (`hidden:`), not held: the applet gets a `firewall:` error and can retry when visible. Control envelopes are answered. Each `resource.*` request from a hidden window counts against `hiddenResourceAllowance` as it arrives; the one past it quarantines the window (`flood:hidden-resource`): the binding is revoked inside the observation, so that request is not dispatched, and the frame is removed. Quarantine is checked first, so a request past the allowance quarantines rather than being refused. The visibility gate holds only a hidden window's window-level `hello`. | `kernl` | `8 downloads while hidden` | -| 8 | `ingress-flood` | Applets that flood the shell are put in quarantine | A busy loop starving the shell's main thread. Per-window token bucket, counted as each port envelope arrives: non-control envelopes in a burst above `ingressBurst` are refused before dispatch (`flood:burst`, `flood:queue-full`) and are not queued; control envelopes still pass. The message that reaches `quarantineThreshold` in 10 s quarantines the window: the binding is revoked inside the observation, so that message is not dispatched, and the frame is removed. Only the window-level `hello` is actually queued or rejected. | `kernl` | `8 messages per second, 300 per 10s` | -| 9 | `size-caps` | Oversized requests are refused | Memory pressure from oversized filters or payloads. The ingress guard measures `resource.*` and `relay.subscribe`/`outbox.subscribe` envelopes as they arrive and rejects an oversized one with `size:`. kehto's runtime cannot measure these itself (it sizes only an envelope's `event`), so kernl calls `refuseCurrentDispatch`, which sets the applet's runtime policy to `deny` for that one synchronous dispatch and restores it in a microtask. The applet receives a `firewall:` error, nothing is fetched, and the user sees the guard event. An oversized `hello` is dropped. | `both` | `downloads 64 KiB, queries 256 KiB` | +| 5 | `rate-limits` | Applets have a budget for how often they can act | Publishing, subscribing or spending quota at a rate no human interaction explains. Refused before dispatch: kehto's runtime firewall refuses an over-budget envelope until the allowance refills, using the per-class rates kernl installs at mount and again on every profile change, where deletions count against `relay:write`. The same firewall applies the profile's startup burst guard: more than `ingressBurst` operations (8 in `strict`) in a window's first second are refused. kernl's focus firewall counts the classes first, as each envelope arrives, including a separate `relay:publish:delete` bucket, and drops overruns with a `rate:` event. | `both` | `publish 20 per 60s, delete 5 per 60s, messages 20 per 60s` | +| 6 | `focus-tightening` | Applets you are not using are measured against a smaller budget | Background windows spending a foreground budget. Refused before dispatch: kernl's focus firewall counts an unfocused window at `unfocusedMultiplier` of capacity as each envelope arrives and drops one over it with a guard event (`rate:`). kehto's runtime firewall applies the same multiplier, with the focus kernl reports through `getFocusContext`, to everything it dispatches, including held envelopes released while the window is unfocused. Once the window is focused, the full budget applies again. Focus is observed by the shell, never self-reported. | `both` | `25% of the focused budget` | +| 7 | `hidden-deferral` | Hidden applets cannot act until you can see them | Hidden or minimised applets acting where the user cannot see them. While a window is not visible, every non-control port envelope is held (`hidden:`, a `defer` event), up to `deferQueue`; past it, it is dropped (`defer:queue-full`). When the window is visible again kernl calls `releaseDeferred` and kehto handles what it held, in order. Control envelopes are answered, unless the window already holds a request, in which case kehto queues them behind it. Each `resource.*` request from a hidden window counts against `hiddenResourceAllowance` as it arrives; the one past it quarantines the window (`flood:hidden-resource`): the binding is revoked inside the filter, so that request is not dispatched, what the window held is discarded, and the frame is removed. Quarantine is checked first, so a request past the allowance quarantines rather than being held. | `kernl` | `8 downloads while hidden` | +| 8 | `ingress-flood` | Applets that flood the shell are put in quarantine | A busy loop starving the shell's main thread. Per-window token bucket, counted as each port envelope arrives: non-control envelopes in a burst above `ingressBurst` are dropped before dispatch (`flood:burst`, `flood:queue-full`) and are not queued; control envelopes still pass. The message that reaches `quarantineThreshold` in 10 s quarantines the window: the binding is revoked inside the filter, so that message is not dispatched, and the frame is removed. Only the window-level `hello` is ever queued by this layer. | `kernl` | `8 messages per second, 300 per 10s` | +| 9 | `size-caps` | Oversized requests are refused | Memory pressure from oversized filters or payloads. The ingress guard measures `resource.*` and `relay.subscribe`/`outbox.subscribe` envelopes as they arrive and rejects an oversized one with `size:`. kehto's runtime cannot measure these itself (it sizes only an envelope's `event`), so kernl's filter drops the envelope. Nothing is fetched, the applet gets no reply, and the user sees the guard event. An oversized `hello` is dropped. | `both` | `downloads 64 KiB, queries 256 KiB` | | 10 | `device-denial` | Applets cannot use your camera, microphone or other devices | An applet reaching hardware. `denyNappletControllerAccess` gives the frame the Permissions Policy `gamepad 'none'; hid 'none'; usb 'none'`; the frame's `allow` attribute delegates nothing else, so camera, microphone and other powerful features stay off. There is no grant for any of them. | `kehto` | none | | 11 | `destructive-consent` | Deleting or overwriting asks you first | An applet acquiring authority the user did not knowingly give. `createSigningConsent` asks `host.presentConsent` before a destructive kind (every replaceable kind 0, 3, 10000–19999; every addressable kind 30000–39999; 5; 62) is signed for an applet, after looking up the current version in the store and then on the outbox and discovery relays for up to `CURRENT_VERSION_TIMEOUT_MS` (4000 ms); if none is found the summary says "Current version: could not be loaded; this replaces it entirely". It runs on both routes: the window signer from `ShellAdapter.auth.getSigner(windowId)` for `relay.publish`, and kernl's outbox gate for `outbox.publish`. A denial is `denied:consent` and nothing is signed; answers are not remembered, and the shell's own signing is not gated. kehto's runtime has no such step. | `kernl` | none | -`enabled` is `true` for every row in every profile; the field exists for the UI contract. Two things are not separate rows: the opaque-origin frame, the CSP and the frame lock are one row (`zero-network-sandbox`), and kehto's startup burst guard is part of `rate-limits`, because the same runtime firewall applies both ([What `burstGuard` is for](#layers)). See `mitigations.feature`, scenario "every mitigation is described once, in display order". +`enabled` is `true` for every row in every profile; the field exists for the UI contract. Two things are not separate rows: the opaque-origin frame, the CSP and the frame lock are one row (`zero-network-sandbox`), and the runtime's startup burst guard is part of `rate-limits`, because the same runtime firewall applies both ([What `burstGuard` is for](#layers)). See `mitigations.feature`, scenario "every mitigation is described once, in display order". ## Operation classes @@ -81,7 +81,7 @@ Every rate kernl registers in kehto has exceed action `block`; kehto's own defau ## Layers -Three stateful objects, each built from the same `GuardConfig`, each keyed by `windowId`, each with a `forget(windowId)` to call on revocation. This section describes each layer's own API, which is what a caller that sits in front of dispatch gets. In the composed shell that is true only for the window-level `hello`; for port traffic the dispositions below become events and, for `quarantine`, a revocation that also stops the envelope that triggered it ([Where enforcement happens](#where-enforcement-happens)). +Three stateful objects, each built from the same `GuardConfig`, each keyed by `windowId`, each with a `forget(windowId)` to call on revocation. This section describes each layer's own API. In the composed shell the window-level `hello` gets these dispositions directly; for port traffic the pipeline turns them into kehto's `dispatch`, `defer` or `drop` ([Where enforcement happens](#where-enforcement-happens)). | Layer | Enforces | Factory | | --- | --- | --- | @@ -95,18 +95,18 @@ Three stateful objects, each built from the same `GuardConfig`, each keyed by `w **Focus firewall.** `observe` builds a kehto `Observation` from the envelope and the shell's `WindowVisibility` (`focused`, and `msSinceFocusGain` when known) and runs `evaluate()`. On the first observation of an applet it calls `applyNappletRules` so the applet's class rates exist in kehto's per-applet map; kehto keys those budgets by applet (`dTag`), so two windows of the same applet share a budget (`focus-firewall.feature`, scenario "budgets are per applet, not per window"). Decisions map to events: `reject` emits `reject`; `pass` with action `flag` emits `flag`; `prompt` emits `reject` with reason `prompt:`. kernl treats a prompt as a denial here because the layer has no UI; signing consent for destructive writes is a separate path, in the signer wrapper, not in the firewall. kehto's `rate:*` rule ids name the tier (`rate:opclass`, `rate:global`, `rate:default`); the event reason is rewritten to `rate:` so the user sees what ran out. Other rule ids pass through unchanged, for example `matcher:size:resource:bytes` and `policy:deny`. In the composed shell a `matcher:size:*` event cannot occur: the ingress guard applies the same caps first, and the pipeline runs the focus firewall only for envelopes the ingress guard passed. `forget(windowId)` drops the buckets of every applet seen in that window, which also resets the budget of another window running the same applet; revocation is user-driven, so that is accepted. -**What `burstGuard` is for.** `createGuardConfig` sets `firewall.burstGuard` to `{ windowMs: 1000, maxOps: ingressBurst, action: 'block' }`. kehto's init-burst guard only fires for callers that supply `initElapsedMs` in the observation, and the focus firewall never does, so this field never fires inside `@kernl/guard`. The guard that does fire is the runtime's: kehto's runtime supplies `initElapsedMs` (time since the window's session registered) and evaluates its own configuration, kehto's default of 20 operations in the first 3 s with action `block`, because kernl installs rates in the runtime and nothing else. An applet that sends more than 20 envelopes in its first 3 s has the excess refused with a `firewall:` error. +**What `burstGuard` is for.** `createGuardConfig` sets `firewall.burstGuard` to `{ windowMs: 1000, maxOps: ingressBurst, action: 'block' }`. kehto's init-burst guard only fires for callers that supply `initElapsedMs` in the observation, and the focus firewall never does, so this field never fires inside `@kernl/guard`. `createKernl` installs it, with `unfocusedMultiplier`, in the runtime firewall on boot and on every profile change (`applyRuntimeFirewallProfile`). kehto's runtime supplies `initElapsedMs` (time since the window's session registered), so an applet that sends more than `ingressBurst` envelopes in its first second (8 in `strict`), control envelopes included, has the excess refused with a `firewall:` error and a `flood:startup` guard event. kehto's own default, which applied before, was 20 in 3 s. ## Where enforcement happens -kehto's bridge owns the `MessagePort` an applet speaks over and shows the host that traffic only through `ShellAdapter.onNappletMessage`. kehto's `dispatchEnvelope` calls that hook synchronously for each envelope, before it resolves the window's session and hands the envelope to the runtime, and ignores the hook's answer. kernl therefore observes every envelope before dispatch but cannot hold or drop it ([D-001](../decisions.md#d-001)). Enforcement is layered ([D-012](../decisions.md#d-012)): +kehto's bridge owns the `MessagePort` an applet speaks over. For each envelope on it, kehto asks the host's `ShellAdapter.filterNappletMessage` before the runtime sees it, and follows the answer: `dispatch`, `drop` (nothing is handled and nothing is sent back) or `defer` (held until `ShellBridge.releaseDeferred(windowId)`). `shell.ready` never reaches the filter. Enforcement is layered ([D-020](../decisions.md#d-020)): | Traffic | Where | How | | --- | --- | --- | -| The window-level `napplet.hello` | kernl, before kehto sees it | `Kernl.handleWindowMessage` resolves the sending window from `originRegistry`, never from the message, and runs ingress guard → visibility gate → focus firewall before `bridge.handleMessage`. This is the one message the pipeline can hold or drop; any other window-level message from a bound applet is kehto's `window-message` revocation. | -| Port traffic, judged | kernl, inside `onNappletMessage`, before dispatch | `onNappletMessage` feeds `GuardPipeline.observe`: the ingress guard and the focus firewall advance their counters and emit events, and `observe` returns a disposition (see the next rows for what a `defer` or `reject` does). A `quarantine` disposition calls `onQuarantine`, which marks the handle revoked with reason `quarantine` and calls `bridge.revokeNapplet(windowId, 'host')` while kehto is still inside the hook. kehto then finds no session for the window and does not dispatch the envelope, and the frame is removed. | -| Port traffic, refused by rate | kehto's runtime firewall, configured by kernl | `kernl.mount` calls `applyRuntimeFirewallRules(bridge.runtime.firewallState, config, dTag)`, which installs the profile's per-class rates and global rate for that applet. It sets no policy: kehto's `allow` policy would make `evaluate()` skip every rate, matcher and burst check. The runtime refuses over-budget envelopes, and envelopes past its startup burst, itself, before it handles them, and answers the applet with an error that starts `firewall:`. Rates are installed at mount: a window keeps the rates it was mounted with until it is opened again. | -| Port traffic, refused by kernl's guard | kernl's ingress guard, focus firewall and hidden-window check, enforced by kehto's runtime firewall | When `observe` returns `defer` or `reject` (`size:`, `flood:burst`, `flood:queue-full`, the focus firewall's `rate:`, or `hidden:`), `createKernl` calls `refuseCurrentDispatch(bridge.runtime.firewallState, dTag)`: the applet's runtime policy is `deny` for the rest of that synchronous dispatch, so kehto refuses the envelope, and the previous policy returns in a microtask, before the next port message can arrive. Control envelopes always come back `pass`. | +| The window-level `napplet.hello` | kernl, before kehto sees it | `Kernl.handleWindowMessage` resolves the sending window from `originRegistry`, never from the message, and runs ingress guard → visibility gate → focus firewall before `bridge.handleMessage`. Any other window-level message from a bound applet is kehto's `window-message` revocation. | +| Port traffic, decided | kernl, inside `filterNappletMessage` | `GuardPipeline.observe` runs the ingress guard, the hidden-window check and the focus firewall and answers kehto. `dispatch` for an envelope within budget and for control envelopes. `drop` for `size:`, `flood:burst`, `flood:queue-full`, the focus firewall's `rate:`, and `defer:queue-full`. `defer` for a non-control envelope from a window that is not visible (`hidden:`). A `quarantine` calls `onQuarantine`, which marks the handle revoked with reason `quarantine` and calls `bridge.revokeNapplet(windowId, 'host')` inside the filter; kehto then drops the envelope and discards what the window held, and the frame is removed. | +| Port traffic, released | kehto, when kernl asks | When a window becomes visible the pipeline calls `onRelease`, and `createKernl` calls `bridge.releaseDeferred(windowId)`. kehto dispatches the held envelopes in arrival order without asking the filter again; the ACL and the runtime firewall check each one, at the window's focus then. A window holding more than kehto's `MAX_DEFERRED_MESSAGES` (1024) is revoked with `deferred-overflow`; kernl's `deferQueue` is smaller, so it drops first, and the frame is removed on that reason as on every other. | +| Port traffic, refused by rate | kehto's runtime firewall, configured by kernl | `kernl.mount` calls `applyRuntimeFirewallRules(bridge.runtime.firewallState, config, dTag)`, which installs the profile's per-class rates and global rate for that applet; a profile change reinstalls them for every open window and sets the multiplier and startup burst (`applyRuntimeFirewallProfile`). No policy is set: kehto's `allow` policy would make `evaluate()` skip every rate, matcher and burst check. The runtime refuses over-budget envelopes and envelopes past the startup burst before it handles them, answers the applet with an error that starts `firewall:`, and reports the refusal through `onFirewallEvent`, which kernl turns into a guard event without the message. | The runtime keys its rates by the sender capability a message needs, while the guard classifies by what a message does. `RUNTIME_OP_CLASSES` translates: @@ -114,7 +114,7 @@ The runtime keys its rates by the sender capability a message needs, while the g | --- | --- | | `relay:subscribe` | `relay:read` | | `relay:publish` | `relay:write` | -| `relay:publish:delete` | none; deletions share `relay:write` in the runtime and keep their own bucket only in kernl's focus firewall, whose rejections are refused by `refuseCurrentDispatch` | +| `relay:publish:delete` | none; deletions share `relay:write` in the runtime and keep their own bucket only in kernl's focus firewall, which drops what goes over it | | `outbox:subscribe` | `outbox:read` | | `outbox:publish` | `outbox:write` | | `resource:bytes` | `resource:fetch` | @@ -124,18 +124,18 @@ The runtime keys its rates by the sender capability a message needs, while the g | `control` | none; control envelopes need no capability | | `generic` | the per-applet global rate (`setGlobalRate`) | -Two limits of this arrangement are stated plainly. The runtime firewall never applies an unfocused multiplier: kehto's `ShellAdapter` does not forward `getFocusContext`, so the runtime builds every observation as focused, and `FirewallStateContainer` has no setter for `unfocusedMultiplier` either. The profile's value (`0.25` in `strict`) is applied by kernl's focus firewall, whose rejections kernl turns into runtime refusals. And a hidden window's port traffic is refused, not deferred: `observe` cannot hold a port envelope, so the applet must retry once visible. A hook that let the shell defer instead of refuse is tracked upstream as #399 ([Alpha status](../alpha-status.md#upstream-gaps)). +Two limits of this arrangement are stated plainly. A dropped request gets no reply, because NAP defines no answer for a request the host drops; an applet has to time out. And the runtime firewall's own configuration seams (`setUnfocusedMultiplier`, `setBurstGuard`, `onFirewallEvent`, `firewallPersistence`) are ignored once kehto's pending approval authority ([kehto/web#336](https://github.com/kehto/web/pull/336)) is configured; kernl keeps them in one module, `runtime-rules.ts`, and its own layers report everything they decide, so the guard log does not depend on them ([Alpha status](../alpha-status.md#upstream-gaps)). ## Dispositions and reasons `IngressGuard.admit()` returns one of four dispositions: -| Disposition | Meaning | For the window-level `hello` | For port traffic (refused, never held) | +| Disposition | Meaning | For the window-level `hello` | For port traffic (kehto's decision) | | --- | --- | --- | --- | -| `pass` | Within budget. | Forwarded to the bridge after the visibility gate and the focus firewall. | Nothing; the envelope is dispatched. | -| `defer` | The per-second burst is spent; the envelope is queued. | Held; `drainAll` forwards it as tokens refill. | A `flood:burst` event; the envelope is not queued and kernl refuses this dispatch (`refuseCurrentDispatch`). Control envelopes pass. | -| `reject` | The queue is full or the envelope is over its size cap. | Dropped. | `flood:queue-full`, `size:`, `rate:` from the focus firewall, or `hidden:` for a non-control envelope from a window that is not visible: an event, and the runtime firewall refuses the envelope (`refuseCurrentDispatch`). Control envelopes are never refused. | -| `quarantine` | The window hit the quarantine threshold or the hidden resource allowance, or is still quarantined. | Dropped, and the window is revoked. | `onQuarantine` revokes the binding inside the hook, so this envelope is not dispatched, and the frame is removed. | +| `pass` | Within budget. | Forwarded to the bridge after the visibility gate and the focus firewall. | `dispatch`, or `defer` when the window is not visible and the envelope is not control. | +| `defer` | The per-second burst is spent; the envelope is queued. | Held; `drainAll` forwards it as tokens refill. | `drop`, with a `flood:burst` event; the envelope is not queued. Control envelopes are dispatched. | +| `reject` | The queue is full or the envelope is over its size cap. | Dropped. | `drop`, with `flood:queue-full`, `size:`, `rate:` from the focus firewall, or `defer:queue-full` for a hidden window past `deferQueue`. Control envelopes are dispatched. | +| `quarantine` | The window hit the quarantine threshold or the hidden resource allowance, or is still quarantined. | Dropped, and the window is revoked. | `drop`; `onQuarantine` revokes the binding inside the filter, so this envelope is not dispatched, what the window held is discarded, and the frame is removed. | Every disposition other than `pass`, and every `pass` the firewall flagged, emits a `GuardEvent` with a stable prefixed reason. The user sees these events; secrets never appear in them. @@ -147,11 +147,14 @@ Every disposition other than `pass`, and every `pass` the firewall flagged, emit | `flood:hidden-resource` | ingress guard | `quarantine` | | `size:` (`size:resource:bytes`, `size:relay:subscribe`, `size:outbox:subscribe`) | ingress guard | `reject` | | `defer:queue-full` | visibility gate | `reject` | -| `hidden:`, a non-control port envelope from a window that is not visible | guard pipeline (`observe`) | `reject` | +| `hidden:`, a non-control port envelope from a window that is not visible, held until it is | guard pipeline (`observe`) | `defer` | | `rate:` | focus firewall | `reject`, or `flag` when the rate's action is `flag` | | `matcher:`, kehto's rule id passed through; the guard's own `matcher:size:*` matchers cannot fire in the composed shell, because the ingress guard rejects the same envelopes first | focus firewall | `reject` | | `policy:deny` | focus firewall | `reject` | | `prompt:` (`prompt:policy:ask`) | focus firewall | `reject` | +| `rate:`, the runtime's own refusal of an envelope it was about to handle | runtime firewall, through `onFirewallEvent` | `reject` | +| `flood:startup`, more than the startup burst in a window's first second | runtime firewall, through `onFirewallEvent` | `reject` | +| `runtime:`, any other runtime firewall decision (`runtime:policy:deny`) | runtime firewall, through `onFirewallEvent` | `reject`, or `flag` when it passed with a flag | ## Why CSP never loosens diff --git a/docs/concepts/identity-and-grants.md b/docs/concepts/identity-and-grants.md index 9407fab..b38e04e 100644 --- a/docs/concepts/identity-and-grants.md +++ b/docs/concepts/identity-and-grants.md @@ -33,11 +33,11 @@ A grant is expressed in domains, not capabilities. `DOMAIN_CAPABILITIES` (`grant ## Restrictive posture -kehto's `createRuntime` hardcodes the ACL default policy to `'permissive'`: a capability with no entry in the ACL store is allowed. kernl wants the opposite, and until the upstream gap in [Alpha status](../alpha-status.md#upstream-gaps) is closed it produces the restrictive posture around the runtime in three places: +kehto's runtime ACL is permissive unless the host says otherwise: a capability with no entry in the ACL store is allowed. kernl says otherwise. `createShellAdapter` sets `aclDefaultPolicy: 'restrictive'` (`@kehto/shell` 0.32.0, [D-020](../decisions.md#d-020)), so an applet with no entry holds nothing, and a stored policy is replaced by this one on load while stored entries are kept. The posture is built in three places: -1. **Explicit ACL writes.** `KernlGrants.ensure(identity)` runs inside `kernl.plan`, before any frame exists. It commits the applet's record, and a commit writes the complete capability set: every capability in kehto's `ALL_CAPABILITIES` is granted when `BASELINE_DOMAINS` or an allowed domain needs it and revoked otherwise, then the blocked flag is applied and the ACL persisted. The permissive default is never consulted for an applet kernl runs, because every capability has an entry. +1. **ACL writes.** `KernlGrants.ensure(identity)` runs inside `kernl.plan`, before any frame exists. It commits the applet's record: every capability `BASELINE_DOMAINS` or an allowed domain needs is granted, and whatever the applet's stored entry holds beyond that is revoked (a domain the user took back, or an entry kept from the earlier permissive posture). Then the blocked flag is applied and the ACL persisted. An applet kernl never planned has no entry and is denied everything. 2. **Environment narrowing.** `createEnvironmentResolver(grants)` is the `ShellAdapter.capabilities.resolveEnvironment` hook. For each identity it returns the baseline plus the domains the user allowed, intersected with what the shell offers live; a blocked applet gets `shell` alone, enough to finish the handshake and nothing more. kehto intersects the result with its own availability set, so the hook can only remove. The applet's `shell.init` therefore advertises a subset. -3. **Runtime firewall rules.** `kernl.mount` calls `applyRuntimeFirewallRules`, which translates the guard profile's per-class rates into kehto's runtime firewall through `RUNTIME_OP_CLASSES` so that over-budget envelopes are refused before dispatch. It sets no policy: kehto's `allow` policy would skip every check, and deny and block are the grants layer's job. The one policy kernl writes there is the momentary `deny` of `refuseCurrentDispatch`, which refuses a single oversized envelope. [Guard](./guard.md#where-enforcement-happens) has the translation table. +3. **Runtime firewall rules.** `kernl.mount` calls `applyRuntimeFirewallRules`, which translates the guard profile's per-class rates into kehto's runtime firewall through `RUNTIME_OP_CLASSES` so that over-budget envelopes are refused before dispatch. It sets no policy: kehto's `allow` policy would skip every check, and deny and block are the grants layer's job. [Guard](./guard.md#where-enforcement-happens) has the translation table. The baseline is `BASELINE_DOMAINS`: `shell`, `inc`, `theme` and `storage`. It has no network, and it is not read-only: the handshake, in-shell messaging, the theme, and the applet's own scoped storage, which it reads and writes within its state quota. Who the user is, is not in it. `identity` is a grant like any other ("your account" in the shell): until the user grants it, the shell does not tell an applet which account is signed in, its public key or its profile (SOUL opinion 2). Other grants can reveal the account indirectly, and the shell does not hide that: an applet granted `relay` that publishes gets the signed event back in `relay.publish.result`, and a signed event carries the author's public key. `grants.revokeDomain` refuses a baseline domain with `denied:baseline-domain`; `grants.allowDomain` refuses a domain the shell does not offer with `invalid:domain`, and is a no-op for a baseline domain. diff --git a/docs/concepts/layouts.md b/docs/concepts/layouts.md index e205280..e4e26cb 100644 --- a/docs/concepts/layouts.md +++ b/docs/concepts/layouts.md @@ -47,7 +47,7 @@ The shell app (`apps/web`) does not call the `@kernl/core` strategies. Its windo At 720 px wide or less the shell is compact: every window fills the desktop, whatever the mode ([Alpha status](../alpha-status.md#what-does-not-work-yet)). -Minimising matters beyond layout. A minimised window is hidden, and the guard watches a hidden window more closely: its resource requests are counted against the hidden-window allowance, and the visibility gate holds its first window-level message ([Guard](./guard.md)). +Minimising matters beyond layout. A minimised window is hidden, and the guard watches a hidden window more closely: its requests are held until it is shown again, and its resource requests are counted against the hidden-window allowance ([Guard](./guard.md)). An embedding host that renders its own windows can pass any `LayoutStrategy`, including the four above, to whatever arranges them; the core does not hold a layout of its own. diff --git a/docs/concepts/persistence.md b/docs/concepts/persistence.md index a5db3f8..ef1d642 100644 --- a/docs/concepts/persistence.md +++ b/docs/concepts/persistence.md @@ -25,6 +25,7 @@ The prefix keeps kernl's keys apart from kehto's and from the host app's in the | `kernl:grants:v1` | `createGrants` | One record per verified applet build, keyed `:`: `{ dTag, hash, domains, blocked }`. See [Identity and grants](./identity-and-grants.md). | | `kernl:relays:v1` | `createRelayTiers` | The four relay tiers, each a list of URLs. See [Relay tiers](./relay-tiers.md). | | `kernl:messaging:v1` | `createDefaultMessaging` | `MessagingPreferences`: the `enabled` provider ids and the optional `defaultProvider`. See [Messaging](./messaging.md). | +| `kernl:firewall:v1` | `createRuntimeFirewallHooks`, as kehto's `firewallPersistence` | kehto's serialised runtime firewall configuration: the per-applet rates kernl installs, the unfocused multiplier and the startup burst guard. Counters are never stored. kehto writes it whenever kernl changes the configuration and loads it when the bridge is created; kernl reapplies the profile on every boot, so a stored value never loosens the profile in force. The string is kehto's format and is written as is, not through `createNamespacedStore`. See [Guard](./guard.md#where-enforcement-happens). | On boot, `createKernlAccounts` restores the accounts and makes the stored id active if that account still exists. `createGrants` loads its records, and `createRelayTiers` and `createDefaultMessaging` start from the stored value when there is one. @@ -40,7 +41,7 @@ The public website also remembers its colour scheme as `kernl-theme` (`light` or ## Keys kehto writes -kernl passes no persistence hooks of its own to `createShellBridge`, so kehto uses its defaults from `@kehto/shell`, which write to `localStorage` directly rather than to `host.storage`. In `@kehto/shell` 0.29.0 those are `napplet:acl` (the runtime ACL, which `kernl:grants:v1` mirrors per applet build), `napplet:manifest-cache`, `napplet-shell-secret`, `napplet-guid:`, and the scoped keys behind the `storage` NAP domain, where an applet's own data lives. kernl does not read or migrate these keys; their format is kehto's. +Apart from the runtime firewall's configuration above, kernl passes no persistence hooks of its own to `createShellBridge`, so kehto uses its defaults from `@kehto/shell`, which write to `localStorage` directly rather than to `host.storage`. In `@kehto/shell` 0.32.0 those are `napplet:acl` (the runtime ACL, which `kernl:grants:v1` mirrors per applet build; kernl's `aclDefaultPolicy: 'restrictive'` replaces the policy stored there on load), `napplet:manifest-cache`, `napplet-shell-secret`, `napplet-guid:`, and the scoped keys behind the `storage` NAP domain, where an applet's own data lives. kernl does not read or migrate these keys; their format is kehto's. ## What is never persisted diff --git a/docs/how-tos/change-guard-profile.md b/docs/how-tos/change-guard-profile.md index 712c4a9..7c6c1ac 100644 --- a/docs/how-tos/change-guard-profile.md +++ b/docs/how-tos/change-guard-profile.md @@ -6,7 +6,7 @@ The hosted shell boots in `strict`. In this draft a user can switch the whole sh 1. Open the guard panel from the desktop bar (*Guard · strict · N*). 2. Under *Profile*, pick `balanced` or `relaxed`. The list of mitigations updates to show the new limit for each one. The CSP does not change; it never does. -3. The change calls `kernl.guard.setProfile(profile)`. kernl's own counting layers switch to the new limits at once: the ingress guard, visibility gate and focus firewall are rebuilt from the new profile, their counters restart and the window-level messages they held are dropped. The rates kehto's runtime firewall refuses with do not change for windows that are already open: they keep the rates they were mounted with, and only applets opened after the change get the new ones. Close and reopen a window to give it the new rates. The choice is not persisted: a reload starts in `strict` again. +3. The change calls `kernl.guard.setProfile(profile)`. kernl's own counting layers switch to the new limits at once: the ingress guard, visibility gate and focus firewall are rebuilt from the new profile, their counters restart and the window-level messages they held are dropped. kehto's runtime firewall follows too: kernl reinstalls the new rates for every open window and sets the profile's unfocused multiplier and startup burst ([D-020](../decisions.md#d-020)). Requests a hidden window holds stay held. The choice is not persisted: a reload starts in `strict` again. A quarantined applet stays closed whatever the profile: its binding was revoked and its frame removed when it was quarantined. Its window's grants panel offers **Release from quarantine**, which clears kernl's quarantine record for that window (`kernl.guard.release(windowId)`) and the panel's marker; it does not bring the frame back. To run the applet again, open it again: a new window starts with a fresh budget. @@ -42,9 +42,9 @@ function guardFor(profile: GuardProfile, onEvent: (event: GuardEvent) => void) { 1. Build the three layers from the new config. The layers hold no reference to the profile, so there is no in-place update; the old `IngressGuard`, `VisibilityGate` and `FocusFirewall` are replaced. 2. Drain what the old gate and old ingress guard still hold (`drain(windowId)`) and admit it through the new layers, so nothing queued is lost. 3. Call `release(windowId)` on the old ingress guard, or simply drop it: quarantine lives in the guard instance and does not survive the swap. -4. If the shell also runs kehto's runtime firewall from `config.firewall`, hand it the new `FirewallConfig`. Per-applet rates are not in a fresh config; `applyNappletRules(config, dTag)` adds them, and the focus firewall does this itself on first observation. +4. If the shell also runs kehto's runtime firewall, hand it the new limits: `setUnfocusedMultiplier` and `setBurstGuard` from `config.firewall`, and the per-applet rates, which are not in a fresh config; `applyNappletRules(config, dTag)` adds them, and the focus firewall does this itself on first observation. -`@kernl/core` does this inside `createGuardPipeline`: `GuardFacade.setProfile(profile)` rebuilds the three layers from `createGuardConfig(profile)`, clears what they held, re-applies the known visibility of every window and emits on `profile$`; `GuardFacade.release(windowId)` clears a quarantine record on its own, which does not bring back a frame that was already removed. Applets mounted after the change get the new profile's rates in kehto's runtime firewall through `applyRuntimeFirewallRules`; windows already open keep the runtime rates they were mounted with until they are opened again. The [core page](../packages/core.md#the-kernl-surface) documents that surface. +`@kernl/core` does this inside `createGuardPipeline`: `GuardFacade.setProfile(profile)` rebuilds the three layers from `createGuardConfig(profile)`, clears what they held, re-applies the known visibility of every window and emits on `profile$`; `GuardFacade.release(windowId)` clears a quarantine record on its own, which does not bring back a frame that was already removed. `createKernl` follows `profile$`: `applyRuntimeFirewallProfile` sets the runtime's unfocused multiplier and startup burst, and `applyRuntimeFirewallRules` reinstalls the rates of every open window. Applets mounted later get the rates at mount. The [core page](../packages/core.md#the-kernl-surface) documents that surface. ## What a profile cannot change diff --git a/docs/how-tos/grant-and-revoke.md b/docs/how-tos/grant-and-revoke.md index 664d3d1..0bc941b 100644 --- a/docs/how-tos/grant-and-revoke.md +++ b/docs/how-tos/grant-and-revoke.md @@ -43,7 +43,7 @@ for (const handle of kernl.applets()) { `GrantRecord` is `{ identity, domains, capabilities, blocked, stateQuotaBytes }`. `domains` are the ones the user allowed beyond the baseline; `capabilities` is `capabilitiesForDomains([...BASELINE_DOMAINS, ...domains])`. -Every change commits the record, which does three things in order: persist it to `kernl:grants:v1`, write the full capability set into kehto's ACL (each capability in `ALL_CAPABILITIES` granted or revoked explicitly, then `block` or `unblock`, then `acl.persist()`), and emit the new `GrantRecord` on `changes$`. A call that changes nothing (allowing a domain twice, revoking one that was never allowed, allowing a baseline domain) commits nothing and emits nothing. +Every change commits the record, which does three things in order: persist it to `kernl:grants:v1`, write it into kehto's restrictive ACL (each capability the record needs granted, whatever the applet's entry holds beyond that revoked, then `block` or `unblock`, then `acl.persist()`), and emit the new `GrantRecord` on `changes$`. A call that changes nothing (allowing a domain twice, revoking one that was never allowed, allowing a baseline domain) commits nothing and emits nothing. - **`allowDomain`** refuses a domain the shell does not offer with `invalid:domain`. A newly allowed domain is in the ACL at once; a running applet was told its environment at mount and is not told again, so the domain appears in its `shell.init` the next time it is opened. - **`revokeDomain`** refuses the baseline with `denied:baseline-domain`. The ACL loses the domain's capabilities at once, and kehto refuses the applet's next envelope that needs one of them. diff --git a/docs/index.md b/docs/index.md index f7be756..b3cb5e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ kehto ships `@kehto/{acl,firewall,runtime,shell,services,nip,wm}` and deliberate | Protocols | One identity layer, many [bridges](./concepts/bridges.md) named by what they do for the user, never by the wire format. Web fetch runs in the shell behind an SSRF policy. Downloads, sharing and mail hand the address to an app on the user's device after the user confirms leaving kernl: kernl connects to nothing for them, and the device app's own network behaviour is outside kernl. | | Messaging | Any reasonable private-messaging protocol, exposed through one contract. [Providers](./concepts/messaging.md) describe their security properties truthfully and the shell does not rank them. `private`, `legacy` and the experimental `ratchet` work in the live shell when the signed-in account's signer can encrypt; the preview runs `private` and `legacy` over the in-memory backend. | -kernl does not fork kehto ([D-001](./decisions.md#d-001)). When kehto lacks a hook kernl needs, kernl composes around it and takes the gap upstream. The four gaps found so far, filed upstream as kehto/web #397–#400, are listed in [Alpha status](./alpha-status.md#upstream-gaps). +kernl does not fork kehto ([D-001](./decisions.md#d-001)). When kehto lacks a hook kernl needs, kernl composes around it and takes the gap upstream. The first four gaps, filed as kehto/web #397–#400, are released upstream and kernl uses them ([D-020](./decisions.md#d-020)); what is still open is in [Alpha status](./alpha-status.md#upstream-gaps). ## Three doors diff --git a/docs/packages/core.md b/docs/packages/core.md index da76b6f..1a45d90 100644 --- a/docs/packages/core.md +++ b/docs/packages/core.md @@ -45,11 +45,11 @@ if (plan.missingRequired.length === 0) kernl.mount(plan, iframe); 1. **Event layer.** `options.eventStore ?? new EventStore()`, `options.pool ?? new RelayPool()`, `options.accounts ?? createKernlAccounts(host.storage)`. A pool the shell created itself is closed by `destroy()`; a supplied pool is left open. 2. **Relay tiers.** `createRelayTiers(host.storage, options.relays ?? {})`. Persisted choices at `kernl:relays:v1` win over `options.relays`, which only overrides `DEFAULT_RELAY_TIERS` on a fresh device. 3. **Registries.** `options.messaging ?? createDefaultMessaging(...)`, with `createPrivateMessagesBackend` and `createLegacyMessagesBackend` as the `private` and `legacy` backends unless `options.messagingBackends` overrides them, and availability tracking the active account ([Messaging backends](#messaging-backends)), and `options.bridges ?? createDefaultBridges({ handoff: host.openLink, torrent: options.torrent })`, so downloads, sharing and mail hand off through the host's confirm-before-leaving opener and a `KernlOptions.torrent` backend, when given, downloads magnet links inside kernl instead ([D-017](../decisions.md#d-017)). The default bridge registry has no `authorize` hook, so in the composed shell a resource request is checked only by the HTTP bridge's SSRF policy and the applet's `resource` grant. -4. **Guard pipeline.** `createGuardPipeline` with `createGuardConfig(options.guard?.profile ?? 'strict')`, the host's `now` when given, `onEvent` feeding `kernl.guard.events$`, `getVisibility` delegated to `host.getVisibility`, and `onQuarantine` marking the handle revoked with reason `quarantine` and calling `bridge.revokeNapplet(windowId, 'host')`. +4. **Guard pipeline.** `createGuardPipeline` with `createGuardConfig(options.guard?.profile ?? 'strict')`, the host's `now` when given, `onEvent` feeding `kernl.guard.events$`, `getVisibility` delegated to `host.getVisibility`, `onQuarantine` marking the handle revoked with reason `quarantine` and calling `bridge.revokeNapplet(windowId, 'host')`, and `onRelease` calling `bridge.releaseDeferred(windowId)` when a window becomes visible. 5. **Services.** `createKernlServices` registers `relay`, `identity`, `theme`, `notify`, `link`, `resource`, `dm` and `outbox` over the pool, the tiers, the event store, the active signer, the consent gate, the host, the bridge registry and the messaging registry. The `outbox` service is wrapped so that `outbox.publish` passes the consent gate first, because kehto's outbox service signs without naming the window. `options.theme` is used only when `isTheme` accepts it. `resolveIdentity` maps a live window to its verified identity so the resource service names the requesting applet to the bridges. -6. **ShellAdapter.** `createShellAdapter` builds kehto's adapter from the relay-pool hooks, the relay-config hooks over the tiers, `createAuthHooks(accounts, consent)`, the service registry, `capabilities.resolveEnvironment` from `createEnvironmentResolver(grants)`, `onNappletRevoked` (removes the frame through the handle) and `onNappletMessage` (feeds `pipeline.observe` and refuses the envelope with `refuseCurrentDispatch` when it answers `defer` or `reject`). Choices kehto left open are fixed here: applets cannot create windows (`createWindow` returns null), hotkeys are not forwarded, there is no worker relay, update behaviour is `banner`, signatures are verified by applesauce's `verifyEvent`, `link.isAvailable` is true and `getNip66Suggestions` is null. +6. **ShellAdapter.** `createShellAdapter` builds kehto's adapter from the relay-pool hooks, the relay-config hooks over the tiers, `createAuthHooks(accounts, consent)`, the service registry, `capabilities.resolveEnvironment` from `createEnvironmentResolver(grants)`, `onNappletRevoked` (removes the frame through the handle, on every reason including `deferred-overflow`), `filterNappletMessage` (answers kehto with `pipeline.observe`: `dispatch`, `defer` or `drop`), `getFocusContext` (`{ focused }` from the pipeline's visibility), `onFirewallEvent` and `firewallPersistence` from `createRuntimeFirewallHooks` (runtime refusals become guard events without the message; the config is kept at `kernl:firewall:v1`), and `aclDefaultPolicy: 'restrictive'` ([D-020](../decisions.md#d-020)). Choices kehto left open are fixed here: applets cannot create windows (`createWindow` returns null), hotkeys are not forwarded, there is no worker relay, update behaviour is `banner`, signatures are verified by applesauce's `verifyEvent`, `link.isAvailable` is true and `getNip66Suggestions` is null. 7. **Bridge.** `createShellBridge(adapter)`, unchanged ([D-001](../decisions.md#d-001)). -8. **Grants.** `createGrants({ acl: bridge.runtime.aclState, storage: host.storage, availableDomains: () => buildShellCapabilities(adapter).domains })`. The grants layer writes kehto's ACL explicitly for every applet, see [Grants](#grants). +8. **Grants.** `createGrants({ acl: bridge.runtime.aclState, storage: host.storage, availableDomains: () => buildShellCapabilities(adapter).domains })`. The ACL is restrictive, so an applet holds only what the grants layer writes, see [Grants](#grants). `pipeline.profile$` is then followed: on boot and on every profile change `applyRuntimeFirewallProfile` sets the runtime's unfocused multiplier and startup burst, and `applyRuntimeFirewallRules` reinstalls the rates of every open window. 9. **Consent.** Before steps 5 and 6, `lookupReplaceable(eventStore, { loader: createReplaceableLoader(eventStore, pool, tiers), relays: () => [...outbox, ...discovery] })` builds the before lookup and `createSigningConsent({ host, lookupBefore, resolveApplet: identityOf, pubkey })` builds the gate, and both of the routes an applet's write can take pass through it: `relay.publish` is signed through `ShellAdapter.auth.getSigner(windowId)`, whose `signEvent` calls the gate first, and `outbox.publish` is gated in the outbox service wrapper before kehto signs it. For a kind `isDestructiveKind` accepts (every replaceable and addressable kind, 5 and 62), the gate describes the draft against the current version, from the event store or else from the outbox and discovery relays within `CURRENT_VERSION_TIMEOUT_MS`, calls `host.presentConsent` and waits; anything but yes rejects with `denied:consent` and nothing is signed. Routine kinds are not prompted; the shell's own signing (no window) is not gated; answers are not remembered. kehto's runtime has no destructive-signing consent of its own. Separately, `bridge.registerConsentHandler(createConsentHandler(host, lookupBefore, identityOf))` answers kehto's own `ConsentRequest`, which the runtime raises only for a firewall `prompt` policy and without an event, so the handler refuses it without asking. 10. **Session.** `accounts.active$` drives `session$` (`toSession(account)` or null) and calls `bridge.publishIdentityChanged(pubkey)` with the active pubkey or `''` on sign-out, so bound applets hear every identity change through kehto's live port. 11. **Lifecycle.** `createAppletLifecycle` with `options.artifactCache ?? openDefaultArtifactCache` and `host.fetchBlob ?? defaultFetchBlob`. @@ -104,7 +104,7 @@ The host is the only way the core reaches the user or the page. Applets never re 5. `bridge.observeFrame(frame, windowId)`, so a second load revokes the binding. 6. Assign `frame.srcdoc = plan.srcdoc`. The handle's status moves from `mounting` to `running` on the frame's first `load`. -A frame without a `contentWindow` throws `invalid:frame-detached`. `handle.close()` calls `bridge.revokeNapplet(windowId, 'host')`, unregisters the window, removes the frame and forgets the window in the guard; a revocation from the bridge (`window-message`, `second-hello`, `invalid-hello`, `second-load`, `port-closed`, `host`) removes the frame, forgets the guard state and calls `host.onRevoked`. The frame is never reused. `handle.setVisibility(visibility)` feeds the guard's visibility gate. +A frame without a `contentWindow` throws `invalid:frame-detached`. `handle.close()` calls `bridge.revokeNapplet(windowId, 'host')`, unregisters the window, removes the frame and forgets the window in the guard; a revocation from the bridge (`window-message`, `second-hello`, `invalid-hello`, `second-load`, `port-closed`, `deferred-overflow`, `host`) removes the frame, forgets the guard state and calls `host.onRevoked`. The frame is never reused. `handle.setVisibility(visibility)` feeds the guard's visibility gate; when the window becomes visible, the requests kehto held for it are released. `handleWindowMessage(event)` resolves the sending window from `originRegistry`, never from the message, and runs the guard pipeline in front of `bridge.handleMessage`. Messages from windows the shell does not know are forwarded unguarded so kehto can report them as unrouted. @@ -217,7 +217,7 @@ legacyMessages.subscribe({}, (message) => console.log(message.content)); | `llm` | `llm:generate` | | `cache` | `cache:read`, `cache:write` | -Unknown domains need no capability (`capabilitiesForDomain('made-up')` is `[]`). `createGrants` keeps a per-identity record `{ dTag, hash, domains, blocked }` at `kernl:grants:v1` and, on every commit, writes the whole capability set into kehto's ACL: every capability in `ALL_CAPABILITIES` is granted when a baseline or allowed domain needs it and revoked otherwise, then `block`/`unblock` is applied and the ACL persisted. `KernlGrants.ensure(identity)` does this for an applet that has no record yet; `plan` calls it. `createEnvironmentResolver(grants)` is the `capabilities.resolveEnvironment` hook: each applet sees the baseline plus its allowed domains, intersected with what the shell offers; a blocked applet sees only `shell`. The calls are in [Grant and revoke](../how-tos/grant-and-revoke.md). +Unknown domains need no capability (`capabilitiesForDomain('made-up')` is `[]`). `createGrants` keeps a per-identity record `{ dTag, hash, domains, blocked }` at `kernl:grants:v1` and, on every commit, writes it into kehto's ACL, which is restrictive (`aclDefaultPolicy`, [D-020](../decisions.md#d-020)): every capability a baseline or allowed domain needs is granted, whatever the applet's stored entry holds beyond that is revoked, then `block`/`unblock` is applied and the ACL persisted. `KernlGrants.ensure(identity)` does this for an applet that has no record yet; `plan` calls it. `createEnvironmentResolver(grants)` is the `capabilities.resolveEnvironment` hook: each applet sees the baseline plus its allowed domains, intersected with what the shell offers; a blocked applet sees only `shell`. The calls are in [Grant and revoke](../how-tos/grant-and-revoke.md). ### Consent summaries @@ -274,7 +274,7 @@ Signatures are abbreviated; the generated reference has the full ones. | `toSession` | `(account: IAccount) => Session` | Describes an account for the UI; `readOnly` when it cannot sign. | | `createAppletLifecycle` | `(context: LifecycleContext) => AppletLifecycle` | `resolve`, `plan`, `mount`, `applets`, `handleWindowMessage` over the composed parts. | | `resolveWindow` | `(handles: ReadonlyMap, event: MessageEvent) => WindowResolution \| null` | The sending window from `originRegistry`, never from the message. | -| `createShellAdapter` | `(deps: ShellAdapterDeps) => ShellAdapter` | kehto's adapter from kernl's parts, with the fixed choices listed under Composition. `deps.consent` (a `SigningConsent`) is required. | +| `createShellAdapter` | `(deps: ShellAdapterDeps) => ShellAdapter` | kehto's adapter from kernl's parts, with the fixed choices listed under Composition. `deps.consent` (a `SigningConsent`), `deps.filterNappletMessage`, `deps.getFocusContext` and `deps.runtimeFirewall` are required; the adapter always sets `aclDefaultPolicy: 'restrictive'`. | | `createKernlServices` | `(deps: KernlServiceDeps) => KernlServices` | `{ registry, theme, dm }` with kehto's eight reference services wired. `deps.consent` is required; `outbox.publish` passes it before signing, and a refusal is answered as `outbox.publish.result` with `ok: false` and the reason. | | `isTheme` | `(value: Record \| undefined) => value is Theme` | True when `colors.background`, `colors.text` and `colors.primary` are strings. | | `resourceInitFor` | `(deps, init: ResourceFetchInit) => ResourceResolveInit` | Names the requesting applet, or `'shell'`, to the bridge resolver. | @@ -303,12 +303,15 @@ Signatures are abbreviated; the generated reference has the full ones. | Export | Signature | Description | | --- | --- | --- | -| `createGuardPipeline` | `(options: GuardPipelineOptions) => GuardPipeline` | Ingress guard, visibility gate and focus firewall in front of the bridge; `handleWindowMessage` for window-level messages, `observe` for port traffic (its `defer` or `reject` means the caller must refuse the envelope; control envelopes always `pass`). | +| `createGuardPipeline` | `(options: GuardPipelineOptions) => GuardPipeline` | Ingress guard, visibility gate and focus firewall in front of the bridge; `handleWindowMessage` for window-level messages, `observe` for port traffic, which returns kehto's `NappletMessageDecision` (`dispatch`, `defer` for a hidden window's non-control envelope, `drop` for what the guard will not let through). `onRelease(windowId)` is called when a window becomes visible. | | `runtimeFirewallRules` | `(config: GuardConfig, napplet: string) => RuntimeFirewallRules` | The profile's per-class rates translated to the runtime's capability keys, plus the global rate. | -| `applyRuntimeFirewallRules` | `(firewallState, config: GuardConfig, napplet: string) => void` | Installs those rules in `bridge.runtime.firewallState` and persists. Sets no policy: kehto's `allow` policy would skip every rate, matcher and burst check. | -| `refuseCurrentDispatch` | `(firewallState, napplet: string) => void` | Sets the applet's runtime policy to `deny` for the envelope kehto is dispatching and restores the previous policy in a microtask. `createKernl` calls it whenever `pipeline.observe` answers `defer` or `reject` for a port envelope (size caps, bursts, the unfocused budget, a hidden window's non-control traffic), because kehto ignores `onNappletMessage`'s answer and its runtime cannot measure size, focus or visibility itself. | +| `applyRuntimeFirewallRules` | `(firewallState: RuntimeFirewallState, config: GuardConfig, napplet: string) => void` | Installs those rules in `bridge.runtime.firewallState` and persists. Sets no policy: kehto's `allow` policy would skip every rate, matcher and burst check. | +| `applyRuntimeFirewallProfile` | `(firewallState: RuntimeFirewallState, config: GuardConfig) => void` | Sets the profile's `unfocusedMultiplier` and `burstGuard` on the runtime firewall and persists. | +| `createRuntimeFirewallHooks` | `(options: RuntimeFirewallHooksOptions) => RuntimeFirewallHooks` | `firewallPersistence` over the host storage at `RUNTIME_FIREWALL_STORAGE_KEY` and `onFirewallEvent` feeding `options.onEvent` through `toGuardEvent`. | +| `toGuardEvent` | `(event: FirewallEvent, at: number) => GuardEvent` | A runtime firewall event in the guard's words (`rate:`, `flood:startup`, `runtime:`), without the applet's message. | +| `RUNTIME_FIREWALL_STORAGE_KEY` | `'kernl:firewall:v1'` | Where the runtime firewall's configuration is kept. | | `RUNTIME_OP_CLASSES` | `Readonly>` | Guard class to runtime key; `null` for `relay:publish:delete`, `control` and `generic`. See [Guard](../concepts/guard.md#where-enforcement-happens). | -| `GuardPipeline`, `GuardPipelineOptions`, `PipelineDisposition`, `WindowResolution`, `RuntimeFirewallRules`, `RuntimeRateRule` | types | `PipelineDisposition` is `GuardDisposition \| 'unrouted'`. | +| `GuardPipeline`, `GuardPipelineOptions`, `PipelineDisposition`, `WindowResolution`, `RuntimeFirewallRules`, `RuntimeRateRule`, `RuntimeFirewallState`, `RuntimeFirewallHooks`, `RuntimeFirewallHooksOptions` | types | `PipelineDisposition` is `GuardDisposition \| 'unrouted'`, the answer for window-level messages. Every use of the runtime firewall's own configuration is in this one module, `guard/runtime-rules.ts`, because [kehto/web#336](https://github.com/kehto/web/pull/336) replaces it ([D-020](../decisions.md#d-020)). | ### Grants and consent diff --git a/docs/packages/guard.md b/docs/packages/guard.md index 6446018..2931d11 100644 --- a/docs/packages/guard.md +++ b/docs/packages/guard.md @@ -124,7 +124,7 @@ describeMitigations(config).find((m) => m.id === 'ingress-flood')?.limit; // '8 - Does not decide grants. Whether a domain is allowed is `@kehto/acl`'s question, surfaced through `@kernl/core`. - Does not touch the shell's DOM or the iframe. The shell applies `createAppletCsp(profile)` through kehto's `prepareNappletSrcdoc`, with kehto's nested-context lock enabled, and `planMount` in `@kernl/core` inserts the insertion lock's script, which runs only inside the applet realm. - Does not carry the Trusted Types policy, the element-factory pins or the static frame scan. Those are kehto's (`experimental.nestedContextLock`, `assertNappletHasNoFrame`). -- Does not supply `initElapsedMs` to kehto, so `GuardConfig.firewall.burstGuard` never fires inside this package. The startup burst that does fire is kehto's runtime firewall's own, at kehto's default of 20 operations in the first 3 s ([Guard](../concepts/guard.md#layers)). +- Does not supply `initElapsedMs` to kehto, so `GuardConfig.firewall.burstGuard` never fires inside this package. `@kernl/core` installs it, with `unfocusedMultiplier`, in kehto's runtime firewall, which supplies `initElapsedMs` and focus, so the profile's startup burst applies there ([Guard](../concepts/guard.md#layers)). ## Specification diff --git a/docs/policies/security-posture.md b/docs/policies/security-posture.md index 253b659..2b08eef 100644 --- a/docs/policies/security-posture.md +++ b/docs/policies/security-posture.md @@ -18,7 +18,7 @@ The rules the shell keeps when it runs untrusted code next to the user's identit 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 narrowing.** `resolveEnvironment` returns only granted domains; kehto intersects, so the hook can only remove. The runtime's permissive default is never reached for an ungranted domain. +12. **Restrictive by default and by narrowing.** The runtime ACL is restrictive (`aclDefaultPolicy: 'restrictive'`, [D-020](../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 @@ -30,7 +30,7 @@ The rules the shell keeps when it runs untrusted code next to the user's identit 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](../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 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 controllable.** The visibility gate holds only a hidden window's window-level `hello`. Port traffic is judged just before kehto dispatches it; it cannot be held there ([D-012](../decisions.md#d-012)), so while a window is not visible every non-control envelope is refused for that one dispatch (`refuseCurrentDispatch`) and the applet gets a `firewall:` error it can retry once visible. Control envelopes (`shell.*`, `inc.*`, `theme.*`, closes) are answered, so a hidden window can still be closed or themed. A `resource.*` request past the hidden allowance quarantines the window, and that request is not dispatched. +18. **Hidden windows cannot act, but stay closable.** Port traffic is decided before kehto's runtime sees it, through `filterNappletMessage` ([D-020](../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 diff --git a/docs/policies/spec-conformance.md b/docs/policies/spec-conformance.md index 75da5b1..8c8c710 100644 --- a/docs/policies/spec-conformance.md +++ b/docs/policies/spec-conformance.md @@ -19,8 +19,8 @@ kernl keeps no local copy of a specification text. A copy drifts; a link does no When kernl needs something a specification or an upstream package does not provide, the order is: -1. **Compose it from what exists.** Most of kernl is this: the guard composes `@kehto/firewall`'s `evaluate()`, the restrictive posture composes `resolveEnvironment` and `aclState.grant`. -2. **Propose it upstream.** An issue or pull request on `kehto/web`, `napplet/naps` or `nostr-protocol/nips`, as appropriate. The four kehto gaps kernl has found, filed upstream as kehto/web #397–#400, are listed in [Alpha status](../alpha-status.md#upstream-gaps). +1. **Compose it from what exists.** Most of kernl is this: the guard composes `@kehto/firewall`'s `evaluate()` and kehto's `filterNappletMessage`, the restrictive posture composes `aclDefaultPolicy`, `resolveEnvironment` and `aclState.grant`. +2. **Propose it upstream.** An issue or pull request on `kehto/web`, `napplet/naps` or `nostr-protocol/nips`, as appropriate. The first four kehto gaps kernl found, filed as kehto/web #397–#400, are released and in use ([D-020](../decisions.md#d-020)); what is still open is in [Alpha status](../alpha-status.md#upstream-gaps). 3. **Implement it locally with a recorded gap.** Only when 1 is impossible and 2 is pending. The local implementation sits behind an interface, cites the upstream issue, and is removed when upstream lands. kernl never re-implements a kehto primitive because it was easier than reading kehto's source, and never invents wire format. When a NAP is ambiguous, kernl prefers an upstream issue over a guess and records the ambiguity next to the code. diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 7370def..598f56d 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -65,26 +65,29 @@ Thrown reasons are the `message` of an `Error`. A few carry a detail after a spa The guard reports each non-pass decision as a `GuardEvent` with a `disposition` (`defer`, `reject`, `quarantine`, `flag`) and one of these reasons. They reach the user through `kernl.guard.events$` and the shell's guard panel. The numbers are per profile; see [Guard](../concepts/guard.md#profiles). -For an applet's port traffic these layers observe each envelope just before kehto dispatches it but cannot hold it ([D-012](../decisions.md#d-012)): an event records what happened. Two reasons change the outcome. `size:` makes kehto's runtime firewall refuse that envelope (`refuseCurrentDispatch`), and `quarantine` revokes the binding, which also stops the envelope that crossed the limit. Otherwise `defer` and `reject` hold or drop only the window-level `hello`. +For an applet's port traffic these layers decide each envelope through kehto's `filterNappletMessage` before the runtime sees it ([D-020](../decisions.md#d-020)). A `reject` drops the envelope, and the applet gets no reply. A `defer` with reason `hidden:` holds it until the window is visible; a `flood:burst` `defer` on port traffic drops it. A `quarantine` revokes the binding, which also stops the envelope that crossed the limit and discards what the window held. For the window-level `hello`, `defer` and `reject` hold or drop it. -Refusals by kehto's runtime firewall (the per-class rates and the startup burst) are not guard events: kehto answers the applet with an error whose message starts `firewall:`, and `ShellAdapter` does not forward the runtime's firewall events to the host ([upstream gap 1](../alpha-status.md#upstream-gaps)). The focus firewall's `rate:` events count the same rates, so the user still sees an applet that runs out. +kehto's runtime firewall refuses what it handles over the per-class rates, the unfocused budget or the startup burst with an error whose message starts `firewall:`, and reports each refusal through `onFirewallEvent`; kernl turns those into guard events with the reasons in the last three rows, without the applet's message. | Reason | Emitted by | Disposition | Meaning | | --- | --- | --- | --- | -| `flood:burst` | ingress guard (`ingress-guard.ts`) | `defer` | More messages in one second than `ingressBurst` allows. A `hello` is held; for port traffic this is a report. | -| `flood:queue-full` | ingress guard | `reject` | More than `ingressQueue` messages over the burst. A `hello` is dropped; for port traffic this is a report. | +| `flood:burst` | ingress guard (`ingress-guard.ts`) | `defer` | More messages in one second than `ingressBurst` allows. A `hello` is held; a port envelope other than a control one is dropped. | +| `flood:queue-full` | ingress guard | `reject` | More than `ingressQueue` messages over the burst. A `hello` is dropped; a port envelope other than a control one is dropped. | | `flood:quarantine` | ingress guard | `quarantine` | `quarantineThreshold` messages in a rolling 10 s, or any message from a window already quarantined. The binding is revoked. | | `flood:hidden-resource` | ingress guard | `quarantine` | A hidden window made more `resource.*` requests than `hiddenResourceAllowance`. The binding is revoked. | -| `size:` | ingress guard | `reject` | The envelope is over the size cap for `resource:bytes`, `relay:subscribe` or `outbox:subscribe`. In the composed shell the runtime firewall refuses it before dispatch, so the applet receives a `firewall:` error and nothing is fetched. | -| `defer:queue-full` | visibility gate (`visibility-gate.ts`) | `reject` | The hidden window's deferral queue is full. Only the window-level `hello` passes through the gate. | -| `rate:` | focus firewall (`focus-firewall.ts`) | `reject`, or `flag` when the rate's action is `flag` | kehto's `rate:opclass`, `rate:global` or `rate:default` rule fired; the reason names the class, for example `rate:relay:publish`. | +| `size:` | ingress guard | `reject` | The envelope is over the size cap for `resource:bytes`, `relay:subscribe` or `outbox:subscribe`. In the composed shell the envelope is dropped before dispatch: nothing is fetched and the applet gets no reply. | +| `hidden:` | guard pipeline (`pipeline.ts`) | `defer` | A non-control port envelope from a window that is not visible. It is held and handled once the window is visible. | +| `defer:queue-full` | visibility gate (`visibility-gate.ts`) | `reject` | The hidden window already holds `deferQueue` envelopes; this one is dropped. | +| `rate:` | focus firewall (`focus-firewall.ts`), or the runtime firewall through `onFirewallEvent` | `reject`, or `flag` when the rate's action is `flag` | kehto's `rate:opclass`, `rate:global` or `rate:default` rule fired; the reason names the class, for example `rate:relay:publish`. From the focus firewall the envelope is dropped; from the runtime it is refused with a `firewall:` error. | | `matcher:` | focus firewall | `reject` | A kehto content matcher fired; the rule id is passed through unchanged. The guard's own matchers are `size:resource:bytes`, `size:relay:subscribe` and `size:outbox:subscribe`, and in the composed shell they cannot fire: the ingress guard rejects the same envelopes with `size:` first, and the focus firewall sees only what it passed. A caller that runs the focus firewall alone can see `matcher:size:`. | | `policy:deny` | focus firewall | `reject` | The applet's policy in the guard's own firewall configuration is `deny`. kernl sets no policy there, so this appears only if a caller set one. | | `prompt:` | focus firewall | `reject` | kehto's engine asked for a prompt (for example `prompt:policy:ask`); the guard has no UI and treats it as a rejection. | +| `flood:startup` | runtime firewall, through `onFirewallEvent` | `reject` | More operations in a window's first second than the profile's `ingressBurst`; the runtime refuses the excess with a `firewall:` error. | +| `runtime:` | runtime firewall, through `onFirewallEvent` | `reject`, or `flag` when it passed with a flag | Any other runtime firewall decision, with kehto's rule id, for example `runtime:policy:deny`. | ## Revocation reasons -`KernlHost.onRevoked(windowId, reason)` receives kehto's revocation reasons (`window-message`, `second-hello`, `invalid-hello`, `second-load`, `port-closed`, `host`) or `quarantine` when the guard quarantined the window. See [Architecture](../concepts/architecture.md#the-mount-path). +`KernlHost.onRevoked(windowId, reason)` receives kehto's revocation reasons (`window-message`, `second-hello`, `invalid-hello`, `second-load`, `port-closed`, `deferred-overflow`, `host`) or `quarantine` when the guard quarantined the window. See [Architecture](../concepts/architecture.md#the-mount-path). ## Related diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f7f340..dd422a5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,14 +4,13 @@ Ordered by the tie-breakers in `SOUL.md`: the user's safety and agency first, pr ## 1. Upstream pull requests to kehto -Filed as [kehto/web #397](https://github.com/kehto/web/issues/397), [#398](https://github.com/kehto/web/issues/398), [#399](https://github.com/kehto/web/issues/399) and [#400](https://github.com/kehto/web/issues/400). Each item shrinks kernl when it lands; see [Alpha status](./alpha-status.md#upstream-gaps). - -- Forward `getFocusContext`, `onFirewallEvent` and `firewallPersistence` through `ShellAdapter` so the runtime's own firewall can be focus-aware without a second engine in front of it. -- Let the host choose the ACL default policy in `createRuntime`, so a restrictive shell does not have to write every capability explicitly. -- Offer a hook that can hold or drop port traffic, so the visibility gate can hold a hidden applet's envelopes instead of only observing them before dispatch. -- Release `experimental.nestedContextLock` in `@kehto/shell`, so kernl can pass the flag to `prepareNappletSrcdoc` and delete its port of the lock ([D-014](./decisions.md#d-014)). -- Let the runtime firewall measure envelopes without an `event`, so its size matchers see downloads and queries and kernl can drop `refuseCurrentDispatch` ([D-012](./decisions.md#d-012)). -- Expose a setter for the runtime firewall's `unfocusedMultiplier`. Together with the forwarded `getFocusContext`, it lets the runtime refuse an unfocused window at the profile's tightened budget instead of treating every window as focused. +The four gaps filed as [kehto/web #397](https://github.com/kehto/web/issues/397), [#398](https://github.com/kehto/web/issues/398), [#399](https://github.com/kehto/web/issues/399) and [#400](https://github.com/kehto/web/issues/400) are released and in use ([D-020](./decisions.md#d-020)). What is left: + +- Follow [kehto/web#336](https://github.com/kehto/web/pull/336) when it is released. It makes kehto's firewall a shared approval authority and moves rate limiting into a separate runtime budget. kernl will move `packages/core/src/guard/runtime-rules.ts` from the firewall setters, `onFirewallEvent` and `firewallPersistence` to `permissions.setRateLimit` and `onRateLimit`, drop `kernl:firewall:v1`, and decide how admission approvals meet the restrictive ACL. `getFocusContext`, `aclDefaultPolicy`, `filterNappletMessage` and `releaseDeferred` stay. +- Ask for an event when a stored deny refuses a request on a host that configures #336's permissions, so the host can show it; until then kernl's own guard layers report what they refuse. +- Ask for a reply kehto can send for a request the host drops, so an applet does not wait for an answer that will not come. NAP defines none today. +- Let the runtime measure envelopes without an `event`, so its size limits see downloads and queries and kernl's size caps could move into kehto. +- Report the four creation routes past `experimental.nestedContextLock` and ask kehto to guard insertion, so kernl can drop its insertion lock ([D-014](./decisions.md#d-014)). ## 2. Bridge backends diff --git a/docs/tutorials/write-an-applet.md b/docs/tutorials/write-an-applet.md index b69a9b5..1949778 100644 --- a/docs/tutorials/write-an-applet.md +++ b/docs/tutorials/write-an-applet.md @@ -99,7 +99,7 @@ What each part does: - **Feature detection, not assumption.** `napplet.shell.supports('relay')` is true only when the shell put `relay` on `window.napplet`, which in kernl means the user granted it. The same holds for `identity`: kernl's baseline is `shell`, `inc`, `theme` and `storage`, so an applet is not told who is signed in until the user grants "your account". Check `supports('identity')` before you read it, and say what the grant would add. - **`shell.ready()`** waits for the shell's answer to the applet's `hello`, so the domains are settled before the applet reads them. - **Routine writes only.** A kind 1 note is routine, so kernl signs it without a prompt. If your applet updated the user's profile (kind 0) or deleted something (kind 5), kernl would show the user what changes and wait; a refusal arrives as an error that names `denied:consent`. -- **Errors are shown, not thrown away.** A publish over the shell's budget is refused with an error that starts `firewall:`; the applet prints it. +- **Errors are shown, not thrown away.** A publish the shell refuses fails with an error, for example one starting `firewall:` or `denied:`, and the applet prints it. A publish kernl drops for going over its budget gets no reply at all ([D-020](../decisions.md#d-020)); at one post per click this applet never gets near the budget. - **No frames, no Trusted Types policy of its own.** The list is built with `createElement` and `textContent`. kernl refuses to mount markup that contains an `iframe`, `object`, `embed` or other frame element, makes every attempt to create one throw `denied:nested-frame`, and reserves the Trusted Types `default` policy for that lock, so an applet must not create a frame or register a Trusted Types policy ([Applets under kernl](../concepts/applets-under-kernl.md#what-the-strict-profile-means-for-you)). The file has no network access of its own and needs none: inline script and style are allowed, and everything else goes through the shell ([No network path](../concepts/guard.md#no-network-path)). @@ -112,15 +112,15 @@ Under kernl's `strict` profile, which every load starts in, the limits that matt | What the applet does | Limit in `strict` | What happens past it | | --- | --- | --- | -| Posts notes (`relay.publish`) | 20 per 60 s per applet | kehto's runtime firewall refuses the publish; the applet sees an error starting `firewall:` | -| Reads the network (`relay.subscribe`) | 30 per 60 s | refused the same way | -| Sends messages to the shell in its first 3 s | 20 | kehto's runtime firewall refuses the rest of the burst with a `firewall:` error | -| Sends messages to the shell at all | 8 per second | requests past it are refused with a `firewall:` error; control messages still pass | -| Is not the focused window | 25% of each rate above | refused with a `firewall:` error until it is focused | -| Asks for anything while hidden, other than control messages | none | refused with a `firewall:` error; retry once visible | +| Posts notes (`relay.publish`) | 20 per 60 s per applet | kernl drops the publish with no reply; the runtime enforces the same budget with a `firewall:` error for requests it handles | +| Reads the network (`relay.subscribe`) | 30 per 60 s | dropped the same way | +| Sends messages to the shell in its first second | 8 | kehto's runtime firewall refuses the rest of the burst with a `firewall:` error | +| Sends messages to the shell at all | 8 per second | requests past it are dropped with no reply; control messages still pass | +| Is not the focused window | 25% of each rate above | dropped with no reply until it is focused | +| Asks for anything while hidden, other than control messages | 200 held | held, and answered once the window is visible; past 200, dropped with no reply | | Sends messages to the shell at all | 300 in any 10 s | the applet is quarantined: the message that crossed the limit is dropped and the applet is closed | | Downloads while hidden (`resource.*`) | 8 | the ninth quarantines the applet | -| Asks for a download or a query larger than the cap | 64 KiB for `resource.*`, 256 KiB for subscriptions and queries | refused before it is handled, with a `firewall:` error | +| Asks for a download or a query larger than the cap | 64 KiB for `resource.*`, 256 KiB for subscriptions and queries | dropped before it is handled, with no reply | This applet sends a handful of messages at start-up, opens one subscription and publishes once per click, so it stays far inside every limit. The full list is in [Guard](../concepts/guard.md#profiles). diff --git a/package.json b/package.json index f33ecb0..dadba9c 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ }, "devDependencies": { "@changesets/cli": "^2.30.0", - "@napplet/core": "^0.37.0", - "@napplet/nap": "^0.36.0", - "@napplet/shim": "^0.35.0", + "@napplet/core": "^0.38.0", + "@napplet/nap": "^0.37.0", + "@napplet/shim": "^0.36.0", "@playwright/test": "^1.64.1", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^5.1.0", diff --git a/packages/core/features/grants.feature b/packages/core/features/grants.feature index d3e3ce6..d042b8f 100644 --- a/packages/core/features/grants.feature +++ b/packages/core/features/grants.feature @@ -1,11 +1,11 @@ Feature: Grants A grant is something the user gave and can take back. Every applet starts with the read-only, no-network baseline, which does not include the signed-in - account; kehto's permissive ACL default is overwritten explicitly for every - applet the shell runs (D-001). + account. The runtime ACL is restrictive, so an applet holds nothing the + grants layer did not write (D-020). Background: - Given a kehto ACL state in memory + Given a restrictive kehto ACL state in memory And kernl grants over empty storage offering "shell,inc,theme,identity,storage,relay,notify,resource" Scenario: the baseline is read-only and offline @@ -24,6 +24,16 @@ Feature: Grants And the ACL denies "relay:write" for "chat" And 1 grant changes were observed + Scenario: an applet the shell never ran holds nothing + Then the ACL denies "theme:read" for "stranger" + And the ACL denies "relay:read" for "stranger" + + Scenario: an ACL entry from an earlier posture is narrowed to the record + Given the ACL already grants "relay:read" to "chat" + When the shell ensures grants for "chat" + Then the ACL denies "relay:read" for "chat" + And the ACL allows "theme:read" for "chat" + Scenario: allowing a domain grants its capabilities When the shell allows "relay" for "chat" Then the grant record for "chat" has domains "relay" and is not blocked diff --git a/packages/core/features/guard-pipeline.feature b/packages/core/features/guard-pipeline.feature index 6478e4d..b3c9175 100644 --- a/packages/core/features/guard-pipeline.feature +++ b/packages/core/features/guard-pipeline.feature @@ -1,8 +1,8 @@ Feature: Guard pipeline The guard sits in front of kehto's bridge: ingress flood control, then the visibility gate, then the focus-aware firewall. Window-level messages can be - held or dropped; port traffic is judged just before kehto dispatches it, and - whatever the pipeline will not let through is refused by the caller (D-012). + held or dropped. Port traffic is decided before kehto's runtime sees it: it + is dispatched, held until the window is visible, or dropped (D-020). Background: Given a kernl guard pipeline using the "strict" profile with a controlled clock @@ -62,11 +62,11 @@ Feature: Guard pipeline And at least 20 messages were forwarded And the pipeline emitted guard events with reasons "rate:relay:publish" - Scenario: port traffic is observed and a flood quarantines the window + Scenario: port traffic is decided and a flood quarantines the window When the pipeline observes a port envelope of type "storage.get" from window "w1" - Then the pipeline disposition was "pass" + Then the pipeline disposition was "dispatch" When the pipeline observes port envelopes of type "storage.get" from window "w1" until it quarantines, at most 400 - Then the pipeline disposition was "quarantine" + Then the pipeline disposition was "drop" And the quarantine callback named window "w1" and applet "chat" And window "w1" is quarantined When the pipeline releases window "w1" @@ -74,9 +74,9 @@ Feature: Guard pipeline Scenario: odd port payloads are classified as unknown and deletes by kind When the pipeline observes a port envelope that is not an object from window "w1" - Then the pipeline disposition was "pass" + Then the pipeline disposition was "dispatch" When the pipeline observes a kind 5 "relay.publish" port envelope from window "w1" - Then the pipeline disposition was "pass" + Then the pipeline disposition was "dispatch" Scenario: visibility for a window the pipeline has not met comes from the host Given the host reports window "w2" as hidden @@ -91,26 +91,44 @@ Feature: Guard pipeline When a window message of type "napplet.hello" arrives from window "w1" Then the pipeline disposition was "pass" - Scenario: a hidden window's port traffic is rejected while its control messages pass + Scenario: a hidden window's port traffic is held until it is visible, while its control messages pass Given pipeline window "w1" is hidden When the pipeline observes a port envelope of type "relay.subscribe" from window "w1" - Then the pipeline disposition was "reject" + Then the pipeline disposition was "defer" And the pipeline emitted guard events with reasons "hidden:relay:subscribe" - When the pipeline observes a port envelope of type "shell.ready" from window "w1" - Then the pipeline disposition was "pass" + And the pipeline asked to release nothing + When the pipeline observes a port envelope of type "theme.get" from window "w1" + Then the pipeline disposition was "dispatch" + When the pipeline marks window "w1" visible + Then the pipeline asked to release window "w1" - Scenario: a hidden window's port traffic is rejected with default options too + Scenario: a hidden window's port traffic is held with default options too Given a kernl guard pipeline using the "strict" profile with default options And pipeline window "w1" runs applet "chat" And pipeline window "w1" is hidden When the pipeline observes a port envelope of type "relay.subscribe" from window "w1" - Then the pipeline disposition was "reject" + Then the pipeline disposition was "defer" + When the pipeline marks window "w1" visible + And the pipeline observes a port envelope of type "relay.subscribe" from window "w1" + Then the pipeline disposition was "dispatch" + + Scenario: a hidden window cannot hold more port requests than the defer queue + Given pipeline window "w1" is hidden + When the pipeline observes 201 paced port envelopes of type "relay.subscribe" from window "w1" + Then the pipeline disposition was "drop" + And the pipeline emitted guard events with reasons "hidden:relay:subscribe,defer:queue-full" - Scenario: a port burst above the per-second rate is deferred for refusal, but control is not + Scenario: a port burst above the per-second rate is dropped, but control is not When the pipeline observes 9 port envelopes of type "relay.subscribe" from window "w1" - Then the pipeline disposition was "defer" + Then the pipeline disposition was "drop" + And the pipeline emitted guard events with reasons "flood:burst" When the pipeline observes a port envelope of type "shell.ready" from window "w1" - Then the pipeline disposition was "pass" + Then the pipeline disposition was "dispatch" + + Scenario: a port request over the firewall's budget is dropped + When the pipeline observes 25 paced port envelopes of type "intent.invoke" from window "w1" + Then the pipeline disposition was "drop" + And the pipeline emitted guard events with reasons "rate:intent:invoke" Scenario: changing the profile rebuilds the layers but keeps visibility Given pipeline window "w1" is hidden @@ -118,3 +136,14 @@ Feature: Guard pipeline Then the pipeline profile is "relaxed" When a window message of type "relay.subscribe" arrives from window "w1" Then the pipeline disposition was "defer" + + Scenario Outline: the runtime firewall's reports are told in the guard's words, without the message + When the runtime firewall reports rule "" with decision "" for "" + Then the guard was told of a "" of "" because "", and nothing else + + Examples: + | rule | decision | runtime class | disposition | op class | reason | + | rate:opclass | reject | state:write | reject | storage:write | rate:storage:write | + | burst | reject | relay:read | reject | relay:subscribe | flood:startup | + | policy:deny | prompt | made:up | reject | generic | runtime:policy:deny | + | matcher:big | pass | relay:write | flag | relay:publish | runtime:matcher:big | diff --git a/packages/core/features/kernl.feature b/packages/core/features/kernl.feature index 2c4c14b..05c42e1 100644 --- a/packages/core/features/kernl.feature +++ b/packages/core/features/kernl.feature @@ -15,6 +15,8 @@ Feature: The composed shell And the shell messaging provider "private" is unavailable And the shell bridges are "http,torrent,nzb,soulseek,mail" And the frame policy meta locks frame navigation + And the runtime ACL refuses "relay:read" to an applet the user never granted anything + And the runtime firewall scales unfocused budgets by 0.25 and allows 8 operations in a window's first 1000 ms Scenario: the shell's downloads, sharing and mail hand off through the host's link opener Given a kernl shell over that host and pool without a blob fetcher @@ -212,65 +214,79 @@ Feature: The composed shell When the applet asks "identity.getPublicKey" over its port Then the applet received a "identity.getPublicKey.result" - Scenario: an oversized download request is refused before any service sees it + Scenario: an oversized download request is dropped before any service sees it Given a kernl shell over that host and pool with fake bridges And a mounted applet granted "resource" that has said hello When the applet asks "resource.bytes" for an address of 65 KiB over its port - Then the applet received a "resource.bytes.error" naming the firewall + Then the oversized "resource.bytes" request got no answer And the fake bridges were asked for nothing And the shell guard events included "size:resource:bytes" When the applet asks "resource.bytes" for "https://files.example/a.txt" over its port Then the applet received a "resource.bytes.result" And the fake bridges were asked for "https://files.example/a.txt" by applet "chat" - Scenario: an applet you are not using is refused past its smaller budget until you focus it + Scenario: an applet you are not using is stopped past its smaller budget until you focus it Given a kernl shell over that host and pool with fake bridges and a manual clock And a mounted applet granted "resource" that has said hello When the applet window becomes "visible but not focused" And the applet sends 25 "storage.set" requests over its port, 125 ms apart Then "storage.set" requests 1 to 15 were answered - And at least one "storage.set" request was refused by the firewall + And at least one "storage.set" request got no answer And the shell guard events included "rate:storage:write" When the applet window becomes "focused" And the applet sends 1 "storage.set" requests over its port, 125 ms apart Then "storage.set" requests 26 to 26 were answered - Scenario: a burst above the per-second rate is refused, and control messages still get through + Scenario: a burst above the per-second rate is dropped, and control messages still get through Given a kernl shell over that host and pool with fake bridges and a manual clock And a mounted applet granted "resource" that has said hello - When the applet sends 8 "resource.bytes" requests over its port, 0 ms apart - Then "resource.bytes" requests 1 to 6 were answered - And "resource.bytes" requests 7 to 8 were refused by the firewall + When the applet sends 9 "resource.bytes" requests over its port, 0 ms apart + Then "resource.bytes" requests 1 to 7 were answered + And "resource.bytes" requests 8 to 9 got no answer And the shell guard events included "flood:burst" When the applet sends 1 "storage.get" requests over its port, 0 ms apart Then "storage.get" requests 1 to 1 were answered - Scenario: a hidden applet's requests are refused until it is visible, while control messages are answered + Scenario: a hidden applet's requests are held until it is visible, while control messages are answered Given a kernl shell over that host and pool with fake bridges and a manual clock And a mounted applet granted "resource" that has said hello When the applet window becomes "hidden" - And the applet sends 1 "resource.bytes" requests over its port, 1000 ms apart - Then "resource.bytes" requests 1 to 1 were refused by the firewall + And the applet sends 1 "theme.get" requests over its port, 1000 ms apart + Then "theme.get" requests 1 to 1 were answered + When the applet sends 1 "resource.bytes" requests over its port, 1000 ms apart + Then "resource.bytes" requests 1 to 1 got no answer And the fake bridges were asked for nothing And the shell guard events included "hidden:resource:bytes" - When the applet sends 1 "storage.get" requests over its port, 1000 ms apart - Then "storage.get" requests 1 to 1 were answered When the applet window becomes "focused" - And the applet sends 1 "resource.bytes" requests over its port, 1000 ms apart - Then "resource.bytes" requests 2 to 2 were answered + Then "resource.bytes" requests 1 to 1 were answered And the fake bridges were asked for "https://files.example/a.txt" by applet "chat" - Scenario: a refused dispatch puts the runtime policy back as it was + Scenario: held requests meet the runtime's smaller unfocused budget when they are released + Given a kernl shell over that host and pool with fake bridges and a manual clock + And a mounted applet granted "resource" that has said hello + And the applet's startup window has passed + When the applet window becomes "hidden" + And the applet sends 20 "storage.set" requests over its port, 125 ms apart + Then "storage.set" requests 1 to 20 got no answer + When the applet window becomes "visible but not focused" + Then "storage.set" requests 1 to 15 were answered + And "storage.set" requests 16 to 20 were refused by the firewall + And the shell guard events included "rate:storage:write" + And no guard event carries the applet's message + + Scenario: a window kehto revokes for holding too many messages is removed Given a kernl shell over that host and pool - And the runtime firewall holds the policy "ask" for "asker" - When the shell refuses the current dispatch for "asker" and for "stranger" - Then during that dispatch the runtime firewall policies were "deny" and "deny" - And afterwards the runtime firewall policy for "asker" is "ask" and for "stranger" is "none" + And a mounted applet + When kehto revokes the mounted window for "deferred-overflow" + Then the host was told the window was revoked for "deferred-overflow" + And the mounted frame was removed + And the shell has 0 applets - Scenario: mounting installs the profile's rates without bypassing them + Scenario: mounting installs the profile's rates without bypassing them, and stores them through the host Given a kernl shell over that host and pool And a mounted applet Then the runtime firewall holds no policy for "chat" and a "resource:fetch" rate of 120 per 10000 ms + And the host storage holds the runtime firewall config under "kernl:firewall:v1" Scenario: messages from windows the shell does not know go to the bridge unguarded Given a kernl shell over that host and pool with fake bridges @@ -388,9 +404,12 @@ Feature: The composed shell Scenario: the guard facade changes the profile and lifts quarantine Given a kernl shell over that host and pool + And a mounted applet When the shell guard profile is set to "relaxed" Then the shell guard profile is "relaxed" with mitigations described And the shell guard profile stream ends with "relaxed" + And the runtime firewall scales unfocused budgets by 0.75 and allows 24 operations in a window's first 1000 ms + And the runtime firewall holds no policy for "chat" and a "resource:fetch" rate of 360 per 10000 ms And releasing window "applet-1" from the guard does nothing harmful Scenario: destroying the shell closes every applet and the bridge diff --git a/packages/core/features/services.feature b/packages/core/features/services.feature index 9525e72..a99e0b0 100644 --- a/packages/core/features/services.feature +++ b/packages/core/features/services.feature @@ -76,6 +76,7 @@ Feature: Shell services and adapter Then the adapter creates no windows, has no worker relay, forwards no hotkeys and shows update banners And the adapter verifies a signed event and rejects a forged one And the adapter advertises the link domain and its services + And the adapter keeps a restrictive ACL and asks the shell about focus and every port message When the adapter adds "wss://relay.mine" to tier "outbox" and "wss://ignored" to tier "nonsense" Then the adapter relay config has outbox "wss://relay.mine" When the adapter removes "wss://relay.mine" from tier "outbox" and "wss://ignored" from tier "nonsense" diff --git a/packages/core/features/steps/grants.ts b/packages/core/features/steps/grants.ts index d60fdde..a4758c5 100644 --- a/packages/core/features/steps/grants.ts +++ b/packages/core/features/steps/grants.ts @@ -37,9 +37,13 @@ function create(world: CoreWorld, available: string[]): void { state.available = available; } -Given('a kehto ACL state in memory', (world: CoreWorld) => { +Given('a restrictive kehto ACL state in memory', (world: CoreWorld) => { let persisted: string | null = null; - scratch(world).acl = createAclState({ persist: (data) => void (persisted = data), load: () => persisted }); + scratch(world).acl = createAclState({ persist: (data) => void (persisted = data), load: () => persisted }, 'restrictive'); +}); + +Given('the ACL already grants {string} to {string}', (world: CoreWorld, capability: Capability, dTag: string) => { + acl(world).grant('', dTag, HASH, capability); }); Given('kernl grants over empty storage offering {string}', (world: CoreWorld, domains: string) => { diff --git a/packages/core/features/steps/guard-pipeline.ts b/packages/core/features/steps/guard-pipeline.ts index 5623990..a08605f 100644 --- a/packages/core/features/steps/guard-pipeline.ts +++ b/packages/core/features/steps/guard-pipeline.ts @@ -1,7 +1,9 @@ import { expect } from 'vitest'; import { Given, Then, When } from 'quickpickle'; import { createGuardConfig, type GuardEvent, type GuardProfile, type WindowVisibility } from '@kernl/guard'; -import { createGuardPipeline, type GuardPipeline, type PipelineDisposition, type WindowResolution } from '@kernl/core'; +import type { NappletMessageDecision } from '@kehto/shell'; +import type { Decision } from '@kehto/firewall'; +import { createGuardPipeline, createRuntimeFirewallHooks, type GuardPipeline, type PipelineDisposition, type WindowResolution } from '@kernl/core'; import { scratch, splitList, type CoreWorld } from './world.js'; interface PipelineRecord { @@ -12,7 +14,8 @@ interface PipelineRecord { origins: WeakMap; hostVisibility: Map; quarantines: Array<{ windowId: string; napplet: string }>; - disposition?: PipelineDisposition; + released: string[]; + disposition?: PipelineDisposition | NappletMessageDecision; profiles: GuardProfile[]; } @@ -21,7 +24,7 @@ interface PipelineScratch extends PipelineRecord { } function freshRecord(): PipelineRecord { - return { clock: { now: 0 }, events: [], forwarded: [], windows: new Map(), origins: new WeakMap(), hostVisibility: new Map(), quarantines: [], profiles: [] }; + return { clock: { now: 0 }, events: [], forwarded: [], windows: new Map(), origins: new WeakMap(), hostVisibility: new Map(), quarantines: [], released: [], profiles: [] }; } const HIDDEN: WindowVisibility = { visible: false, focused: false }; @@ -60,6 +63,7 @@ Given('a kernl guard pipeline using the {string} profile with a controlled clock onEvent: (event) => void record.events.push(event), onQuarantine: (windowId, napplet) => void record.quarantines.push({ windowId, napplet }), getVisibility: (windowId) => record.hostVisibility.get(windowId) ?? VISIBLE, + onRelease: (windowId) => void record.released.push(windowId), }); pipeline.profile$.subscribe((value) => record.profiles.push(value)); scratch(world).pipeline = { ...record, pipeline } satisfies PipelineScratch; @@ -137,6 +141,14 @@ When('the pipeline observes {int} port envelopes of type {string} from window {s for (let i = 0; i < count; i += 1) s.disposition = s.pipeline.observe(windowId, s.windows.get(windowId) ?? 'unknown', { type }); }); +When('the pipeline observes {int} paced port envelopes of type {string} from window {string}', (world: CoreWorld, count: number, type: string, windowId: string) => { + const s = state(world); + for (let i = 0; i < count; i += 1) { + if (i > 0 && i % 8 === 0) s.clock.now += 1000; + s.disposition = s.pipeline.observe(windowId, s.windows.get(windowId) ?? 'unknown', { type }); + } +}); + When('the pipeline observes a port envelope of type {string} from window {string}', (world: CoreWorld, type: string, windowId: string) => { const s = state(world); s.disposition = s.pipeline.observe(windowId, s.windows.get(windowId) ?? 'unknown', { type }); @@ -144,7 +156,7 @@ When('the pipeline observes a port envelope of type {string} from window {string When('the pipeline observes port envelopes of type {string} from window {string} until it quarantines, at most {int}', (world: CoreWorld, type: string, windowId: string, max: number) => { const s = state(world); - for (let i = 0; i < max && s.disposition !== 'quarantine'; i += 1) { + for (let i = 0; i < max && s.quarantines.length === 0; i += 1) { s.disposition = s.pipeline.observe(windowId, s.windows.get(windowId) ?? 'unknown', { type }); } }); @@ -192,7 +204,27 @@ Then('the pipeline reports window {string} as hidden', (world: CoreWorld, window expect(state(world).pipeline.getVisibility(windowId)).toEqual(HIDDEN); }); +Then('the pipeline asked to release nothing', (world: CoreWorld) => { + expect(state(world).released).toEqual([]); +}); + +Then('the pipeline asked to release window {string}', (world: CoreWorld, windowId: string) => { + expect(state(world).released).toEqual([windowId]); +}); + Then('the pipeline profile is {string}', (world: CoreWorld, profile: GuardProfile) => { expect(state(world).pipeline.config.profile).toBe(profile); expect(state(world).profiles.at(-1)).toBe(profile); }); + +When('the runtime firewall reports rule {string} with decision {string} for {string}', (world: CoreWorld, ruleId: string, decision: Decision, opClass: string) => { + const events: GuardEvent[] = []; + const hooks = createRuntimeFirewallHooks({ storage: scratch(world).storage, onEvent: (event) => void events.push(event) }); + hooks.onFirewallEvent({ windowId: 'w1', napplet: 'chat', opClass, decision, action: 'block', ruleId, reason: 'secret detail', message: { type: 'storage.set', value: 'secret' } as never }); + scratch(world).runtimeEvents = events; +}); + +Then('the guard was told of a {string} of {string} because {string}, and nothing else', (world: CoreWorld, disposition: string, opClass: string, reason: string) => { + const events = scratch(world).runtimeEvents as GuardEvent[]; + expect(events).toEqual([{ disposition, windowId: 'w1', napplet: 'chat', opClass, reason, at: expect.any(Number) }]); +}); diff --git a/packages/core/features/steps/kernl.ts b/packages/core/features/steps/kernl.ts index 3eb90d0..3d6bdfb 100644 --- a/packages/core/features/steps/kernl.ts +++ b/packages/core/features/steps/kernl.ts @@ -101,6 +101,7 @@ export function boot(world: CoreWorld, options: Partial = {}, host } After((world: CoreWorld) => { + vi.useRealTimers(); const value = scratch(world).kernl as KernlScratch | undefined; if (!value) return; value.channel?.port1.close(); @@ -156,6 +157,7 @@ Given('a kernl shell over that host and pool with fake bridges', (world: CoreWor Given('a kernl shell over that host and pool with fake bridges and a manual clock', (world: CoreWorld) => { scratch(world).clock = 1_700_000_000_000; + vi.useFakeTimers({ toFake: ['Date'], now: scratch(world).clock as number }); bootWithFakeBridges(world, { now: () => scratch(world).clock as number }); }); diff --git a/packages/core/features/steps/plan-and-mount.ts b/packages/core/features/steps/plan-and-mount.ts index 6649b6b..f8e18ff 100644 --- a/packages/core/features/steps/plan-and-mount.ts +++ b/packages/core/features/steps/plan-and-mount.ts @@ -3,7 +3,7 @@ import { Given, Then, When } from 'quickpickle'; import { renderNappletNamespacePrelude, type ShellAdapter, type ShellEnvironment } from '@kehto/shell'; import type { GuardProfile, WindowVisibility } from '@kernl/guard'; import { AccountManager } from 'applesauce-accounts'; -import { createRelayTiers, createShellAdapter, mountApplet, planMount, resolveApplet, type AppletStatus, type AppletSummary, type MountDeps, type MountPlan, type MountedApplet } from '@kernl/core'; +import { createRelayTiers, createRuntimeFirewallHooks, createShellAdapter, mountApplet, planMount, resolveApplet, type AppletStatus, type AppletSummary, type MountDeps, type MountPlan, type MountedApplet } from '@kernl/core'; import { spliceOnce } from '../../src/applets/plan.js'; import { allowAllSigning, attempt, createFakeFrame, scratch, signManifest, splitList, type CoreWorld, type FakeFrame } from './world.js'; @@ -37,7 +37,9 @@ function fakeAdapter(world: CoreWorld, allowed: string[]): ShellAdapter { services: available.services.filter((service) => allowed.includes(service)), }), onNappletRevoked: () => undefined, - onNappletMessage: () => undefined, + filterNappletMessage: () => 'dispatch', + getFocusContext: () => ({ focused: true }), + runtimeFirewall: createRuntimeFirewallHooks({ storage: scratch(world).storage, onEvent: () => undefined }), }); } diff --git a/packages/core/features/steps/runtime-guard.ts b/packages/core/features/steps/runtime-guard.ts index 81e840e..70e3764 100644 --- a/packages/core/features/steps/runtime-guard.ts +++ b/packages/core/features/steps/runtime-guard.ts @@ -1,6 +1,6 @@ -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import { Given, Then, When } from 'quickpickle'; -import { refuseCurrentDispatch } from '@kernl/core'; +import type { NappletRevocationReason } from '@kehto/shell'; import { k, settle } from './kernl.js'; import { scratch, type CoreWorld } from './world.js'; @@ -24,32 +24,39 @@ When('the applet asks {string} for an address of {int} KiB over its port', async await settle(); }); -Then('the applet received a {string} naming the firewall', (world: CoreWorld, type: string) => { - const answer = k(world).received.find((entry) => entry.type === type); - expect(answer).toMatchObject({ id: `${type.replace(/\.error$/, '')}-oversized` }); - expect(String(answer?.error)).toMatch(/^firewall: /); +Then('the oversized {string} request got no answer', (world: CoreWorld, type: string) => { + expect(k(world).received.filter((entry) => entry.id === `${type}-oversized`)).toEqual([]); }); Then('the fake bridges were asked for nothing', (world: CoreWorld) => { expect(k(world).bridgeCalls).toEqual([]); }); -Given('the runtime firewall holds the policy {string} for {string}', (world: CoreWorld, policy: 'ask', napplet: string) => { - firewall(world).setPolicy(napplet, policy); +Then('the runtime firewall scales unfocused budgets by {float} and allows {int} operations in a window\'s first {int} ms', (world: CoreWorld, multiplier: number, maxOps: number, windowMs: number) => { + const config = firewall(world).getConfig(); + expect(config.unfocusedMultiplier).toBe(multiplier); + expect(config.burstGuard).toEqual({ windowMs, maxOps, action: 'block' }); }); -When('the shell refuses the current dispatch for {string} and for {string}', (world: CoreWorld, first: string, second: string) => { - refuseCurrentDispatch(firewall(world), first); - refuseCurrentDispatch(firewall(world), second); - scratch(world).duringDispatch = [policyOf(world, first), policyOf(world, second)]; +Then('the runtime ACL refuses {string} to an applet the user never granted anything', (world: CoreWorld, capability: 'relay:read') => { + expect(k(world).kernl.bridge.runtime.aclState.check('', 'stranger', 'ab'.repeat(32), capability)).toBe(false); }); -Then('during that dispatch the runtime firewall policies were {string} and {string}', (world: CoreWorld, first: string, second: string) => { - expect(scratch(world).duringDispatch).toEqual([first, second]); +Then('the host storage holds the runtime firewall config under {string}', (world: CoreWorld, key: string) => { + const stored = JSON.parse(scratch(world).storage.getItem(key) ?? '{}') as { napplets?: Record }; + expect(stored.napplets?.chat).toBeDefined(); }); -Then('afterwards the runtime firewall policy for {string} is {string} and for {string} is {string}', (world: CoreWorld, first: string, firstPolicy: string, second: string, secondPolicy: string) => { - expect([policyOf(world, first), policyOf(world, second)]).toEqual([firstPolicy, secondPolicy]); +When('kehto revokes the mounted window for {string}', (world: CoreWorld, reason: NappletRevocationReason) => { + k(world).kernl.bridge.revokeNapplet(k(world).plan?.windowId ?? '', reason); +}); + +Given('the applet\'s startup window has passed', (world: CoreWorld) => { + advance(world, 1000); +}); + +Then('no guard event carries the applet\'s message', (world: CoreWorld) => { + for (const event of k(world).guardEvents) expect(Object.keys(event).sort()).toEqual(['at', 'disposition', 'napplet', 'opClass', 'reason', 'windowId']); }); Then('the runtime firewall holds no policy for {string} and a {string} rate of {int} per {int} ms', (world: CoreWorld, napplet: string, opClass: string, capacity: number, windowMs: number) => { @@ -64,8 +71,9 @@ const VISIBILITY = { focused: { visible: true, focused: true }, } as const; -When('the applet window becomes {string}', (world: CoreWorld, state: keyof typeof VISIBILITY) => { +When('the applet window becomes {string}', async (world: CoreWorld, state: keyof typeof VISIBILITY) => { k(world).handle?.setVisibility(VISIBILITY[state]); + await settle(); }); When('the applet sends {int} {string} requests over its port, {int} ms apart', async (world: CoreWorld, count: number, type: string, gap: number) => { @@ -76,13 +84,19 @@ When('the applet sends {int} {string} requests over its port, {int} ms apart', a port.postMessage({ type, id: `${type}-${base + i}`, url: `${FILES}a.txt`, key: 'k', value: 'v' }); if (gap > 0) { await settle(); - scratch(world).clock = (scratch(world).clock as number) + gap; + advance(world, gap); } } scratch(world)[`sent:${type}`] = base + count; await settle(); }); +/** The manual clock also drives `Date`, because kehto's runtime reads `Date.now()` for its budgets and startup window. */ +function advance(world: CoreWorld, ms: number): void { + scratch(world).clock = (scratch(world).clock as number) + ms; + vi.setSystemTime(scratch(world).clock as number); +} + function answerTo(world: CoreWorld, type: string, n: number): Record | undefined { return k(world).received.find((entry) => entry.id === `${type}-${n}`); } @@ -99,11 +113,16 @@ Then('{string} requests {int} to {int} were answered', (world: CoreWorld, type: } }); -Then('{string} requests {int} to {int} were refused by the firewall', (world: CoreWorld, type: string, from: number, to: number) => { - for (let n = from; n <= to; n += 1) expect(refused(answerTo(world, type, n)), `${type}-${n}`).toBe(true); +Then('{string} requests {int} to {int} got no answer', (world: CoreWorld, type: string, from: number, to: number) => { + for (let n = from; n <= to; n += 1) expect(answerTo(world, type, n), `${type}-${n}`).toBeUndefined(); }); -Then('at least one {string} request was refused by the firewall', (world: CoreWorld, type: string) => { +Then('at least one {string} request got no answer', (world: CoreWorld, type: string) => { const sent = scratch(world)[`sent:${type}`] as number; - expect(Array.from({ length: sent }, (_, i) => answerTo(world, type, i + 1)).some(refused)).toBe(true); + expect(Array.from({ length: sent }, (_, i) => answerTo(world, type, i + 1)).some((answer) => answer === undefined)).toBe(true); }); + +Then('{string} requests {int} to {int} were refused by the firewall', (world: CoreWorld, type: string, from: number, to: number) => { + for (let n = from; n <= to; n += 1) expect(refused(answerTo(world, type, n)), `${type}-${n}`).toBe(true); +}); + diff --git a/packages/core/features/steps/services.ts b/packages/core/features/steps/services.ts index 6663ada..ee41954 100644 --- a/packages/core/features/steps/services.ts +++ b/packages/core/features/steps/services.ts @@ -13,6 +13,7 @@ import { createDefaultBridges, createDefaultMessaging, createKernlServices, + createRuntimeFirewallHooks, createShellAdapter, isTheme, resourceInitFor, @@ -224,7 +225,9 @@ Given('a kernl shell adapter', (world: CoreWorld) => { services: state(world).services.registry, resolveEnvironment: (_identity, available) => available, onNappletRevoked: () => undefined, - onNappletMessage: () => undefined, + filterNappletMessage: (windowId) => (windowId === 'hidden' ? 'defer' : 'dispatch'), + getFocusContext: (windowId) => ({ focused: windowId === 'focused' }), + runtimeFirewall: createRuntimeFirewallHooks({ storage: scratch(world).storage, onEvent: () => undefined }), onUnroutedMessage: (info) => void unrouted.push(info), }); s.adapter = adapter; @@ -278,6 +281,14 @@ Then('the adapter has no network suggestions', (world: CoreWorld) => { expect(adapter(world).relayConfig.getNip66Suggestions()).toBeNull(); }); +Then('the adapter keeps a restrictive ACL and asks the shell about focus and every port message', (world: CoreWorld) => { + const shell = adapter(world); + expect(shell.aclDefaultPolicy).toBe('restrictive'); + expect([shell.getFocusContext?.('focused'), shell.getFocusContext?.('other')]).toEqual([{ focused: true }, { focused: false }]); + expect([shell.filterNappletMessage?.('hidden', { type: 'relay.subscribe' }), shell.filterNappletMessage?.('w1', { type: 'relay.subscribe' })]).toEqual(['defer', 'dispatch']); + expect(shell.firewallPersistence?.load()).toBeNull(); +}); + Then('the adapter forwards unrouted messages when asked to', (world: CoreWorld) => { adapter(world).onUnroutedMessage?.({ origin: 'null', reason: 'no-source-window' }); expect(scratch(world).unrouted).toHaveLength(1); @@ -289,7 +300,9 @@ Then('the adapter forwards unrouted messages when asked to', (world: CoreWorld) services: {}, resolveEnvironment: (_identity, available) => available, onNappletRevoked: () => undefined, - onNappletMessage: () => undefined, + filterNappletMessage: () => 'dispatch', + getFocusContext: () => ({ focused: true }), + runtimeFirewall: createRuntimeFirewallHooks({ storage: scratch(world).storage, onEvent: () => undefined }), }); expect(silent.onUnroutedMessage).toBeUndefined(); }); diff --git a/packages/core/package.json b/packages/core/package.json index eae3aac..01d55c1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,12 +25,12 @@ }, "dependencies": { "@internet-privacy/marmot-ts": "^0.9.0", - "@kehto/acl": "^0.20.2", - "@kehto/firewall": "^0.6.3", + "@kehto/acl": "^0.20.3", + "@kehto/firewall": "^0.7.0", "@kehto/nip": "^0.8.0", - "@kehto/runtime": "^0.27.2", - "@kehto/services": "^0.29.0", - "@kehto/shell": "^0.30.0", + "@kehto/runtime": "^0.28.0", + "@kehto/services": "^0.30.1", + "@kehto/shell": "^0.32.0", "@kehto/wm": "^0.0.2", "@kernl/bridges": "workspace:^", "@kernl/guard": "workspace:^", @@ -45,8 +45,8 @@ "rxjs": "^7.8.2" }, "peerDependencies": { - "@napplet/core": ">=0.37.0 <0.38.0", - "@napplet/nap": ">=0.36.0 <0.37.0", + "@napplet/core": ">=0.38.0 <0.39.0", + "@napplet/nap": ">=0.37.0 <0.38.0", "@napplet/shim": ">=0.30.0", "nostr-tools": ">=2.23.3 <=2.x" }, diff --git a/packages/core/src/grants/grants.ts b/packages/core/src/grants/grants.ts index 32b61c9..0c994ed 100644 --- a/packages/core/src/grants/grants.ts +++ b/packages/core/src/grants/grants.ts @@ -1,5 +1,5 @@ import { Subject } from 'rxjs'; -import { ALL_CAPABILITIES, type AclStateContainer, type Capability } from '@kehto/runtime'; +import type { AclStateContainer, Capability } from '@kehto/runtime'; import type { CapabilityHooks } from '@kehto/shell'; import type { AppletIdentity, GrantRecord, Grants, StorageLike } from '../types.js'; import { createNamespacedStore } from '../persistence/store.js'; @@ -25,7 +25,7 @@ export interface GrantsOptions { /** The grants surface plus the hook the shell calls when an applet is about to run. */ export interface KernlGrants extends Grants { - /** Make sure the runtime ACL reflects this applet's record; returns the record. */ + /** Write this applet's record into the runtime ACL before it runs; returns the record. */ ensure(identity: AppletIdentity): GrantRecord; } @@ -36,11 +36,12 @@ function keyOf(identity: AppletIdentity): string { /** * User-facing grants over kehto's ACL state. * - * kehto's runtime defaults to a permissive ACL (D-001), so kernl writes the - * complete capability set for every applet it runs: the baseline domains plus - * what the user allowed are granted, everything else is revoked explicitly. - * The per-identity record is persisted at `kernl:grants:v1`; the ACL itself is - * persisted by kehto. Blocking an applet blocks it in the ACL and empties its + * The runtime ACL is restrictive (`aclDefaultPolicy`, D-020): an applet with + * no entry holds nothing. kernl grants the baseline domains plus what the user + * allowed, and revokes whatever the applet's entry holds beyond that, which + * covers a domain the user took back and an entry kept from an earlier + * posture. The per-identity record is persisted at `kernl:grants:v1`; the ACL + * itself is persisted by kehto. Blocking an applet blocks it in the ACL and empties its * environment through {@link createEnvironmentResolver}. * * @param options - ACL container, storage and the live domain list. @@ -75,10 +76,9 @@ export function createGrants(options: GrantsOptions): KernlGrants { function syncAcl(entry: StoredGrant): void { const wanted = new Set(capabilitiesForDomains([...BASELINE_DOMAINS, ...entry.domains])); - for (const capability of ALL_CAPABILITIES) { - if (wanted.has(capability)) options.acl.grant('', entry.dTag, entry.hash, capability); - else options.acl.revoke('', entry.dTag, entry.hash, capability); - } + const held = options.acl.getEntry('', entry.dTag, entry.hash)?.capabilities ?? []; + for (const capability of held) if (!wanted.has(capability)) options.acl.revoke('', entry.dTag, entry.hash, capability); + for (const capability of wanted) options.acl.grant('', entry.dTag, entry.hash, capability); if (entry.blocked) options.acl.block('', entry.dTag, entry.hash); else options.acl.unblock('', entry.dTag, entry.hash); options.acl.persist(); diff --git a/packages/core/src/guard/pipeline.ts b/packages/core/src/guard/pipeline.ts index 8a672eb..a2135fe 100644 --- a/packages/core/src/guard/pipeline.ts +++ b/packages/core/src/guard/pipeline.ts @@ -1,4 +1,5 @@ import { BehaviorSubject, type Observable } from 'rxjs'; +import type { NappletMessageDecision } from '@kehto/shell'; import { classifyEnvelope, createFocusFirewall, @@ -14,6 +15,7 @@ import { type GuardEvent, type GuardProfile, type IngressGuard, + type OpClass, type VisibilityGate, type WindowVisibility, } from '@kernl/guard'; @@ -34,6 +36,8 @@ export interface GuardPipelineOptions { readonly onQuarantine?: (windowId: string, napplet: string) => void; /** Visibility for a window the pipeline has not been told about yet. */ readonly getVisibility?: (windowId: string) => WindowVisibility; + /** Called when a window becomes visible; kernl releases the port envelopes kehto held for it here. */ + readonly onRelease?: (windowId: string) => void; } /** What the pipeline decided about a message. `unrouted` means no registered window sent it. */ @@ -59,19 +63,16 @@ export interface GuardPipeline { */ handleWindowMessage(event: MessageEvent, resolveWindow: (event: MessageEvent) => WindowResolution | null, forward: (event: MessageEvent) => void): PipelineDisposition; /** - * Judge one envelope that arrived over a bound port. kehto calls this - * synchronously just before it dispatches the envelope to the runtime, so - * nothing can be held or delayed here; the caller refuses instead. A - * `defer` or `reject` answer means the envelope must not be handled: - * `createKernl` refuses it for that one dispatch with - * `refuseCurrentDispatch`. That covers bursts above the per-second rate, - * the focus-aware firewall, size caps, and every non-control envelope from - * a window that is not visible (reason `hidden:`), which the applet - * can retry once it is visible. Control envelopes are never refused. A - * quarantine triggers `onQuarantine`, and when that revokes the binding - * kehto no longer finds the window's session and drops the envelope. + * Decide one envelope that arrived over a bound port, for kehto's + * `filterNappletMessage`. `defer` holds every non-control envelope from a + * window that is not visible (reason `hidden:`) until it is visible + * again, up to `deferQueue` envelopes. `drop` discards an envelope over its + * size cap, a burst above the per-second rate, a request over the + * focus-aware firewall's budget, a hidden window's envelope past the defer + * queue, and the envelope that triggers a quarantine. Control envelopes are + * dispatched unless the window is quarantined. */ - observe(windowId: string, napplet: string, message: unknown): GuardDisposition; + observe(windowId: string, napplet: string, message: unknown): NappletMessageDecision; setVisibility(windowId: string, visibility: WindowVisibility): void; getVisibility(windowId: string): WindowVisibility; /** Lift quarantine after the user chose to. */ @@ -107,43 +108,41 @@ interface PortEnvelope { readonly visibility: WindowVisibility; } +/** How the pipeline holds a hidden window's port envelope: counted against `deferQueue`, reported, and left with kehto. */ +type HoldPort = (port: PortEnvelope, opClass: OpClass) => NappletMessageDecision; + /** * What the pipeline answers for a port envelope once the ingress guard has - * admitted it. Control envelopes always pass; a hidden window's other - * envelopes are rejected with `hidden:`; the rest go to the firewall. + * admitted it. Control envelopes are dispatched; a hidden window's other + * envelopes are held; the rest go to the firewall. */ -function judgePort(port: PortEnvelope, admitted: GuardDisposition, firewall: FocusFirewall, emit: (event: Omit) => void): GuardDisposition { +function judgePort(port: PortEnvelope, admitted: GuardDisposition, firewall: FocusFirewall, hold: HoldPort): NappletMessageDecision { const { windowId, napplet, envelope, visibility } = port; - if (admitted === 'quarantine') return admitted; + if (admitted === 'quarantine') return 'drop'; const opClass = classifyEnvelope(envelope); if (opClass === 'control') { if (admitted === 'pass') firewall.observe(windowId, napplet, envelope, visibility); - return 'pass'; - } - if (admitted !== 'pass') return admitted; - if (!visibility.visible) { - emit({ disposition: 'reject', windowId, napplet, opClass, reason: `hidden:${opClass}` }); - return 'reject'; + return 'dispatch'; } - return firewall.observe(windowId, napplet, envelope, visibility).decision === 'pass' ? 'pass' : 'reject'; + if (admitted !== 'pass') return 'drop'; + if (!visibility.visible) return hold(port, opClass); + return firewall.observe(windowId, napplet, envelope, visibility).decision === 'pass' ? 'dispatch' : 'drop'; } /** * Compose the guard: ingress flood control, then the visibility gate, then the * focus-aware firewall, then kehto's bridge. * - * What is enforced where (D-001): kehto binds NAP traffic to a `MessagePort` - * the bridge owns, so the only window-level message the pipeline can hold or - * drop is `napplet.hello`. Port traffic reaches the pipeline through - * `ShellAdapter.onNappletMessage`, which kehto calls synchronously before it - * dispatches each envelope and whose answer kehto ignores. `observe` returns - * a disposition instead, and `createKernl` refuses every `defer` or `reject` - * for that one dispatch with `refuseCurrentDispatch` (D-012), so the applet - * gets a firewall error and can retry. Control envelopes are never refused. - * Quarantine revokes the window inside the callback; kehto then finds no - * session for it, and the envelope that crossed the limit is dropped along - * with everything after it. The runtime firewall configured by - * `applyRuntimeFirewallRules` adds the profile's per-class rates. + * What is enforced where (D-020): kehto binds NAP traffic to a `MessagePort` + * the bridge owns. Window-level messages, of which only `napplet.hello` is + * legitimate, are held or dropped by `handleWindowMessage`. Port traffic + * reaches `observe` through kehto's `filterNappletMessage`, whose answer + * kehto follows: dispatch, hold until `releaseDeferred`, or drop. Hidden + * windows' envelopes are held and `onRelease` is called when the window is + * visible again. Quarantine revokes the window inside the filter, and kehto + * discards what it held. The runtime firewall configured by + * `applyRuntimeFirewallRules` adds the profile's per-class rates to every + * envelope it dispatches, held ones included. * * @param options - Configuration, clock, event sink and quarantine callback. * @returns The pipeline. @@ -218,6 +217,14 @@ export function createGuardPipeline(options: GuardPipelineOptions): GuardPipelin return disposition; } + function holdPort(port: PortEnvelope, opClass: OpClass): NappletMessageDecision { + const { windowId, napplet } = port; + deferring = napplet; + if (!layers.gate.defer(windowId, port.envelope)) return 'drop'; + options.onEvent?.({ disposition: 'defer', windowId, napplet, opClass, reason: `hidden:${opClass}`, at: clock() }); + return 'defer'; + } + function drainAll(windowId: string): void { const released = layers.ingress.drain(windowId).length; for (const pending of take(heldByIngress, windowId, released)) pastGate(windowId, pending); @@ -253,12 +260,14 @@ export function createGuardPipeline(options: GuardPipelineOptions): GuardPipelin observe(windowId, napplet, message) { const envelope = toEnvelope(message); const port: PortEnvelope = { windowId, napplet, envelope, visibility: visibilityOf(windowId) }; - return judgePort(port, admit(windowId, napplet, envelope, { hold: false }), layers.firewall, (event) => options.onEvent?.({ ...event, at: clock() })); + return judgePort(port, admit(windowId, napplet, envelope, { hold: false }), layers.firewall, holdPort); }, setVisibility(windowId, visibility) { known.set(windowId, visibility); layers.gate.setVisibility(windowId, visibility); - if (visibility.visible) drainAll(windowId); + if (!visibility.visible) return; + drainAll(windowId); + options.onRelease?.(windowId); }, getVisibility: visibilityOf, release: (windowId) => layers.ingress.release(windowId), diff --git a/packages/core/src/guard/runtime-rules.ts b/packages/core/src/guard/runtime-rules.ts index 1e45e7f..fcd59ed 100644 --- a/packages/core/src/guard/runtime-rules.ts +++ b/packages/core/src/guard/runtime-rules.ts @@ -1,6 +1,24 @@ -import type { FirewallStateContainer } from '@kehto/runtime'; -import type { NappletPolicy, NappletRules, RateLimit } from '@kehto/firewall'; -import { applyNappletRules, type GuardConfig, type OpClass } from '@kernl/guard'; +import type { FirewallEvent, FirewallPersistence, FirewallStateContainer } from '@kehto/runtime'; +import type { NappletRules, RateLimit } from '@kehto/firewall'; +import { applyNappletRules, type GuardConfig, type GuardEvent, type OpClass } from '@kernl/guard'; +import type { StorageLike } from '../types.js'; + +/* + * Every call kernl makes into the runtime firewall's own configuration lives in + * this module. kehto/web#336 replaces these seams on hosts that configure + * permissions (`permissions.setRateLimit`, `onRateLimit`), so moving there is a + * change to this file only (D-020). + */ + +/** + * Where kernl stores the runtime firewall's configuration in the host storage. + * + * @example + * ```ts + * localStorage.getItem(RUNTIME_FIREWALL_STORAGE_KEY); // the serialised kehto firewall config + * ``` + */ +export const RUNTIME_FIREWALL_STORAGE_KEY = 'kernl:firewall:v1'; /** * How the guard's operation classes appear inside kehto's runtime firewall. @@ -67,15 +85,18 @@ export function runtimeFirewallRules(config: GuardConfig, napplet: string): Runt return { rates, globalRate: rules.globalRate as RateLimit }; } +/** The runtime firewall's state, as far as kernl configures it. */ +export type RuntimeFirewallState = Pick; + /** * Install the guard profile's rates for an applet in the runtime firewall. * - * This is the pre-dispatch half of kernl's guard: `ShellAdapter` does not - * forward kehto's firewall hooks (D-001), but the runtime's own firewall still - * runs before every dispatch, so the profile's limits are pushed into it when - * a window binds. No policy is set: kehto's `allow` policy bypasses every rate, - * matcher and burst check, and the user's choices to deny or block live in the - * grants layer, not here. + * The runtime's firewall runs before every dispatch, including the envelopes + * kernl held for a hidden window and released later, so the profile's limits + * are pushed into it when a window mounts and again when the profile changes. + * No policy is set: kehto's `allow` policy bypasses every rate, matcher and + * burst check, and the user's choices to deny or block live in the grants + * layer, not here. * * @param firewallState - `bridge.runtime.firewallState`. * @param config - Current guard configuration. @@ -86,11 +107,7 @@ export function runtimeFirewallRules(config: GuardConfig, napplet: string): Runt * applyRuntimeFirewallRules(bridge.runtime.firewallState, guardConfig, summary.identity.dTag); * ``` */ -export function applyRuntimeFirewallRules( - firewallState: Pick, - config: GuardConfig, - napplet: string, -): void { +export function applyRuntimeFirewallRules(firewallState: RuntimeFirewallState, config: GuardConfig, napplet: string): void { const rules = runtimeFirewallRules(config, napplet); for (const rule of rules.rates) firewallState.setRateLimit(napplet, rule.opClass, rule.limit); firewallState.setGlobalRate(napplet, rules.globalRate); @@ -98,33 +115,89 @@ export function applyRuntimeFirewallRules( } /** - * Make the runtime firewall refuse the envelope kehto is about to dispatch for - * an applet, and only that one. - * - * kehto calls `onNappletMessage` synchronously, then evaluates the runtime - * firewall and dispatches in the same call, and ignores the callback's answer - * (D-012). kernl's guard layers judge each envelope in that callback: a burst - * above the per-second rate, a request over the unfocused budget, an - * oversized download or query (the runtime measures size only for envelopes - * that carry an event), and any non-control request from a hidden window. - * When one of them will not let an envelope through, the applet's policy is - * set to `deny` for the rest of this dispatch: kehto answers the request with - * a firewall denial instead of handing it to a service, and the previous - * policy returns in a microtask, before the next port message can arrive. + * Apply a profile's shell-wide limits to the runtime firewall: the unfocused + * multiplier, which the runtime applies with the focus kernl reports through + * `getFocusContext`, and the startup burst guard. * * @param firewallState - `bridge.runtime.firewallState`. - * @param napplet - Applet identity (dTag) of the window whose envelope is being dispatched. - * @returns Nothing; the policy is restored in a microtask. + * @param config - The guard configuration of the profile now in force. + * @returns Nothing; the firewall state is mutated and persisted. + * @example + * ```ts + * applyRuntimeFirewallProfile(bridge.runtime.firewallState, createGuardConfig('balanced')); + * bridge.runtime.firewallState.getConfig().unfocusedMultiplier; // 0.5 + * ``` + */ +export function applyRuntimeFirewallProfile(firewallState: RuntimeFirewallState, config: GuardConfig): void { + firewallState.setUnfocusedMultiplier(config.firewall.unfocusedMultiplier); + firewallState.setBurstGuard(config.firewall.burstGuard); + firewallState.persist(); +} + +const GUARD_OP_CLASSES: ReadonlyMap = new Map( + Object.entries(RUNTIME_OP_CLASSES).flatMap(([opClass, runtimeClass]) => (runtimeClass ? [[runtimeClass, opClass as OpClass] as const] : [])), +); + +/** + * Turn a runtime firewall event into a guard event. The runtime's event + * carries the applet's message; the guard event never does, so nothing the + * applet sent reaches the guard log. Reasons follow the guard's vocabulary: + * a rate is `rate:`, the startup burst is `flood:startup`, anything + * else is `runtime:`. + * + * @param event - What the runtime firewall reported. + * @param at - When it happened. + * @returns The guard event. + * @example + * ```ts + * toGuardEvent({ windowId: 'w1', napplet: 'chat', opClass: 'state:write', decision: 'reject', action: 'block', ruleId: 'rate:opclass', reason: '…' }, 0).reason; + * // 'rate:storage:write' + * ``` + */ +export function toGuardEvent(event: FirewallEvent, at: number): GuardEvent { + const opClass = GUARD_OP_CLASSES.get(event.opClass) ?? 'generic'; + const reason = event.ruleId === 'burst' ? 'flood:startup' : event.ruleId.startsWith('rate:') ? `rate:${opClass}` : `runtime:${event.ruleId}`; + const disposition = event.decision === 'pass' ? 'flag' : 'reject'; + return { disposition, windowId: event.windowId, napplet: event.napplet, opClass, reason, at }; +} + +/** The runtime firewall hooks kernl hands to `createShellAdapter`. */ +export interface RuntimeFirewallHooks { + readonly firewallPersistence: FirewallPersistence; + readonly onFirewallEvent: (event: FirewallEvent) => void; +} + +/** Options for {@link createRuntimeFirewallHooks}. */ +export interface RuntimeFirewallHooksOptions { + /** The host storage; the config is kept under {@link RUNTIME_FIREWALL_STORAGE_KEY}. */ + readonly storage: StorageLike; + /** Receives every runtime refusal and flag as a guard event. */ + readonly onEvent: (event: GuardEvent) => void; + readonly now?: () => number; +} + +/** + * The runtime firewall's persistence and event sink, through kernl's host + * storage and guard event stream. These report what the runtime itself + * refused, which is the startup burst and anything over budget when held + * envelopes are released; what kernl drops before the runtime sees it is + * reported by kernl's own guard layers. + * + * @param options - Host storage, the guard event sink and a clock. + * @returns `firewallPersistence` and `onFirewallEvent` for the `ShellAdapter`. * @example * ```ts - * onNappletMessage: (windowId, message) => { - * if (pipeline.observe(windowId, dTag, message) === 'reject') refuseCurrentDispatch(bridge.runtime.firewallState, dTag); - * }; + * const hooks = createRuntimeFirewallHooks({ storage: localStorage, onEvent: (event) => log.push(event) }); + * const adapter: ShellAdapter = { ...rest, ...hooks }; * ``` */ -export function refuseCurrentDispatch(firewallState: Pick, napplet: string): void { - // An absent policy is restored as `undefined`, which kehto's engine reads as no policy. - const previous = firewallState.getConfig().napplets[napplet]?.policy as NappletPolicy; - firewallState.setPolicy(napplet, 'deny'); - queueMicrotask(() => firewallState.setPolicy(napplet, previous)); +export function createRuntimeFirewallHooks(options: RuntimeFirewallHooksOptions): RuntimeFirewallHooks { + const now = options.now ?? Date.now; + return { + firewallPersistence: { + persist: (data) => options.storage.setItem(RUNTIME_FIREWALL_STORAGE_KEY, data), + load: () => options.storage.getItem(RUNTIME_FIREWALL_STORAGE_KEY), + }, + onFirewallEvent: (event) => options.onEvent(toGuardEvent(event, now())), + }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d94a791..c9f8b86 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,11 +75,17 @@ export { type WindowResolution, } from './guard/pipeline.js'; export { + RUNTIME_FIREWALL_STORAGE_KEY, RUNTIME_OP_CLASSES, + applyRuntimeFirewallProfile, applyRuntimeFirewallRules, - refuseCurrentDispatch, + createRuntimeFirewallHooks, runtimeFirewallRules, + toGuardEvent, + type RuntimeFirewallHooks, + type RuntimeFirewallHooksOptions, type RuntimeFirewallRules, + type RuntimeFirewallState, type RuntimeRateRule, } from './guard/runtime-rules.js'; diff --git a/packages/core/src/kernl-adapter.ts b/packages/core/src/kernl-adapter.ts index 4784194..e1054e5 100644 --- a/packages/core/src/kernl-adapter.ts +++ b/packages/core/src/kernl-adapter.ts @@ -1,11 +1,12 @@ import type { AccountManager } from 'applesauce-accounts'; import { verifyEvent } from 'applesauce-core/helpers'; -import type { CapabilityHooks, NappletMessage, NappletRevocation, ShellAdapter, UnroutedMessageInfo } from '@kehto/shell'; +import type { CapabilityHooks, NappletRevocation, ShellAdapter, UnroutedMessageInfo } from '@kehto/shell'; import type { ServiceRegistry } from '@kehto/runtime'; import type { RelayTier, RelayTiers } from './types.js'; import { createAuthHooks } from './applesauce/auth-hooks.js'; import type { SigningConsent } from './consent/handler.js'; import { createRelayPoolHooks, type ApplesaucePool } from './applesauce/relay-hooks.js'; +import type { RuntimeFirewallHooks } from './guard/runtime-rules.js'; const TIERS: readonly string[] = ['discovery', 'apps', 'outbox', 'super']; @@ -19,7 +20,12 @@ export interface ShellAdapterDeps { readonly services: ServiceRegistry; readonly resolveEnvironment: NonNullable; readonly onNappletRevoked: (revocation: NappletRevocation) => void; - readonly onNappletMessage: (windowId: string, message: NappletMessage) => void; + /** Decides every port envelope before kehto's runtime sees it; see `GuardPipeline.observe`. */ + readonly filterNappletMessage: NonNullable; + /** Whether a window has focus, as the shell's window manager reports it. */ + readonly getFocusContext: (windowId: string) => { focused: boolean }; + /** The runtime firewall's persistence and event sink; see `createRuntimeFirewallHooks`. */ + readonly runtimeFirewall: RuntimeFirewallHooks; readonly onUnroutedMessage?: (info: UnroutedMessageInfo) => void; } @@ -35,7 +41,9 @@ function isTier(tier: string): tier is RelayTier { * not forwarded; there is no worker relay, the applesauce pool is the only * event source; every signature made for an applet window passes the consent * gate; update behaviour is `banner`; event signatures are verified by - * applesauce; and every applet's environment is narrowed by the grants layer. + * applesauce; every applet's environment is narrowed by the grants layer; the + * ACL is restrictive, so an applet holds only what the grants layer wrote; and + * every port envelope passes kernl's guard before the runtime sees it. * * @param deps - Pool, tiers, accounts, services and the shell's hooks. * @returns The adapter for `createShellBridge`. @@ -71,7 +79,10 @@ export function createShellAdapter(deps: ShellAdapterDeps): ShellAdapter { services: deps.services, capabilities: { resolveEnvironment: deps.resolveEnvironment }, onNappletRevoked: deps.onNappletRevoked, - onNappletMessage: deps.onNappletMessage, + filterNappletMessage: deps.filterNappletMessage, + getFocusContext: deps.getFocusContext, + ...deps.runtimeFirewall, + aclDefaultPolicy: 'restrictive', ...(deps.onUnroutedMessage ? { onUnroutedMessage: deps.onUnroutedMessage } : {}), }; } diff --git a/packages/core/src/kernl.ts b/packages/core/src/kernl.ts index 32941ff..b08b89a 100644 --- a/packages/core/src/kernl.ts +++ b/packages/core/src/kernl.ts @@ -15,7 +15,7 @@ import { decodePointer } from './applets/pointer.js'; import { defaultFetchBlob, openDefaultArtifactCache } from './applets/resolve.js'; import type { MountedApplet } from './applets/mount.js'; import { createGuardPipeline, type GuardPipeline } from './guard/pipeline.js'; -import { refuseCurrentDispatch } from './guard/runtime-rules.js'; +import { applyRuntimeFirewallProfile, applyRuntimeFirewallRules, createRuntimeFirewallHooks } from './guard/runtime-rules.js'; import { createEnvironmentResolver, createGrants, type KernlGrants } from './grants/grants.js'; import { createConsentHandler, createSigningConsent, lookupReplaceable } from './consent/handler.js'; import { abbreviate } from './consent/describe-change.js'; @@ -140,6 +140,7 @@ export function createKernl(options: KernlOptions): Kernl { bridge.revokeNapplet(windowId, 'host'); }, getVisibility: (windowId) => host.getVisibility(windowId), + onRelease: (windowId) => bridge.releaseDeferred(windowId), }); const identityOf = (windowId: string): AppletIdentity | undefined => handles.get(windowId)?.summary.identity; @@ -172,17 +173,22 @@ export function createKernl(options: KernlOptions): Kernl { services: services.registry, resolveEnvironment: (identity, available) => createEnvironmentResolver(grants)(identity, available), onNappletRevoked: ({ windowId, reason }) => handles.get(windowId)?.revoked(reason), - onNappletMessage: (windowId, message) => { - const napplet = identityOf(windowId)?.dTag ?? 'unknown'; - const disposition = pipeline.observe(windowId, napplet, message); - // kehto ignores this callback's answer, so what the guard will not let through is refused by the runtime firewall for this one dispatch (D-012). - if (disposition === 'defer' || disposition === 'reject') refuseCurrentDispatch(bridge.runtime.firewallState, napplet); - }, + filterNappletMessage: (windowId, message) => pipeline.observe(windowId, identityOf(windowId)?.dTag ?? 'unknown', message), + getFocusContext: (windowId) => ({ focused: pipeline.getVisibility(windowId).focused }), + runtimeFirewall: createRuntimeFirewallHooks({ + storage: host.storage, + onEvent: (event) => guardEvents$.next(event), + ...(host.now ? { now: host.now.bind(host) } : {}), + }), }); const bridge = createShellBridge(adapter); grants = createGrants({ acl: bridge.runtime.aclState, storage: host.storage, availableDomains: () => buildShellCapabilities(adapter).domains }); bridge.registerConsentHandler(createConsentHandler(host, lookupBefore, identityOf)); + subscriptions.add(pipeline.profile$.subscribe(() => { + applyRuntimeFirewallProfile(bridge.runtime.firewallState, pipeline.config); + for (const handle of handles.values()) applyRuntimeFirewallRules(bridge.runtime.firewallState, pipeline.config, handle.summary.identity.dTag); + })); subscriptions.add(accounts.active$.subscribe((account) => { session$.next(account ? toSession(account) : null); bridge.publishIdentityChanged(account?.pubkey ?? ''); diff --git a/packages/guard/features/mitigations.feature b/packages/guard/features/mitigations.feature index ba20381..b48957e 100644 --- a/packages/guard/features/mitigations.feature +++ b/packages/guard/features/mitigations.feature @@ -13,7 +13,7 @@ Feature: Mitigation descriptions | verified-artifacts | kehto | | replay-detection | kehto | | rate-limits | both | - | focus-tightening | kernl | + | focus-tightening | both | | hidden-deferral | kernl | | ingress-flood | kernl | | size-caps | both | @@ -26,8 +26,8 @@ Feature: Mitigation descriptions Scenario: what the shell refuses before dispatch is described as refused, never as held When the guard describes the mitigations for the "strict" profile - Then the guard mitigations "focus-tightening,hidden-deferral,ingress-flood" never claim to hold, queue or pause - And the guard mitigations "focus-tightening,hidden-deferral,ingress-flood" say they refuse and never that they only report + Then the guard mitigations "focus-tightening,ingress-flood" never claim to hold, queue or pause + And the guard mitigations "focus-tightening,ingress-flood" say they refuse and never that they only report And the guard mitigation "focus-tightening" says "When an applet window is not the one you are working in, the shell counts its requests against a smaller allowance. A request over it is refused before it is handled, and the shell tells you; the applet can try again once you switch to it. The budget above still applies." Scenario: the sealed frame cannot open frames of its own (D-014) @@ -39,10 +39,13 @@ Feature: Mitigation descriptions Then the guard mitigation "size-caps" is titled "Oversized requests are refused" And the guard mitigation "size-caps" says "The shell measures download and long-running query requests against a maximum size. One that is too large is refused before it is handled, so nothing it asked for is fetched, and the shell tells you." + Scenario: a hidden applet's requests are held until you can see it (D-020) + When the guard describes the mitigations for the "strict" profile + Then the guard mitigation "hidden-deferral" says "While an applet is minimised or hidden, the shell holds its requests and handles them, in order, once you can see it again; you can still close it. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, the requests it held are discarded, and the shell closes the applet and tells you." + Scenario: quarantine drops the message that crossed the limit When the guard describes the mitigations for the "strict" profile Then the guard mitigation "ingress-flood" says "The shell counts every message an applet sends. Requests in a burst above the per-second rate are refused before they are handled, and the shell tells you; messages that keep the window controllable still get through. An applet that keeps flooding is quarantined: the message that crossed the limit is dropped, and the shell closes the applet and tells you." - And the guard mitigation "hidden-deferral" says "While an applet is minimised or hidden, the shell refuses its requests before they are handled, except the messages that let you close, theme or reach it; the applet can try again once it is visible. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, and the shell closes the applet and tells you." Scenario: every overwrite or erasure of the account's record is put to the user When the guard describes the mitigations for the "strict" profile diff --git a/packages/guard/package.json b/packages/guard/package.json index 4e5ce63..77d1c93 100644 --- a/packages/guard/package.json +++ b/packages/guard/package.json @@ -24,10 +24,10 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@kehto/firewall": "^0.6.3" + "@kehto/firewall": "^0.7.0" }, "peerDependencies": { - "@napplet/core": ">=0.37.0 <0.38.0" + "@napplet/core": ">=0.37.0 <0.39.0" }, "devDependencies": { "quickpickle": "^1.11.2", diff --git a/packages/guard/src/mitigations.ts b/packages/guard/src/mitigations.ts index 6c3e458..491a223 100644 --- a/packages/guard/src/mitigations.ts +++ b/packages/guard/src/mitigations.ts @@ -20,14 +20,13 @@ function mitigation(id: string, provider: Provider, title: string, summary: stri * The list is what the grants panel renders (D-003). Titles and summaries * avoid protocol vocabulary: they say network, applet and account. Every * mitigation is enabled in every profile; only the `limit` text changes. - * Each summary claims only what is enforced where D-012 places it. kernl - * sees each request just before the runtime handles it and cannot hold it - * back, but it can have the runtime refuse that one request: a burst above - * the per-second rate, a request over the smaller unfocused budget, an - * oversized request, and any request from a hidden applet other than the - * messages that keep its window controllable are described as refused. - * Quarantine closes the applet before the request that crossed the limit is - * handled, so that request is described as dropped. + * Each summary claims only what is enforced where D-020 places it. kernl + * decides each request before the runtime handles it: a burst above the + * per-second rate, a request over the smaller unfocused budget and an + * oversized request are described as refused, and a hidden applet's + * requests as held until it is visible. Quarantine closes the applet before + * the request that crossed the limit is handled, so that request is + * described as dropped. * * @param config - The guard configuration whose limits appear in the text. * @returns One description per mitigation, in the order the UI shows them. @@ -74,7 +73,7 @@ export function describeMitigations(config: GuardConfig): readonly MitigationDes ), mitigation( 'focus-tightening', - 'kernl', + 'both', 'Applets you are not using are measured against a smaller budget', 'When an applet window is not the one you are working in, the shell counts its requests against a smaller allowance. A request over it is refused before it is handled, and the shell tells you; the applet can try again once you switch to it. The budget above still applies.', `${limits.unfocusedMultiplier * 100}% of the focused budget`, @@ -83,7 +82,7 @@ export function describeMitigations(config: GuardConfig): readonly MitigationDes 'hidden-deferral', 'kernl', 'Hidden applets cannot act until you can see them', - 'While an applet is minimised or hidden, the shell refuses its requests before they are handled, except the messages that let you close, theme or reach it; the applet can try again once it is visible. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, and the shell closes the applet and tells you.', + 'While an applet is minimised or hidden, the shell holds its requests and handles them, in order, once you can see it again; you can still close it. One that keeps asking for downloads past its allowance is quarantined: the download that crossed the allowance is dropped, the requests it held are discarded, and the shell closes the applet and tells you.', `${limits.hiddenResourceAllowance} downloads while hidden`, ), mitigation( diff --git a/packages/guard/src/profiles.ts b/packages/guard/src/profiles.ts index 8a524a5..482d2d4 100644 --- a/packages/guard/src/profiles.ts +++ b/packages/guard/src/profiles.ts @@ -135,9 +135,9 @@ function deriveFirewall(limits: GuardLimits): FirewallConfig { * rates registered by {@link applyNappletRules}, the `generic` default rate * and the unfocused multiplier. Its init-burst guard only fires for callers * that supply `initElapsedMs`; kernl's focus firewall does not, so inside - * kernl the per-second burst is enforced by the ingress guard, and the - * `burstGuard` field exists so a runtime firewall configured from this - * object agrees with it. + * `@kernl/guard` the per-second burst is enforced by the ingress guard. + * `@kernl/core` installs `burstGuard` and `unfocusedMultiplier` in kehto's + * runtime firewall, which supplies `initElapsedMs` and the window's focus. * - The ingress guard enforces `ingressBurst`, `ingressQueue`, * `quarantineThreshold`, `hiddenResourceAllowance` and the envelope size caps. * - The visibility gate enforces `deferQueue`. diff --git a/packages/messaging/package.json b/packages/messaging/package.json index 2cf76d9..456ccb0 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -25,7 +25,7 @@ }, "dependencies": {}, "peerDependencies": { - "@napplet/core": ">=0.37.0 <0.38.0" + "@napplet/core": ">=0.37.0 <0.39.0" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeeea7b..3574bb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^2.30.0 version: 2.31.1(@types/node@24.19.2) '@napplet/core': - specifier: ^0.37.0 - version: 0.37.0 + specifier: ^0.38.0 + version: 0.38.0 '@napplet/nap': + specifier: ^0.37.0 + version: 0.37.0(typescript@5.9.3) + '@napplet/shim': specifier: ^0.36.0 version: 0.36.0(typescript@5.9.3) - '@napplet/shim': - specifier: ^0.35.0 - version: 0.35.0(typescript@5.9.3) '@playwright/test': specifier: ^1.64.1 version: 1.64.1 @@ -60,8 +60,8 @@ importers: apps/web: dependencies: '@kehto/shell': - specifier: ^0.30.0 - version: 0.30.0(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) + specifier: ^0.32.0 + version: 0.32.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) '@kernl/bridges': specifier: workspace:^ version: link:../../packages/bridges @@ -145,23 +145,23 @@ importers: specifier: ^0.9.0 version: 0.9.0(typescript@5.9.3) '@kehto/acl': - specifier: ^0.20.2 - version: 0.20.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) + specifier: ^0.20.3 + version: 0.20.3(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) '@kehto/firewall': - specifier: ^0.6.3 - version: 0.6.3(@napplet/core@0.37.0) + specifier: ^0.7.0 + version: 0.7.0(@napplet/core@0.38.0) '@kehto/nip': specifier: ^0.8.0 version: 0.8.0(nostr-tools@2.25.5(typescript@5.9.3)) '@kehto/runtime': - specifier: ^0.27.2 - version: 0.27.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) + specifier: ^0.28.0 + version: 0.28.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) '@kehto/services': - specifier: ^0.29.0 - version: 0.29.0(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) + specifier: ^0.30.1 + version: 0.30.1(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) '@kehto/shell': - specifier: ^0.30.0 - version: 0.30.0(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) + specifier: ^0.32.0 + version: 0.32.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3)) '@kehto/wm': specifier: ^0.0.2 version: 0.0.2 @@ -175,14 +175,14 @@ importers: specifier: workspace:^ version: link:../messaging '@napplet/core': - specifier: '>=0.37.0 <0.38.0' - version: 0.37.0 + specifier: '>=0.38.0 <0.39.0' + version: 0.38.0 '@napplet/nap': - specifier: '>=0.36.0 <0.37.0' - version: 0.36.0(typescript@5.9.3) + specifier: '>=0.37.0 <0.38.0' + version: 0.37.0(typescript@5.9.3) '@napplet/shim': specifier: '>=0.30.0' - version: 0.35.0(typescript@5.9.3) + version: 0.36.0(typescript@5.9.3) applesauce-accounts: specifier: ^7.0.2 version: 7.0.2(@capacitor/core@8.6.0)(typescript@5.9.3) @@ -227,11 +227,11 @@ importers: packages/guard: dependencies: '@kehto/firewall': - specifier: ^0.6.3 - version: 0.6.3(@napplet/core@0.37.0) + specifier: ^0.7.0 + version: 0.7.0(@napplet/core@0.38.0) '@napplet/core': - specifier: '>=0.37.0 <0.38.0' - version: 0.37.0 + specifier: '>=0.37.0 <0.39.0' + version: 0.38.0 devDependencies: quickpickle: specifier: ^1.11.2 @@ -249,8 +249,8 @@ importers: packages/messaging: dependencies: '@napplet/core': - specifier: '>=0.37.0 <0.38.0' - version: 0.37.0 + specifier: '>=0.37.0 <0.39.0' + version: 0.38.0 devDependencies: quickpickle: specifier: ^1.11.2 @@ -1266,43 +1266,43 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kehto/acl@0.20.2': - resolution: {integrity: sha512-JOg/OnIqEZw0Qc/IkvNDcxP94MsjovEJgRBlqVy4xa7raihkeWcye59W9N1j4pH2MVRqkYhXkWO2+ksJK4fVHw==} + '@kehto/acl@0.20.3': + resolution: {integrity: sha512-jmO9YG7rniUwGDzmeduoN0+4a6aez+7q/SIboZhu0dX55/Idfb/0vLfwXTPm/RN7lTCnlJU8sA6fbvoXknUVCw==} peerDependencies: - '@napplet/core': '>=0.32.0 <0.38.0' - '@napplet/nap': '>=0.32.0 <0.37.0' + '@napplet/core': '>=0.32.0 <0.39.0' + '@napplet/nap': '>=0.32.0 <0.38.0' - '@kehto/firewall@0.6.3': - resolution: {integrity: sha512-zDi3LUmBCmbMjImDlb2HKhzFU73WOq5H3G6gWdzc1ymGVDwuKPg0GlDQP41OQ60YiljQN6occZ/nOdavZGHXPg==} + '@kehto/firewall@0.7.0': + resolution: {integrity: sha512-C5KZCOp+VJBUi19lJkHd3lw4CRJK5fpOgrez36xyJqbGY2/7BquDz4REV+eDeqWmWyujAIketDVUDSjc76Tq3w==} peerDependencies: - '@napplet/core': '>=0.32.0 <0.38.0' + '@napplet/core': '>=0.32.0 <0.39.0' '@kehto/nip@0.8.0': resolution: {integrity: sha512-vmVTU4ey4zt04qiZcTMMiN6TKhttAFm9tYy4qy4nzNraCXi1mZqLznIZGbhiYwGA7TVWRCO9pIsUyv/umb+yuA==} peerDependencies: nostr-tools: '>=2.23.3 <=2.x' - '@kehto/runtime@0.27.2': - resolution: {integrity: sha512-NFQOlIoDrA56LkEfVxBcOMU9hVXn38ZlWHhUqTaWl5IRxoZE9kG6MtTMIO3qIOkRoe8OwhzYgGs90gE+H11rSA==} + '@kehto/runtime@0.28.0': + resolution: {integrity: sha512-oZGUjbaXnwFAg3gQg6znko+mJoxbD+Njo1U6AcNjcjzlemuk0bYcGJY8tL3bzn0JxRm9oav1nPApTQQVfNqu2Q==} peerDependencies: - '@napplet/core': '>=0.32.0 <0.38.0' - '@napplet/nap': '>=0.32.0 <0.37.0' + '@napplet/core': '>=0.32.0 <0.39.0' + '@napplet/nap': '>=0.32.0 <0.38.0' - '@kehto/services@0.29.0': - resolution: {integrity: sha512-vFbqrqueBx+G19bkaso4qJKNVCTfK+AfJPjkVagx+TK/RZ1mr87QxJkjMgsaLbMb2GPZun6g/uMJlfWVNY7IsQ==} + '@kehto/services@0.30.1': + resolution: {integrity: sha512-y77g/j0hN+dFP/n1X5MnM/tUrFTF85Ga3ZCAsht/h9EJYTr+aRF23MWYLOZdjx9aiWW16DOee1zZ7/M3YHyEfQ==} peerDependencies: - '@napplet/core': '>=0.32.0 <0.38.0' - '@napplet/nap': '>=0.32.0 <0.37.0' + '@napplet/core': '>=0.38.0 <0.39.0' + '@napplet/nap': '>=0.37.0 <0.38.0' nostr-tools: '>=2.23.3 <=2.x' peerDependenciesMeta: nostr-tools: optional: true - '@kehto/shell@0.30.0': - resolution: {integrity: sha512-o7vtS8VJr36omVoCxHqusyX1FGV1V4o5b3cU6jeF4g85M2sNguCyboCsyWBUvC4TtcAr2SKrhCkwQ84ablMaFg==} + '@kehto/shell@0.32.0': + resolution: {integrity: sha512-VhJ+qBMU67GDR/Uprj+REzOsFbac/H9vpwEqHyhyE9a1e3xb1MXX/OLcX++OYnizC/NvsDCTAuCVfRw6O5bcFQ==} peerDependencies: - '@napplet/core': '>=0.32.0 <0.38.0' - '@napplet/nap': '>=0.32.0 <0.37.0' + '@napplet/core': '>=0.32.0 <0.39.0' + '@napplet/nap': '>=0.32.0 <0.38.0' nostr-tools: '>=2.23.3 <=2.x' '@kehto/wm@0.0.2': @@ -1344,19 +1344,19 @@ packages: '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3 '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3 - '@napplet/core@0.37.0': - resolution: {integrity: sha512-Zujsku1KMZKBDW01DtdmpobJiVUjXPoELP9uR/SJZTirDuggXEWxw+GF5fG13CjQ2r0ywrCeWL3vwH+/ENDshQ==} + '@napplet/core@0.38.0': + resolution: {integrity: sha512-kAcmTiAYX00mPlCOUqK9rtuxysN5XBqBrwxeGf/r8NS+EdmYq6DtMqFBQ1+ii/DNO0uxN5JFChPzZfLA9FS5Uw==} - '@napplet/nap@0.36.0': - resolution: {integrity: sha512-xmR9VLXFFpcbr7g3VDoPRy5aPLHQAG+pd7AGhVIulYQVdeNbzi3lQ0ex2C4BUulbWVkZzpIrq4yGgCrVR0Revg==} + '@napplet/nap@0.37.0': + resolution: {integrity: sha512-EeUeXlHGEScRHU3WJgJax8di8zr6QXTdnE013TXwEm7zRkrh+TpRpm9KBt3KlDP2WV+JgRJ7Jw7PnTdzCjP3BA==} peerDependencies: json-schema-to-ts: ^3.1.1 peerDependenciesMeta: json-schema-to-ts: optional: true - '@napplet/shim@0.35.0': - resolution: {integrity: sha512-pjsWuCDe0LJorrvWtrt1yomc5HdiBELTHx3USoYx3cHZV1CbfTkZ930k5C10KNbvIodRUp56vUE1adYNbzPvjA==} + '@napplet/shim@0.36.0': + resolution: {integrity: sha512-hqM5X6oFMqWlXiex8Vv87eW2dLH2RzffqAMlfjCDvLviVGVjR24lmSxCkcKZIHkUDPuSU4/nA7hoekRRlaL01w==} '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} @@ -6239,43 +6239,43 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@kehto/acl@0.20.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))': + '@kehto/acl@0.20.3(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))': dependencies: - '@napplet/core': 0.37.0 - '@napplet/nap': 0.36.0(typescript@5.9.3) + '@napplet/core': 0.38.0 + '@napplet/nap': 0.37.0(typescript@5.9.3) - '@kehto/firewall@0.6.3(@napplet/core@0.37.0)': + '@kehto/firewall@0.7.0(@napplet/core@0.38.0)': dependencies: - '@napplet/core': 0.37.0 + '@napplet/core': 0.38.0 '@kehto/nip@0.8.0(nostr-tools@2.25.5(typescript@5.9.3))': dependencies: '@noble/hashes': 2.4.0 nostr-tools: 2.25.5(typescript@5.9.3) - '@kehto/runtime@0.27.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))': + '@kehto/runtime@0.28.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))': dependencies: - '@kehto/acl': 0.20.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) - '@kehto/firewall': 0.6.3(@napplet/core@0.37.0) - '@napplet/core': 0.37.0 - '@napplet/nap': 0.36.0(typescript@5.9.3) + '@kehto/acl': 0.20.3(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) + '@kehto/firewall': 0.7.0(@napplet/core@0.38.0) + '@napplet/core': 0.38.0 + '@napplet/nap': 0.37.0(typescript@5.9.3) '@noble/curves': 2.4.0 '@noble/hashes': 2.4.0 - '@kehto/services@0.29.0(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3))': + '@kehto/services@0.30.1(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3))': dependencies: - '@kehto/runtime': 0.27.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) - '@napplet/core': 0.37.0 - '@napplet/nap': 0.36.0(typescript@5.9.3) + '@kehto/runtime': 0.28.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) + '@napplet/core': 0.38.0 + '@napplet/nap': 0.37.0(typescript@5.9.3) optionalDependencies: nostr-tools: 2.25.5(typescript@5.9.3) - '@kehto/shell@0.30.0(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3))': + '@kehto/shell@0.32.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3))(nostr-tools@2.25.5(typescript@5.9.3))': dependencies: - '@kehto/acl': 0.20.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) - '@kehto/runtime': 0.27.2(@napplet/core@0.37.0)(@napplet/nap@0.36.0(typescript@5.9.3)) - '@napplet/core': 0.37.0 - '@napplet/nap': 0.36.0(typescript@5.9.3) + '@kehto/acl': 0.20.3(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) + '@kehto/runtime': 0.28.0(@napplet/core@0.38.0)(@napplet/nap@0.37.0(typescript@5.9.3)) + '@napplet/core': 0.38.0 + '@napplet/nap': 0.37.0(typescript@5.9.3) nostr-tools: 2.25.5(typescript@5.9.3) '@kehto/wm@0.0.2': {} @@ -6348,20 +6348,20 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true - '@napplet/core@0.37.0': {} + '@napplet/core@0.38.0': {} - '@napplet/nap@0.36.0(typescript@5.9.3)': + '@napplet/nap@0.37.0(typescript@5.9.3)': dependencies: - '@napplet/core': 0.37.0 + '@napplet/core': 0.38.0 nostr-tools: 2.25.5(typescript@5.9.3) transitivePeerDependencies: - nostr-wasm - typescript - '@napplet/shim@0.35.0(typescript@5.9.3)': + '@napplet/shim@0.36.0(typescript@5.9.3)': dependencies: - '@napplet/core': 0.37.0 - '@napplet/nap': 0.36.0(typescript@5.9.3) + '@napplet/core': 0.38.0 + '@napplet/nap': 0.37.0(typescript@5.9.3) transitivePeerDependencies: - json-schema-to-ts - nostr-wasm