The model wrote it. Did anyone decide it could do that?
frostjs is a deny-by-default capability gate for JavaScript. You write a policy that fits on one screen, in plain words:
may reach "api.example.com"
may use session storage
forbid cookies -- consent banner owns these
and the build fails on anything the code reaches for that the policy does
not grant: reading storage, setting cookies, calling eval, injecting a
<script>, opening a WebSocket to a host you have never heard of. The
report names the file, the line, the expression, and the policy line that
said no.
Code review was built for code written by a colleague at human speed. An
assistant writes a hundred lines in the time it takes to read ten, and
none of those lines arrive with an intent attached. A prompt says "cache
the results"; the model reaches for localStorage. A prompt says "load the
physics engine"; the model writes import("https://cdn.skypack.dev/...").
A prompt says "make the markdown render"; the model assigns innerHTML.
Each is a reasonable reading of the words, each is a capability your
application now has, and nobody decided it.
A test suite does not catch this: the code works. A linter does not catch this: the code is well formed. A human reviewer skims it, because the diff is long and the code looks fine, which it is. The only thing that catches it is a rule that says what this project may do, written down before the code was, and a build that enforces it. That is the whole tool.
frostjs gives you:
- Deny by default. Everything is off until the policy turns it on. A new capability cannot arrive unnoticed, whoever or whatever wrote it.
- A policy a non-engineer can read and sign off.
frostjs summaryprints it in English.frostjs cspturns the same file into yourContent-Security-Policyheader, so the build-time gate and the runtime backstop cannot disagree. - Zero false positives as the product. Real scope analysis, so a local
named
fetchis not a network call. Every engine change runs against 21 MB of real, pinned, hash-verified JavaScript and the finding count must not move. - Adoption in one command.
frostjs init srcwrites a policy that grants exactly what the code does today, with a note on each line saying where. The first run passes. Then you delete what should not be allowed. - Dependencies by fingerprint. Vendored files are admitted by hash with the capabilities someone reviewed; a version bump that gains a network destination is refused and shown as a diff.
- Exceptions that expire.
may use local storage in "src/legacy/*" until 2026-12-01warns for two weeks, then fails. Drift has a deadline.
CLI, GitHub Action, pre-commit hook, ESLint plugin. One runtime dependency. MIT.
npm install -D @keithadler/frostjs
npx frostjs init src # writes frostjs.policy from what the code does today
git add frostjs.policy # commit it; it is the contract
npx frostjs src # passes, because init granted what existsThe package is scoped (@keithadler/frostjs) because npm's typosquat
rules reserve the bare name; the command it installs is frostjs.
Then add the check to CI:
# .github/workflows/frostjs.yml
on: [pull_request]
jobs:
frostjs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: keithadler/frostjs@main
with:
paths: src
args: --changed-since origin/mainFrom here on, a pull request that reaches for a capability the policy does
not grant fails with an inline annotation on the offending line. To allow
it, someone edits frostjs.policy in the same PR, and the reviewer sees
both changes side by side. That is the decision being made visible.
If an assistant writes code in your project, tell it the gate exists.
Paste this into CLAUDE.md, AGENTS.md, .cursorrules, or whatever your
tool reads:
## frostjs
This project is gated by frostjs (https://github.com/keithadler/frostjs,
npm package @keithadler/frostjs, installed as a dev dependency).
`frostjs.policy` at the repository root says which browser capabilities
the code may use: storage, network destinations, code generation, HTML
injection, identity, navigation, globals, workers. Everything else is
denied.
- Before finishing any change to JavaScript or TypeScript, run
`npx frostjs <paths you changed>` and make it pass.
- Read a denial as a question, not an obstacle. If the task genuinely needs
the capability, add the narrowest grant that covers it to
`frostjs.policy` (scope it with `in "<file>"`, name hosts with
`may reach "<host>"`, add `until <date>` if it is temporary) and say in
your summary that you widened the policy and why. If the task does not
need it, change the code instead.
- Never add `may use everything`, never add `// frostjs: ignore` to make a
build pass, and never delete or loosen an existing `forbid` line. Those
are a person's decisions.
- `npx frostjs summary` prints the policy in plain English if you need to
explain it.The instructions above are also in AGENTS.md in this
repository, which applies them to frostjs's own code.
frostjs is a build-time gate for code you own and dependencies you vet. Its
job is to enforce a policy on your project's build, and to answer, when you
point audit at a dependency, "what does this reach for, and does anything
untrusted flow into a dangerous sink." It is not a scanner that promises
novel vulnerabilities in popular packages. Those are the most-audited code on
the web; run frost across the top of npm and it stays quiet, because the
capabilities it finds there are the libraries' advertised jobs and the policy
is your application's, not theirs. A clean result on maintained code is the
tool working, not failing. Where it earns its keep is the deny-by-default gate
on first-party code and the moment before you adopt a dependency you have not
read.
The two write-ups below are demonstrations of audit on real, widely-shipped
code — not a claim that frost hunts 0-days. three.js 0.160.0 ships
examples/jsm/libs/ecsy.module.js, which, if a page that imports it is opened
with ?enable-remote-devtools in the URL, loads a script from a third-party
CDN, connects to a third-party relay, and evals whatever the relay sends.
frostjs reports it as
codegen.eval denied by default (no rule grants it): eval(data.script) under any policy
an application would plausibly write. The same run names a runtime
import() of physics engine code from cdn.skypack.dev. The full story, the
policy, the CSP it emits and the honest count of what else the policy
flags are in SHOWCASE.md. A second, fully-public example —
lottie-web evaluating expression strings embedded in an animation JSON, on
by default — is in showcase/lottie/README.md.
Published as @keithadler/frostjs.
Nine capability families across JavaScript, TypeScript, JSX, HTML, and Vue/Svelte/Angular templates (inline
scripts and attribute surfaces: on* handlers, javascript: URLs, remote
<script src>). Frost-dialect policies with shared bases (extends), scope
analysis, taint analysis (source -> sink, one hop across functions; sockets, React and worker registration included),
baselines, changed-lines mode, unused-grant reporting, json/sarif/github
output, a GitHub Action, an ESLint plugin, and a fingerprint registry for
vendored code with SRI output. Run on Excalidraw (656 files, a TypeScript
and React monorepo) it finishes in under a second and frostjs init writes
an 18-line policy; every finding was checked by hand. Swept across 50 real projects (25 apps, 25 core libraries) with zero false positives after tuning.
$ cat frostjs.policy
policy "checkout-widget"
may use session storage
may use local storage in "src/legacy/*" -- old code, rewrite by Q4
forbid cookies -- consent banner owns these
may use the cache until 2026-08-30 -- service worker experiment
$ frostjs src
src/app.js:2:1: storage.local denied by default (no rule grants it): localStorage.setItem("not-here", 1)
src/legacy/old.js:2:1: storage.cookie denied by "forbid cookies" (line 4): consent banner owns these: document.cookie
warning: frostjs.policy line 5: "may use the cache until 2026-08-30" expires in 7 days
3 files, 2 denied, 0 unknown
$ echo $?
1
npm install -D @keithadler/frostjs
npx frostjs init srcwrites a frostjs.policy in the current directory that grants exactly
what the code under src does today, one line per capability, scoped to
the files that use it when there are only a few, each with a note saying
where. The first check passes. Then read the file and delete what should
not be allowed; the build starts refusing it. A network destination the
code builds at runtime is called out in a hint rather than quietly widened
to may use the network. For a large codebase with debt you would rather
pay down than grant, use --baseline instead (below).
npx frostjs audit node_modules/some-widgetNo policy involved. It prints, alarming things first: untrusted input
reaching a dangerous sink (a URL parameter, document.cookie, or a
postMessage payload flowing into eval, innerHTML, or
importScripts - real taint analysis, see below); files where code
generation or script injection meets a network reach (a remote code
path, the shape that found three.js's bundled remote eval); code
generation from non-constant input, every host reached, hosts merely
named in strings (a lead, not a finding), service workers, postMessage
to any origin, and the capability counts. --format json for tooling.
Run it on a pull request's new dependency, or on the one you already have
and never read.
See docs/CAPABILITIES.md for the full taxonomy with
the policy phrase for each code, or run frostjs capabilities.
frostjs audit includes a bounded taint analysis. It answers the question
capability detection cannot: not "can this code eval?" but "does a value
from the URL, a cookie, or a postMessage actually flow into eval?".
That is the difference between a capability and a vulnerability.
const route = location.hash.slice(1);
document.getElementById("app").innerHTML = "<div>" + route + "</div>";
// audit: t.js:2 location.hash -> innerHTMLThe rule that keeps it honest: taint survives only through operations
that provably preserve it - string methods, URL decoding, JSON.parse,
template concatenation, member access. Any other function call breaks the
chain, so innerHTML = DOMPurify.sanitize(x) is not flagged while
innerHTML = x is.
- Sources:
location.search/.hash/.href/.pathname,document.URL/.cookie/.referrer/.baseURI,window.name,URLSearchParamsreads, awindowmessagehandler'sevent.data, and theevent.dataof a WebSocket/EventSource this file constructs. - Sinks:
eval,Function,innerHTML/outerHTML/srcdoc,insertAdjacentHTML,document.write,importScripts,import(),setAttribute("on*"/"srcdoc", ...), ReactdangerouslySetInnerHTML, andnew Worker/serviceWorker.register. Open redirect (location = x) is deliberately not a sink: a sweep of 20 top apps showed it is almost always benign same-origin navigation, and static analysis cannot tell that from a real open redirect. - One hop across functions: a tainted argument passed to a local
function whose parameter reaches a sink is flagged (
setHtml(x) { el.innerHTML = x }called withlocation.hash), reported... (via setHtml()). A parameter only counts if it reaches the sink through provably-preserving operations, so a helper that sanitizes its argument is not a sink. - Limit, stated plainly: flow is followed one function hop, not a whole
call graph (a parameter that reaches a sink through a second function is
not summarized), and DOM input values (
el.value) are not modeled as sources. It finds real flows; it does not claim to find all of them.
Run over 21 MB of popular packages it reports zero (mature libraries sanitize); on real application code it lights up the flows a reviewer would flag by hand.
Taint is a human report in frostjs audit and, with --taint, an
enforced gate in frostjs check: each flow becomes a taint.<sink>
finding that fails the build and appears in the json, sarif and github
outputs, so it lands as a code-scanning alert on the pull request.
--changed-since, --baseline and // frostjs: ignore[taint] all apply
to taint findings, so it adopts on a legacy codebase the same way the
capability gate does. It is off by default because it is best-effort;
the capability gate stays deterministic. Put forbid tainted flows in
frostjs.policy to turn the gate on for everyone without the flag, so the
committed policy expresses the whole security posture in one place.
A policy is a frostjs.policy file in frost's policy dialect: one rule per
line, -- or # comments, case-insensitive keywords. Deny-by-default, so
the file only ever grants. A trailing comment on a rule is its hint, and is
printed whenever that rule refuses something.
policy "<name>" optional, once
extends "<base.frostjs.policy>" merge a base policy first
ignore "<glob>", ... files not analyzed at all
vendored "<glob>", ... third-party files, checked by fingerprint
may use <capability> [in "<glob>", ...] [until YYYY-MM-DD]
may reach "<host>", ... [in "<glob>", ...] [until YYYY-MM-DD]
forbid [using] <capability> [in "<glob>", ...]
forbid reaching "<host>", ... [in "<glob>", ...]
forbid everything else optional, readability only
forbid tainted flows gate on taint (like --taint)
<capability> is a phrase or a code. A family name grants the whole family.
| phrase | code |
|---|---|
storage |
storage (every member below) |
local storage |
storage.local |
session storage |
storage.session |
cookies |
storage.cookie |
indexeddb |
storage.indexeddb |
the cache, caches |
storage.cache |
navigator storage |
storage.navigator |
the network |
network (any destination) |
code generation, eval |
codegen |
html injection |
dom-escape |
identity, fingerprinting |
identity |
navigation |
navigation |
| globals | globals |
| workers, service workers | worker |
| device access, file access, usb, bluetooth, notifications | device |
| everything | * |
Rules:
forbidalways wins overmay, somay use storage+forbid cookiesgrants everything in storage except cookies.inscopes a rule to path globs (*within a segment,**across segments, a bare name matches at any depth, a plain directory matches everything beneath it). Globs are relative to the policy file's directory.ignoreskips files entirely: generated bundles, test fixtures, anything that is not your code to police. It lives in the policy so the exception is visible in review, where--excludeon the command line is not.may reachgrants the network family only to the named hosts.*in a host spans any characters (*.internal)."same-origin"names relative URLs. A destination that cannot be read from the code is not allowed by a host list: cannot be shown to be allowed is not allowed. Grantmay use the networkif you really mean any destination.untilputs an expiry on a grant. Inside the last 14 days the build warns; after the date the grant denies with its own message. This is how drift is fought: an exception has to be renewed on purpose.
frostjs.policy is searched for in the directory shared by all the given
paths, then upward; the nearest one wins, so a monorepo can keep one per
tenant directory. --policy <file> overrides the search. With no policy at
all, every capability is denied and a note says so.
frostjs init [paths] write a starter frostjs.policy granting what the code does today
frostjs audit <paths> what the code does, no policy needed: hosts, codegen, script injection, remote code paths
frostjs <paths...> discover and analyze .js/.mjs/.cjs/.jsx/.ts/.tsx/.mts/.cts and inline <script> in .html under paths
frostjs csp print the Content-Security-Policy header the policy implies
frostjs summary print a plain-English reading of the policy
frostjs vendor add <files> fingerprint third-party files and record their capabilities
frostjs registry sync re-admit bumped dependencies whose capabilities did not change
frostjs sri [paths] print Subresource Integrity values for registered vendored files
frostjs --exclude <name> skip directories with this name (repeatable)
frostjs --exit-zero report findings but always exit 0
frostjs --policy <file> use this policy instead of searching for frostjs.policy
frostjs --today <date> treat YYYY-MM-DD as today when checking expiry
frostjs --min-confidence <c> lowest confidence that fails: certain, probable (default), possible
frostjs --baseline <file> denials recorded in this file do not fail the build
frostjs --update-baseline write every current denial into the baseline and exit 0
frostjs --changed-since <ref> fail only on uses in lines changed since the git ref
frostjs --taint also fail on untrusted input reaching a dangerous sink
frostjs --format <f> text (default), json, sarif, or github
frostjs --version print the version and exit
frostjs --help show usage
node_modules, dist, build, coverage and .git are always skipped
(unless a vendored glob reaches into them). .d.ts files are skipped:
they describe globals and contain no code. A path that names a file
directly is always analyzed.
TypeScript type positions are never references (let f: typeof fetch is
quiet), declare statements neither use nor shadow the globals they
describe, and as / ! / satisfies are looked through. In JSX,
dangerouslySetInnerHTML={...} and srcdoc={...} are html injection and
intrinsic <script> / <iframe> elements count like createElement;
component names and ordinary attributes are quiet.
Inline <script> blocks in .html and .htm files are analyzed in place:
positions refer to the HTML file, type="module" blocks parse as modules,
and blocks with a src or a non-JavaScript type (JSON, import maps,
templates) are data, not code. Script elements are found with a regular
expression, which is right for markup people write and wrong only for
markup written to confuse it, which the threat model already excludes.
Exit codes: 0 clean, 1 policy violations, 2 usage or input error
(bad flag, missing path, syntax error).
Uses with possible confidence are listed under "unknown" and never fail the
build; certain and probable uses do.
- uses: keithadler/frostjs@main
with:
paths: src
args: --baseline .frostjs-baseline.json --changed-since origin/main
fail-on-findings: "true"Inputs: paths (default .), format (default github, which annotates
the pull request inline), args (extra flags), fail-on-findings (set
"false" for an informational run). Inputs reach the script through the
environment only, never spliced into the script body, so a hostile input is
an argument and not a command.
The same engine as an ESLint rule, so denials show up in the editor and on
eslint runs, with the same policy discovery (nearest frostjs.policy
above the file) and the same frostjs: ignore comments. eslint-disable
works too.
// eslint.config.js
import frostjs from "@keithadler/frostjs/eslint";
export default [frostjs.configs.recommended];
// or: [{ plugins: { frostjs }, rules: { "frostjs/capability": ["error", { reportUnknown: true }] } }]Options: policy (explicit file), minConfidence, reportUnknown
(also report uses the CLI would list as unknown), today.
repos:
- repo: https://github.com/keithadler/frostjs
rev: main
hooks:
- id: frostjsDependencies are not analyzed line by line; that is a year-long project that ends in noise. Instead the policy names which files are vendored:
vendored "vendor/**", "static/lib/*.min.js"
A vendored file is hashed (SHA-384, the same value SRI uses) and looked up
in .frostjs/registry.json beside the policy. A known hash contributes the
capability set somebody recorded for it, checked against the policy like
any first-party use. An unknown hash fails the build:
vendor/widget.min.js:1:1: vendored file is not in the registry; review it with: frostjs vendor add vendor/widget.min.js
frostjs vendor add analyzes the file once, prints what it found so a
person can look at it, and records the entry. A patch release changes the
hash, so the review happens again; that is the point. To keep that from
being a chore, frostjs registry sync walks the vendored paths after a
dependency bump: a new version that uses exactly the capabilities the old
one did is re-admitted automatically and noted; one that gained a
capability or a new destination is refused with the difference printed,
which is the "dependency bump silently introduces a new network
destination" case from the threat model. Entries whose file is gone are
pruned, and the lockfile's hash is recorded so the next run can say
whether anything moved. A vendored glob may reach into node_modules;
the walk follows it there.
frostjs sri prints the same SHA-384 values as integrity attributes
(--format html for ready-made script tags, --format json for a build
step), so the browser refuses at load time exactly what the registry never
reviewed. A vendored file that is not in the registry is refused here too.
The same frostjs.policy drives the linter ruleset, a CSP header, and a
reviewer's summary, so they cannot drift apart.
frostjs csp prints the header and nothing else, for nginx or the CDN
config. Only directives the policy determines are emitted: connect-src
from may reach hosts ('none' when nothing is granted, * for
may use the network), script-src 'self' plus 'unsafe-eval' when code
generation is granted and the reach hosts when dynamic import is, and
worker-src when workers are. Expired grants do not widen it; path-scoped
grants do, because a header covers the whole page.
frostjs summary prints what the code may do, what it may not, and spells
out the implicit deny:
Policy "proj" (frostjs.policy)
This code may:
- use session storage (line 2)
- use local storage, only in src/legacy/* (line 3) - old code, rewrite by Q4
- use the cache, until 2026-08-30 (line 5) - service worker experiment
It may not, even where a broader grant would allow it:
- use cookies (line 4) - consent banner owns these
Everything else is denied. In particular this code may not use: the network, code generation, html injection, identity, navigation, globals, workers.
text(default): one line per denial with the policy line that denied it, unknowns in their own section, warnings, then a summary.json: a versioned document (schema: 1) with every decision, the policy used, a summary by verdict, and expiry warnings.sarif: SARIF 2.1.0 for code scanning. One rule per capability code; denied uses are errors, unknown uses warnings, baselined uses carrybaselineState: "unchanged".github: GitHub Actions workflow commands (::error file=...) so each denial shows up inline on the pull request, followed by the text report.
frostjs --baseline .frostjs-baseline.json --update-baseline srcThat records every current denial, keyed on file, capability and expression
text (never line numbers), and exits 0. From then on
frostjs --baseline .frostjs-baseline.json src fails only on new uses;
existing ones are counted as "baselined". Commit the file; shrink it as the
debt is paid down. Paths inside it are relative to the file's directory.
For pull-request checks, frostjs --changed-since origin/main src fails only
on uses that sit in lines the branch added or modified; the rest are counted
as "unchanged". Untracked files count as entirely changed.
frostjs check --unused src lists grants that matched nothing on the scan,
so an over-broad or redundant line can be removed:
2 grants matched nothing (remove, or scan more):
frostjs.policy line 3: may use session storage
frostjs.policy line 5: may use cookies
It prints to stderr and does not change the exit code. Run it on a full scan, not a changed-lines subset, or a grant will look unused only because the file that needs it was not scanned.
An organization or monorepo can keep one base policy and extend it:
# packages/widget/frostjs.policy
extends "../../frostjs.base.policy"
may use local storage
The base is merged in first, so its grants apply and its forbid lines
(and forbid tainted flows) cannot be loosened by a child. Path globs in
the base are interpreted relative to the base file and rebased when merged,
so may use cookies in "legacy/*" keeps meaning the base's legacy/. A
cycle or a missing base is a precise error naming the line.
// frostjs: ignore[storage.local]
localStorage.setItem("draft", text);
fetch(url); // frostjs: ignoreA bare frostjs: ignore suppresses every capability on that line; a
bracketed list suppresses only those codes or families. The comment applies
to its own line or, when it stands alone, to the line after it. Suppressed
uses are counted in the summary and never fail the build. Prefer a scoped
may ... in "file" line in the policy when the exception should be visible
to a reviewer; suppression is for the one-off.
| code | what |
|---|---|
storage.local |
localStorage |
storage.session |
sessionStorage |
storage.indexeddb |
indexedDB |
storage.cache |
caches |
storage.cookie |
document.cookie |
storage.navigator |
navigator.storage |
network.fetch |
fetch |
network.xhr |
XMLHttpRequest |
network.websocket |
WebSocket |
network.eventsource |
EventSource |
network.beacon |
navigator.sendBeacon |
network.import |
dynamic import() of an absolute URL or an expression |
network.webtransport |
new WebTransport(url) |
network.webrtc |
new RTCPeerConnection() (peer-to-peer connection) |
network.importscripts |
importScripts(url) in a worker (loads and runs a script) |
network.resource |
el.src = "https://..." or setAttribute("src", ...) naming another host (literal or folded const only) |
codegen.eval |
eval |
codegen.function |
Function(...), new Function(...) |
codegen.timer |
setTimeout / setInterval with string code |
codegen.write |
document.write, document.writeln |
dom-escape.html |
assignment to innerHTML / outerHTML / srcdoc, insertAdjacentHTML, createContextualFragment, JSX dangerouslySetInnerHTML / srcdoc |
dom-escape.script |
document.createElement("script"), JSX <script> |
dom-escape.iframe |
document.createElement("iframe"), JSX <iframe> |
dom-escape.handler |
setAttribute("onclick" / "onerror" / ..., code) (installs a handler from a string) |
identity.device |
navigator.userAgent, platform, vendor, plugins, hardwareConcurrency, deviceMemory... |
identity.geolocation |
navigator.geolocation |
identity.media |
navigator.mediaDevices, getUserMedia |
identity.clipboard |
navigator.clipboard, document.execCommand("copy" / "paste") |
identity.credentials |
navigator.credentials |
identity.permissions |
navigator.permissions |
navigation.location |
assignment to location / location.href etc., location.assign / replace / reload |
navigation.open |
window.open |
navigation.history |
history.pushState / replaceState / back / forward / go |
navigation.postmessage |
postMessage to parent / top / opener / contentWindow, or with a string origin |
navigation.message-receive |
window.addEventListener("message", ...) whose handler reads event.data but never checks event.origin |
globals.window |
assignment to window.* / globalThis.*, Object.defineProperty(window, ...) |
globals.prototype |
assignment to a built-in or its prototype (Array.prototype.x = , Error.prepareStackTrace = ), or Object.defineProperty / assign on one |
worker.dedicated |
new Worker(url) |
worker.shared |
new SharedWorker(url) |
worker.service |
navigator.serviceWorker.register(url) |
worker.worklet |
CSS.paintWorklet.addModule(url), audioWorklet.addModule(url)... |
device.filesystem |
showOpenFilePicker / showSaveFilePicker / showDirectoryPicker (read/write the user's files) |
device.usb / device.bluetooth / device.serial / device.hid / device.midi |
navigator.usb etc. (hardware access) |
device.wakelock |
navigator.wakeLock |
device.notification |
Notification |
device.payment |
PaymentRequest (the browser payment sheet) |
Each is recognized bare, via window / globalThis / self, and via a
computed member whose name is a string literal (window["localStorage"]),
a concatenation of literals, or a const the scope analysis can fold
(const k = "localStorage"; window[k], reported as probable).
Scope analysis is real, with hoisting: a local named fetch or window is
not the global, so a use through it is not reported at all, while the same
name declared in a sibling function does not hide anything. Only inside a
with block, where nothing can be resolved, is a use reported as possible.
Uses via self are probable rather than certain, since self is often
a local alias for this in older code.
Canvas and audio fingerprinting are deliberately not recognized: every charting and 3D library draws to canvases, and no static signature separates that from fingerprinting without false positives. CSP and the network family are the backstop for where such a fingerprint would be sent.
Network uses carry a target when it can be fixed statically. Frost's
rule applies: a literal that closes the authority fixes the host, and nothing
after the slash can move it. fetch("https://api.example.com/items/" + id)
reaches api.example.com; fetch("https://" + host) reaches nobody we can
name. Relative URLs are same-origin. Dynamic import() of a relative path
or a bare package name goes through the bundler, not the network, and is not
reported.
- Not a runtime sandbox. No membrane, no proxied globals. A determined attacker with code execution defeats any wrapper; that fight is not worth having.
- Not a replacement for CSP. It emits CSP, and CSP remains the runtime backstop. This tool is the build-time gate.
- Not a universal npm scanner. Dependencies are admitted by fingerprint against a registry, not analyzed line by line.
frostjs catches careless or accidental use of forbidden APIs in first-party,
tenant, or model-generated code, and it catches drift over time. It does not
catch deliberately obfuscated code, runtime-constructed access beyond a shallow
constant fold, or anything injected after the build. The value is a high floor,
not a ceiling.
npm install
npm test vitest (npm run test:watch to keep it running)
npm run lint prettier --check, then typecheck src, test and scripts
npm run format prettier --write
npm run build tsc to dist/
npm run corpus scan the pinned corpus; fails if findings changed
npm run showcase reproduce SHOWCASE.md
Zero false positives is the product. npm run corpus runs the extractor over
six pinned, hash-verified npm packages (about 21 MB of real JavaScript) and
diffs the findings against corpus/expected.txt. Any change to src/extract/
must leave that diff empty, or update the file deliberately with
npm run corpus -- --update and explain why in the commit.
- ARCHITECTURE.md: how the code is laid out and the contract a recognizer signs.
- CONTRIBUTING.md: how to add a recognizer or a policy form, and the conventions.
- SECURITY.md: what a green run does and does not promise, and how to report a bypass.
- CHANGELOG.md.
- REQUIREMENTS.md: the original plan, with every step marked done and every decision recorded.
MIT, the same license as frost and exact. A build-time linter wants the widest possible adoption and gives nobody a reason to hesitate: no copyleft, no patent clause to have reviewed, nothing to attribute beyond the notice. Contributions are accepted under the same license.