Security model
The Worker permissions, the trust boundary, and the threat model. What the sandbox protects against — and what it doesn't.
The question
The engine runs untrusted-ish code: cloned generators may be edited by anyone on the team, and stock generators come from JSR (a public registry). When a generator runs, what damage can it do? What's the security boundary actually protecting?
The short answer
The Worker is a soft sandbox enforced by Deno permissions. It exists to limit damage from a bad generator, not to defeat a determined attacker. The threat model:
- Out of scope: stopping a malicious generator from exfiltrating data when the attacker controls the JSR publish pipeline.
- In scope: preventing a generator with a bug or a minor vulnerability from making arbitrary network calls, spawning subprocesses, or writing outside the project.
The Worker has read, write, and env Deno permissions. It
doesn't have net or run. The reasoning behind each
follows.
The trust boundary
The engine has two processes:
Host process
The CLI itself. Runs with the user's full permissions: anything the user could do at the shell, the CLI process can do. Reads configuration files, spawns the Worker, writes artifacts to disk, exits with status codes.
The host is trusted in the standard sense — it's the binary the user installed and invoked.
Worker process
The codegen process. Spawned by the host via new Worker(...)
with explicit reduced permissions. Receives the parsed document
and generator map; produces the artifacts and manifest; sends
them back.
The Worker is partially trusted. The user installed (or cloned) the generators it runs, but those generators may include bugs, unintended behaviors, or — in the worst case — malicious code. The permissions reflect this distrust.
Deno permissions granted to the Worker
The Worker spawns with read: true, write: true, env: true, net: false, run: false.
read: true
The Worker can read any file the host process could read. Needed for:
- Reading template files (generators may include
.txt/.tmplpayloads they reference at runtime) - Reading peer-dependency packages (e.g., a generator that
imports
@/lib/helper.ts) - Reading the project's own configuration if the generator wants it
Why not restrict to the project directory? Deno's permission
system supports allow-lists for read, but the Worker's
exact needs depend on which generators are installed. Restricting
to a specific directory would break generators that need to read
outside it. The trade-off: convenience over precision.
write: true
The Worker can write to any path the host could write. Needed for:
- Writing artifacts to the project's
src/generated/(or whereverbasePathpoints) - Writing the manifest
Why not restrict? Same reason as read: the exact write
paths depend on the configuration. The Worker writes wherever
the engine tells it to.
env: true
The Worker can read environment variables. Needed for:
- Some generators read env-driven config (e.g., a custom
scalar map driven by
SKMTC_SCALARS=...) - Some generators read
NODE_ENV-style switches for development vs production output style
This is the most contestable permission. Most generators don't need env access, and giving it opens an exfiltration channel (see residual risks). Tightening this to a specific allow-list is a possible future direction.
Denied: net
The Worker cannot make network calls. This is the most important denial. It means:
- Generators can't phone home (no telemetry to remote servers)
- Generators can't fetch additional schemas at runtime
- Generators can't exfiltrate read data over the wire
The OAS spec fetch (when source is a URL) happens host-side,
before the Worker spawns. The Worker never sees the network.
Denied: run
The Worker cannot spawn subprocesses. This means:
- Generators can't shell out to other tools
- Generators can't invoke compilers, formatters, or arbitrary binaries at generate time
- Generators can't manipulate git state, npm packages, etc.
Combined with net: false, the Worker is largely confined to
"transform inputs to outputs via in-memory logic." The damage a
bad generator can do is bounded.
Residual risks
The permission set isn't airtight. Three notable gaps:
env reads + write → exfiltration via git push
A malicious generator could:
- Read environment variables (
AWS_SECRET,DATABASE_URL, etc.) - Write them into a generated file (
/* leaked: $AWS_SECRET */) - The user commits and pushes the file
- The secrets land in a git history (and possibly a public repo)
The Worker permissions don't prevent this — both reads are allowed. The mitigations live downstream:
- Code review of generator changes (cloned generators are inspectable source)
- Secret scanning in CI (
git-secrets, GitHub's built-in scanner) - Lint rules on the generated output (rejecting suspicious patterns like base64-encoded blobs or env-name substrings)
Workspace pollution
A generator could write into directories outside the configured
output. For example, overwriting .gitignore, the project's
own source files, or even ~/.ssh/.
The permission system allows this. The mitigations:
- The manifest records every artifact path. Post-generate inspection catches unexpected writes.
- Code review. Cloned generators are short and inspectable; stock generators are widely-read.
- Restricted output directories at the OS level (rarely practical, but possible).
Read-everything
Even with write constrained somehow, read: true means the
Worker can read any file the user could. Sensitive files in
the working directory (test fixtures with real credentials,
.env files, ssh keys in ~/.ssh/) are technically readable.
The Worker has to write somewhere to surface that read data, so
this combines with the write issue above. But the read alone
is a concern for generators that might log read content to
stderr.
Mitigations
What the project does to make the residual risks tractable:
Clone-to-customize favors auditable source
Stock generators are MIT-licensed, intentionally small (200-500 lines), and meant to be cloned. Users can read the source before running it. The lack of an opaque plugin API means there's nowhere for malicious code to hide.
The contrast with plugin-based systems: a generator with a plugin API can ship a binary plugin you can't read. SKMTC's "source-code or nothing" approach makes inspection the default.
Manifest as forensic record
Every generate run produces a manifest.json listing every
artifact written. After a generate, comparing the manifest's
paths against git status surfaces unexpected writes:
skmtc generate my-api --json > generate-output.json
jq -r '.files[]' generate-output.json | sort > expected-paths.txt
git status --porcelain | awk '{print $2}' | sort > actual-changes.txt
diff expected-paths.txt actual-changes.txt(skmtc generate --json stdout has files as a flat top-level
array of paths — no manifest wrapper. The on-disk
manifest.json uses a different shape; see
manifest format.)
Discrepancies flag both undesired writes and undesired skips.
CI-side controls
The Worker's permission constraints aren't the last line of defense. Several CI patterns add layers:
- Secret scanning on generated output before merge
- Diff review for generated changes (e.g., requiring a human
approval on PRs that touch
src/generated/) - Lockfiles to prevent silent generator updates (pin via
deno.lock) - Generator-source review as part of clone hygiene (review any change to a cloned generator's source)
What SKMTC doesn't protect against
To be explicit about the threat model's limits:
- Supply-chain attacks on JSR packages. If an attacker compromises a stock generator's JSR publish, users who pull the new version run the compromised code. The mitigations are generic to the JSR ecosystem (lockfiles, integrity checks), not SKMTC-specific.
- Determined adversaries with code-execution intent. The Worker is a damage-limiting boundary, not a cryptographic one. An attacker who controls a generator's source can do anything the Worker permissions allow.
- Bugs in generators that leak secrets. A generator that accidentally includes environment data in its output isn't a security failure of the engine — it's a bug in the generator. Catching this is the user's responsibility (via review and secret scanning).
- Side-channel attacks (timing, memory observation, etc.). Not in scope.
The model: SKMTC ships a sandbox that's strict enough to make casual abuse hard, but not strict enough to defeat motivated attackers. Defense-in-depth at the CI/review layer covers what the sandbox doesn't.
Why not a stricter sandbox?
We've considered tighter permissions:
- Restricted
readandwriteto specific directories. Possible in principle but fragile in practice. Generator authors would need to declare which paths they need, and the CLI would need to compose permissions across generators. The complexity-to-benefit ratio isn't good. env: false. Possible but breaks scalar-customization flows. Could be opt-out per-project as a future improvement.- Process isolation beyond Workers (e.g., Firecracker microVMs). Massive complexity increase for a marginal threat reduction. Off-roadmap.
The current model is the equilibrium: enough sandboxing to make the common cases safe, not so much that generator authors fight the system.
See also
- The worker runtime concept — the operational details of Worker spawning
- Generators as packages concept — how generators are installed and updated
- Design philosophy — "build on the substrate" as it applies to Deno permissions
- skmtc-debug skill — operational guidance including the manifest-as-forensic-record pattern