Skip to content

Content filtering: getting to MBSmart / Gentech level

Where we are

KosherOS today filters by identity of the destination, not by what is on the page:

  • Cloudflare family DNS (1.1.1.3) blocks known adult and malware domains;
  • whitelist mode allows only named domains;
  • filtered mode decrypts HTTPS locally and applies admin-written URL rules (youtube.com/shorts*).

That is a real filter, but it is not what a family expects from a kosher filter. Cloudflare's list is broad and safety-oriented, not modesty- oriented: it misses immodest imagery on mainstream sites, most social media, video platforms, shopping sites with suggestive advertising, and anything new. And an admin cannot realistically hand-write rules to cover the web.

The gap between "blocks porn domains" and "a frum family trusts this machine" is the whole product.

Which ports an account may use

A supervised account's network is default-deny, not "everything except what is blocked". For an account in Filtered internet every TCP connection it opens — on any port — is redirected into that account's own proxy listener, so a web page served on 8080 or 8443 meets the same URL rules, category lists and picture filter as one on 443. Loopback is never redirected: a program on the computer talking to itself is nobody's business. Anything that reaches the proxy and is not HTTP, or TLS carrying HTTP, is refused rather than passed through (rawtcp=false).

Three things do not go through the proxy, because they are not web and the proxy could not read them:

  • Mail. IMAPS (993), SMTPS (465) and submission (587) are allowed directly. They are named protocols with a reason, not "ports the web might use".
  • Extra ports an administrator grants one account, under Beyond the web on the account's page (or kosherctl network UID --extra-ports 22). This is the advanced escape hatch for a program that is neither web nor mail — ssh, say. The filter's own ports (53, 80, 443) can never be granted this way. Empty by default.
  • Video calls. UDP cannot be inspected at all, and Zoom, Meet and school calls send picture and sound over UDP to high ports. That is allowed by default, so calls work without a visit to the admin app, and the switch on the account's page says plainly that this is the one channel the filter cannot read; turn it off for an account that has no calls to make. Below port 1024 an account gets only NTP; QUIC (UDP 443) stays refused.

DNS-filtered accounts have no proxy, so for them the web is 80 and 443 to anywhere plus the same named protocols; other TCP is refused. Whitelist only and No internet allow nothing beyond the approved sites, as before.

What the incumbents actually do

Worth being clear-eyed, because it sets the bar and the cost:

  • A curated category database. Millions of domains and URLs, classified (adult, dating, social, video, shopping, news, gambling…). This is the core asset. It is maintained continuously by paid staff and is why these services are subscriptions rather than products.
  • Human review. A queue where users request access to a site and a reviewer decides — usually same-day. This is what makes a strict filter livable, and it is a staffing commitment, not a feature.
  • Per-customer profiles. A rav or the vendor sets levels; families pick one and request exceptions against it.
  • Image and video handling. Blocking or blurring images on otherwise allowed pages, and turning off video/thumbnails on search and shopping.
  • Search enforcement. Forcing SafeSearch on Google/Bing/YouTube, often restricting YouTube to approved channels.
  • Reporting. What was visited, what was blocked, sometimes to a third party (an accountability partner).

What KosherOS should build, in order

The interception layer already exists — every filtered user's HTTPS passes through our proxy, where the full URL and the response body are visible. Everything below is a policy layer on top of infrastructure we have.

1. Category lists (the foundation) — BUILT

The image now imports the University of Toulouse blacklists at build time: 10.5 million domains across 24 categories (4.6M of them adult), stored as SQLite at 190 MB, answering a lookup in 4.6 microseconds with about 60 MB resident. A Python dict of the same data would cost hundreds of megabytes of RAM, which the family machine does not have.

Spot-checked against sites that matter: chinuch.org, torahanytime.com, artscroll.com, ou.org, chabad.org, sefaria.org, hebrewbooks.org, yeshiva.co, matzav.com, theyeshivaworld.com, kosher.com, wikipedia.org and several government and medical sites all come back clean, while pornhub, xvideos, onlyfans, chaturbate, stripchat, tinder, bet365, nordvpn and hidemyass are all caught. A list that blocked chinuch.org would be worse than no list, so that check runs before shipping.

The original plan for this piece

A signed, versioned list bundle the device downloads and the proxy consults per request: {domain or URL pattern -> categories}. Per-user policy then says which categories are allowed.

  • Start from open sources — Shalla, UT1 (Toulouse), StevenBlack, the Hagezi lists — which cover adult/gambling/social reasonably and are free.
  • Ship it as data over the portal, so lists update without an OS release; sign it with the same Ed25519 key as policy.
  • Design the format for a curated list of our own to replace or extend the open ones later. That curation is the long-term moat and the long-term cost.

2. Enforced safe search and video restriction

Cheap, high value, entirely mechanical in the proxy:

  • rewrite Google/Bing/DuckDuckGo queries to force SafeSearch;
  • send YouTube's Restricted Mode header, or map it to Restrict Moderate via DNS CNAME (restrict.youtube.com);
  • optionally allow YouTube only for an approved channel list.

3. Request-and-approve

The block page becomes useful instead of a dead end: "ask for this page". The request goes to the portal, an admin (or a reviewer at a service) sees the URL and who asked, and approves or denies. Approvals become URL rules automatically.

This is what makes strict settings tolerable, and we already have the two halves — a block page and a portal that pushes signed policy.

4. Image handling

The proxy can already see response bodies. Options in increasing cost: strip images by MIME type on flagged domains; blur them client-side with an injected stylesheet; or run a local nudity classifier (NudeNet-class model, CPU-only) on images above a size threshold. The last is the only one that generalises to unknown sites, and is the most expensive per request — worth prototyping only after 1–3.

5. Reporting

A per-user log of allowed and blocked URLs, visible to the admin and optionally mailed to an accountability partner. Cheap to build once the proxy records decisions; needs a clear retention policy, since it is the most privacy-sensitive thing in the product.

The honest constraints

  • Interception is required for anything above domain level, and it means the machine decrypts that user's HTTPS. Already true of filtered mode, and it must stay visible in the UI.
  • A curated list is staff, not code. We can ship open lists on day one, but matching MBSmart means someone maintaining categories and answering requests. That is a business decision.
  • Apps are not the browser. A phone-style app can bypass URL rules with certificate pinning; the app allowlist is what handles that, and the two layers have to be designed together.
  • Over-blocking is the usual failure. Every layer needs the request path from (3), or families disable the filter entirely.

Suggested first slice

Category lists (1) plus safe search (2), with the list delivered by the portal. That is a visible jump in filtering quality, uses infrastructure that already exists, needs no new staff, and makes the "filtered internet" mode behave the way someone expects when they read the label.

Asking for a page

Every filter is wrong sometimes. What decides whether a family keeps using one is not how often it is wrong, it is what happens next. If the answer is "find the parent, get them to open a settings app, work out which of five settings caused it and type the address in by hand", the real answer is that the filter gets turned off.

So the block page has a button. It records who asked, for what, and why (the filter's own reason, "category:video", travels with the request, so the admin sees "blocked by Video and streaming" beside it instead of guessing which of five settings was responsible). The admin app shows the queue as a blue banner under the header of whichever page is open — "2 requests waiting for you" — and each request is answered with one button on its row. A request carries no authority of its own — nothing changes until an admin says so — which is exactly what lets the asking be frictionless.

Nobody has to ask first. Everything the filter blocks, hides or refuses is written to an activity log (kosherd/activity.py; the proxy and the search service append to their own files under /var/lib/kosher-activity, the same 0730 root:kosher-spool arrangement as the request spool, and kosherd is the only reader). The admin app shows it as an Activity page in the sidebar — the filter's diary, newest first, narrowable to one person from the header bar — and on each person's own page as "Blocked today". A blocked page has an Allow button that does exactly what approving a request does (AllowUrl), so a parent who sees the block can fix it before the child comes to ask. It is a record of the filter, not of the person: what was allowed through is never written, so it cannot become a browsing history. Settings changes are in the same diary with the admin who made them, so a two-parent household can see who changed what. Everything is trimmed to a week.

Where the form posts. To the blocked site's own origin, on a reserved path (/__kosheros__/request), which the proxy answers itself and never forwards. A local http:// address would be mixed content from a page the browser considers https, and browsers refuse to submit that.

The spool. Requests are individual files in /var/lib/kosher-requests, mode 0730 root:kosher-spool. The two unprivileged services that can create one (the proxy and the search service) are in that group: they may drop a request, they may not list the directory or read anyone else's. kosherd, running as root, is the only reader. Ids are validated as 32 hex characters before they are turned into paths.

What approving does depends on the account's mode, because making the admin work that out is the same friction again. A whitelist account gets the domain on its whitelist — an allow rule would do nothing there, since its traffic never reaches the proxy. A filtered account gets an allow rule placed ahead of the existing rules, because one added after whatever blocked the page would never be reached. The admin chooses "just this page" or "all of this site"; a whitelist account is only offered the site, since that is all its enforcement can express.

Saying when the filter is not filtering

Every list loader here fails open and quiet. Miss the category database and the bundle is empty; miss the content terms and the scorer matches nothing; miss the word list and the profanity filter is off. That behaviour is right — a missing file must not take the machine down — and the silence is the part that is wrong, because the account still says "Filtered internet" while nothing it covers is filtered.

kosherd/selfcheck.py counts what actually loaded and reports anything empty or truncated in words a person can act on. A truncated list is the worse case of the two, because it looks like it is working.

FilterStatus gathers that with three other facts about the machine right now, as opposed to what is configured:

  • which services should be running and are not — only the ones this household's modes actually need, because a warning that does not matter teaches people to ignore warnings;
  • whether pictures are being checked, or hidden because the machine cannot keep up, or hidden because no model is installed;
  • whether the inspection certificate is generated, in the trust store, and the same in both places.

That last one is worth the space. Without a trusted CA, every HTTPS page a filtered account opens shows a certificate warning — the person sees a broken internet and a scary security error, and nothing connects that to the filter. A stale anchor (the CA regenerated without refreshing the trusted copy) gives the identical symptom and is harder to spot, so it is checked by comparing the two files rather than by their existence.

All of it appears in the admin app as an amber count beside Protection in the sidebar, with the full list first on the Protection page — and when nothing is wrong the badge is simply absent and the page says "Running", because a healthy machine that says nothing is indistinguishable from one that was never checked. kosherctl status prints the same; kosherctl lists prints what each list holds and where it came from.

Where an admin override goes

Lists ship complete and can be replaced or extended by an admin or the portal. That override used to be written under /var/lib/kosher, which is mode 0700 because it holds the policy — so the filtering proxy and the search service, both unprivileged, could not read a single one of them. They fell back to the shipped copy and said nothing, which meant the documented behaviour simply did not happen.

Overrides live in /var/lib/kosher-lists instead: a directory of nothing but lists, world readable, with no secrets in it. There is a test asserting no list is ever looked for under the private state directory again, and just check-services proves the proxy — running as its own unprivileged user — actually picks an override up.

Three layers

shipped   what came with the image
portal    a signed update, so a list improves without an OS rebuild
edits     this family's handful of additions and removals

A filter that can only improve when the operating system is rebuilt is a filter that goes stale, so the portal can publish a list bundle. It is signed with the same Ed25519 key as the policy and replay-protected the same way, because rolling a family back to last year's word list is an attack rather than a downgrade — there is a test for exactly that.

The bundle is the same for every enrolled device. A word list is upstream's business, not a family's, and what a family changes stays on their own machine: the portal has no reason to know it and no business holding it.

The portal layer replaces the shipped copy, and the family's delta is applied on top of whichever base is in force. So a portal update brings every new entry and still keeps a parent's edits — tested directly, by adding a word, removing another, then shipping an update and checking all three outcomes survive.

Only four filenames are accepted, by name. A portal that could choose the filename could write something that is not a list into a directory the filter reads.

A portal that predates all this returns 404, which is treated as "nothing new" rather than an error: an older portal should keep working, not start failing every sync with something nobody can act on.

The category database is a manifest, not a payload

Five million domains is 190 MB, which cannot ride inside a signed JSON document. So the bundle carries a manifest — a version, a URL, a size and a SHA-256 — and the device fetches the file itself.

The signature covers the hash, which is the whole point: the download needs no trust of its own. It can come from a CDN, a mirror, plain HTTP, and a byte out of place is caught on arrival. The hash is computed as the bytes stream in, so a mismatch costs one wasted download and never a wasted disk.

Then the file has to prove it is a catalogue before it is installed: it is opened, queried, and refused if it holds fewer than a hundred thousand domains. A truncated database that loads anyway is the worst outcome available, because it looks exactly like a working one. The old catalogue stays in place through any failure, and there is a test for that.

Publishing

just publish-lists https://portal.example sends this repository's lists to a portal, so a family on an older image catches up without waiting for a new one — publishing is "send what the next image would have shipped".

Add --catalog-url to publish a category-database manifest alongside them. The hash is taken from the local file rather than from whatever the URL returns at that moment, which is the mistake this would otherwise invite: signing a hash of something you have not seen.

The script sends only the four names a device will accept. Anything else is silently dropped on arrival, and refusing to send it is better than discovering that later.

An edit is a delta, not a copy

The lists ship complete on purpose. What a family will actually do is disagree with a handful of entries: a word they want cleaned that is not listed, a term that keeps blocking something innocent.

If that edit were a copy of the whole list, the family would silently stop receiving every later improvement to it, and nobody would notice for a year. So an override is {"add": ..., "remove": [...]}, applied on top of whatever ships — add one word today and next year's shipped additions still arrive. A complete replacement is still honoured, because the portal may legitimately want to send one.

Three lists are editable this way: the bad-language list, the blocked search terms, and the words pages are judged by. The shop rules and the five-million-row category database are not, because neither is a thing to hand somebody a text box for.

EditList caps an edit at a hundred entries, and the refusal says why: the answer to needing more is not "try again with fewer", it is that the shipped list is not finished and should be fixed for everybody. GetListEdits reports how many entries ship alongside how many the family has changed, because "119 ship with KosherOS, you have added two" is the sentence that stops somebody trying to build the list themselves.

From the command line: kosherctl words show words, kosherctl words add words blast --replacement bother, kosherctl words remove searches "bikini atoll".

Other languages

A family that reads Hebrew, Russian or French at home was, until the lists learned those languages, protected only by the domain lists. Every word list was English, and the matchers assumed it: a word boundary was "not [A-Za-z0-9]", a disguised letter was a Latin letter, and a page in another script was, as far as the matcher was concerned, one long word.

The lists now ship in sixteen languages — English, Hebrew, Yiddish, Russian, Ukrainian, French, Spanish, Portuguese, German, Italian, Dutch, Hungarian, Polish, Arabic, Persian and Turkish — chosen for where Jewish families live. Each is one file under os-image/lists/, and the three shipped files are built from them (just lists); a test fails when they drift. The README there says how to add a word or a language and, more importantly, what to leave out.

The matchers learned what the lists needed (kosherd/textfold.py):

  • A word is a whole word in any script, and in Hebrew and Arabic also behind the prefixes those languages glue on — והביקיני counts as ביקיני, البورن as بورن — while מזונות is left alone. This cuts both ways, which is why זונות is not on the list: with a prefix allowed, the bracha is מ plus the plural of a curse.
  • Vowel points and accents between letters are ignored, and a mark before a letter is the middle of a word, not a boundary.
  • An accented letter matches its plain spelling — cabrón and cabron, kurwa mać and kurwa mac — for the accents that mark stress, and only those: ñ is not n (coño, cono), ö is not o (göt, got). ß is ss, ё is е, the alef variants are one letter, and the Persian kaf is the Arabic one, so one list serves both.
  • The bad-language list compiles one pattern per script and only runs the ones a page contains; the alternation is factored by first letter, so fifteen hundred words scan a page in about the time a hundred and fifty did.
  • A page is scored in its folded spelling, so a term is listed once. A Hebrew phrase may take the article on its second word (נערות הליווי), an Arabic one on both.
  • The "writing about the problem" register that halves a page's evidence speaks the same languages.

Testing the lists on real front pages in every language found the problems a word list has and a unit test does not. Three were in the matcher and applied to English too: a digit standing in for a letter let numbers match short words (8.5 read as bs, 455 as ass), so a word under four letters is now matched as written and a disguise must keep half its letters; a page cut at the scan limit inside a script or an SVG path leaked its source into the "visible" text, so the unterminated tail is dropped first; and a vowel mark counted as a word boundary. The rest were entries that are ordinary words in a neighbouring language, because every Latin-script list shares one matcher: af and hoe are Dutch, hell is German, git is Turkish and a program, pédé folds to the Portuguese pede, fica is Portuguese, huren is Dutch for renting, mayo is mayonnaise, string and body are code. They were removed, and the sweep that caught them is now a test — two tests, in fact. The sentences it produced are in the unit suite, and the sweep itself is scripts/list-sweep.py (just sweep), which CI runs weekly and on every change to the lists or the matchers. It fetches the curated ordinary sites in every language, the top of the web minus the adult category for breadth, and a fresh sample of the adult category for recall, judges each as the proxy would, and fails the run when ordinary pages read as explicit too often, a language's pages are rewritten wholesale, or the adult sample slips past the scorer. The report names the ordinary pages the lists reacted to and never an adult one.

Two limits remain, and both are recorded rather than hidden. A word two Latin-script languages spell alike gets one replacement — puta reads as Spanish on a Portuguese page — because nothing tells the proxy what language a page is in; reading <html lang> would fix that and is the natural next step. And the scorer's plural rule is English (bra matches bras, which is French for arms, worth four points), so the other lists carry their own forms.

YouTube

Restricted Mode is one switch for the whole site, which is useless for a family that wants shiurim but not entertainment. So there are three levers, and the important part is where they are applied.

Per category. YouTube labels every video with one of fifteen categories, and a parent can turn any of them off — block Entertainment and Gaming, keep Education. The settings keep a category's id (10); YouTube's player JSON names it (Music, and Howto & Style where the table says How-to); the proxy maps the name to the id, however it is spelled, and reads the microformat's category rather than the first "category" key it meets. (For a while it compared the name with the id as they were, and no category limit did anything.)

Shorts. Not a category — a short can be in any of them — but a kind of video all the same, and the one no category limit reaches. It is a switch of its own in the same list, on by default. Off means the /shorts pages are refused, the reel endpoints and the ordinary player are answered "cannot be played" when the page asking is a short, and the Shorts shelves and reel links are taken out of the feeds.

Thumbnails. At the modesty picture levels YouTube's thumbnails (i.ytimg.com) are treated as image-search thumbnails are: small pictures of the whole web from a host the family cannot curate, hidden whenever a person is in them rather than judged one by one at a size the detector cannot judge reliably.

Per channel. An approved-channel list, which when it has anything in it is the whole allowance: only those channels play.

Restricted Mode, sent as a header on every request, as a floor under both.

It has to be enforced in the app, not on the page

YouTube is a single-page application. The first load is HTML; after that, every video is fetched as JSON from /youtubei/v1/player and no watch page is ever parsed again. A filter that reads only the HTML therefore checks the first video a child opens and nothing they click afterwards — which is almost all of them.

So the rules are applied to the player API — on the site's own hosts and on youtubei.googleapis.com, where the clients call the same endpoints and where an answer used to go out with no rule applied at all — and the block is returned in YouTube's own "cannot be played" shape rather than as a block page. That response is consumed by the player, not read by a person: a 403 spins forever, and an HTML page where JSON was expected is a broken app.

What a person sees, and why it is not always the same frame

A video opened by its address is a page, and gets the KosherOS block page with its "ask for this page" form. A video clicked inside the app is a player answer, and gets YouTube's own error frame. The frame differs because the request differs; the words are the same — "Blocked by KosherOS", then the reason and "you can ask the administrator of this computer for it. Nothing changes until they say yes." Both are written to the activity log either way.

A third message is not ours at all: with Restricted Mode on, YouTube blocks some videos by itself and says so in its own words. Nothing on this machine can make that one look like ours, which is the price of having a floor under the per-account rules.

The preview that plays on hover

Hovering a thumbnail plays the video: the feed carries an inline player and a moving thumbnail, and neither asks the player API whether it may. For any account with YouTube limits those keys are taken out of the feed — keys, not entries, so the video keeps its picture and its title and only the part that plays on hover goes — and the animated preview picture itself (an_webp on the thumbnail host) is hidden if a client asks for one anyway.

The feeds, for approved-channel accounts only

An account limited to a few channels would otherwise get a home page full of videos that all fail to play, which teaches a child that the computer is broken rather than that somebody chose this. So the feed responses are pruned to the channels that will actually play.

Deliberately conservative: an entry is dropped only if it both looks like a video renderer and names a channel, so anything with an unfamiliar shape survives untouched. The channel is matched by shape — the id, the handle, or the byline text — not by searching the response for a name, because a title mentioning an approved channel is not that channel's video. Nothing is pruned for accounts that are not limited to a channel list; playback filtering already covers those, and rewriting a feed nobody needed rewritten is all risk and no benefit.

Ad blocking, for everyone

Like a Pi-hole, and for the same reason: the machine already forces every account's DNS through its own two resolvers (the family one that answers filtered accounts, the plain one that answers unfiltered ones — see kosherd/nft.py), so a domain list rendered into both reaches every browser and every app on every account. A browser extension covers one browser for one account and can be switched off; this cannot be stepped around from inside a session, and Firefox is told (DNSOverHTTPS locked off) not to carry its DNS elsewhere.

What is blocked is the catalogue's ads category, which at image build is UT1's advertising lists plus StevenBlack's unified hosts — Pi-hole's own default list, adware and tracker domains merged from several reputable sources, about seventy thousand domains, MIT-licensed. kosherd/dns.py render_adblock writes them as address=/domain/ (NXDOMAIN: one line each, and an ad that does not resolve is an ad the page never waits for) into /etc/kosher/dnsmasq.d/adblock.conf and /etc/kosher/dnsmasq-open.d/ adblock.conf. A dnsfilter account that also blocks the ads category is not given the list twice.

Machine-wide, on by default. It is not content filtering, so it applies to unfiltered accounts too; switching it off is the guardian-gated act (SetAdBlock, the switch on the Protection page under "Administration" in the admin app, kosherctl adblock off), because an ad network is also where immodest imagery arrives uninvited. It sits there, under "Applies to everyone", rather than on a person's page, because one resolver cannot answer differently per account — the page says so next to the switch. What it does not do: cosmetic filtering (an empty box where an ad was), first-party ads served from the site's own domain, and ads inside YouTube's own player, which the YouTube limits handle.

Which settings act in which mode

Twice the interesting bug has been a setting that quietly did nothing for some kind of account. So it is a matrix test rather than a habit of checking: for every mode, a user is given every setting a non-default value, the real enforcement artefacts are rendered, and each setting must either turn up in one of them or be named as deliberately inert with the reason. A new setting that reaches nothing fails the suite.

Two things fell out of writing it.

The search service was given settings it could not act on. Its policy file carried rules, whitelists and categories for unfiltered accounts — harmless in itself, and exactly the sort of file somebody later reads as evidence that those settings are applied. It now carries only what the account's mode can use; an unfiltered or no-internet account is one word.

Two controls were greyed out although they still act. "Pictures and video" and "Bad language" were disabled outside filtered mode, with subtitles saying pictures can only be filtered there. That stopped being true: the media level also decides whether image search results are shown at all, and the language setting also blocks searches containing bad language. Both are editable again in every mode that enforces anything, with subtitles that say exactly which half applies. Disabling a control that still acts is worse than a wordy subtitle.

YouTube stays greyed out everywhere but filtered mode, and that one is genuine: nothing outside the proxy can see which video is playing.

Ready-made approved-site lists

"Whitelist only" is the strongest thing this product offers and the most work to set up: Sefaria alone loads from half a dozen hosts, and a page whose fonts and scripts are blocked looks broken rather than blocked — which is how a filter ends up switched off. So the image ships bundles (os-image/files/usr/share/kosher/whitelist-bundles.json, kosherd/whitelists.py) that a parent switches on instead of typing domains:

  • Torah study — Sefaria, YUTorah, TorahAnytime, the Daf Yomi sites, Chabad.org, HebrewBooks, OU Torah, Alhatorah, Mechon Mamre and the rest, with the audio and asset hosts they use.
  • Email and files — Gmail, Outlook, OneDrive, Google Drive, Dropbox, Proton, iCloud, with the sign-in and content hosts each needs. Deliberately not google.com or microsoft.com as a whole: entries include their subdomains, so a bare google.com would open search and YouTube alongside Gmail. There is a test asserting those stay out.

Any number can be on at once, and a set of shared hosts (fonts, script CDNs) rides along whenever any bundle is on. The account's own list is merged with them, so a family still adds its shul's website by hand. The merge happens in whitelists.effective() and is what dns.py renders, so switching a bundle on reaches the resolver on the next apply.

kosherctl site-lists show prints the bundles; kosherctl site-lists set <user> torah switches them on without the GUI.

And in the admin app, a whitelist account is not asked which kinds of site to block: it reaches its list and nothing else, so the category grid would be fifteen toggles that change nothing. The approved lists take their place on that tab.

Developer tools on a filtered machine

In "Filtered internet" mode the machine reads the account's HTTPS with its own certificate authority. Browsers, curl, git and Go trust it through the system store, which mitmca.install adds the anchor to. The language package managers do not: npm, yarn, pnpm and bun (Node's own bundle), pip and requests (certifi), uv, gem, cargo and deno each carry their own certificates and would refuse every download with a certificate error, which to a student looks like "the internet is broken".

So the image sets, for every account, the environment each tool honours: SSL_CERT_FILE, REQUESTS_CA_BUNDLE, PIP_CERT, NODE_EXTRA_CA_CERTS, CARGO_HTTP_CAINFO and GIT_SSL_CAINFO at the system bundle (/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem, which contains the KosherOS authority once inspection is set up and is Fedora's ordinary bundle otherwise), and DENO_TLS_CA_STORE=system, UV_NATIVE_TLS=1. Twice: /etc/profile.d/kosher-ca.sh for login shells and /etc/environment.d/50-kosher-ca.conf for programs started from the desktop. Java is deliberately left out: JAVA_TOOL_OPTIONS prints a line on every JVM start; Fedora's trust store already extracts a Java keystore at /etc/pki/ca-trust/extracted/java/cacerts for anyone who needs it.

And the registries those tools fetch from — npm, yarn, bun, PyPI, astral, RubyGems, crates.io, deno.land, JSR, the Go proxy, GitHub release assets — are in DEVELOPER_REGISTRIES (policy.py), reachable from every account like the OS's own update and Flathub hosts. They hold code, not pages, and a school or work project on this computer needs them on day one.

Who is connecting: one port per user

The proxy has to know which person a connection belongs to, because two people at the same machine get different filtering. nftables already knows — it dispatches every packet by the owning uid (meta skuid) — so rather than redirect everyone to one proxy port and have the proxy rediscover the owner afterwards, kosherd gives each filtered user their own loopback port (30000, 30001, … in uid order) and redirects them to it. The proxy listens on exactly that set and reads the user from the port a connection arrived on. One map lookup, deterministic, nothing to race.

The first design redirected everyone to port 8080 and scanned /proc/net/tcp for the client's source port. A few curls never caught what a browser's connection churn did: a just-closed connection lingers in TIME_WAIT reporting uid 0, Linux reuses its port for a new connection to a different server, and the scan found the ghost first — so a child became "root", a uid with no policy, which then meant the open web. The kosherd.uidmap lookup still exists (the search service uses it, matching the full 4-tuple on live sockets only), and the proxy falls back to it only for a rules file that predates per-user ports.

"Who is at the keyboard" would not have been enough either: GNOME's Switch User leaves the previous session running, and a browser left open there keeps making requests under its own uid while somebody else is active. The owner of the socket is the only right answer, and the port carries it.

Whatever the identification, a uid the proxy holds no policy for gets the fail-closed floor (the default categories, immodest media, substituted language), never the open web. Every connection reaching the proxy is a filtered user's by construction, so an unknown one is a fault to log — and it is logged — not a stranger to wave through.