Skip to content

Shape mode: load a schema without running anything - #1289

Draft
voiys wants to merge 12 commits into
dmno-dev:mainfrom
voiys:shape-mode-v2
Draft

voiys wants to merge 12 commits into
dmno-dev:mainfrom
voiys:shape-mode-v2

Conversation

@voiys

@voiys voiys commented Nov 10, 2026

Copy link
Copy Markdown
Contributor
# .env.schema
# @type=port
PORT=3000
# @sensitive @required
API_TOKEN=exec(`op read op://vault/api/token`)
AUTH_HEADER=concat("Bearer ", $API_TOKEN)

graph = await loadEnvGraph({ mode: 'shape' })
await graph.resolveEnvValues()

normal load:  runs the command   API_TOKEN = "tok_..."   AUTH_HEADER = "Bearer tok_..."
shape load:   runs nothing       API_TOKEN = unknown     AUTH_HEADER = unknown     PORT = 3000
              graph.shapeSkipped = [{ kind: 'resolver', name: 'exec', itemKey: 'API_TOKEN' }]

why

opening a repo has turned into a way to get attacked. from the last three months:

  • CHAINDROP (august): an npm worm in 400+ packages that commits a .vscode/tasks.json and a .claude/settings.json into every repo it can reach, so "a developer can still be infected by just opening the repository"
  • VS Code fixed six Workspace Trust and Restricted Mode bypasses in two releases (1.136.2 on sep 8, 1.141.1 on oct 13), two of them rated critical
  • LiteLLM 1.107.0 (oct 21) collected "nonempty environment variables" on "a normal Python startup"

a .env.schema can run a command, call a secret store and download a plugin, and today the only way to learn what a schema says is to do all of that. the VS Code extension is safe only because it doesn't use varlock's loader at all. the shared core we talked about in #469 would use it, and so do varlock codegen and a type checker. shape mode is the load those can use on a folder someone just opened.

what a shape load never does

runs exec() or any resolver not marked pure, executes a root decorator (except @setValuesBulk on data written in the schema), opens the cache, downloads a plugin, reads the shell env, reads a named pipe.

the promise

for every item it says unknown, or exactly the value and errors a normal load gives. same for required and sensitive, unless that one is flagged unknown.

  • shape-soundness.test.ts generates 1,200 schemas and loads each both ways. it fails on every seed if the unknown rules are removed
  • outside the repo: 32,000 generated schemas, 0 mismatches. 165 public schemas, same types as codegen on all of them. 100 hand-written hostile schemas, 87 clean

what's left: plugins

of the 13 hostile schemas that aren't clean, one is a regex written to backtrack forever, which the calling program can defuse for itself (the mapper does). the other 12 are all the same thing: a plugin that is installed still runs its own code when loaded. you described the fix in #469 back in march:

A middle ground could be that a plugin need to expose a json manifest of what it adds. Since we control the whole plugin pipeline and all the plugins (for now), we can build tooling that generates it at build time to maintain a single source of truth.

that was a while ago, but i think it's the right answer, and this is where it plugs in: a plugin with a manifest never has to be imported in a shape load. i have a rough version for the 18 first-party plugins. not in this PR.

the small PRs came out of this

#1282 to #1288 were all found while building and testing shape mode: the fuzzer, the hostile schemas and the generated test above each turned up bugs that are also in a normal load.

other limits

  • the generated schemas don't cover imports, forEnv, bulk values or plugins
  • a skipped command is assumed to succeed
  • a file behind an unknown enabled= condition keeps its items as if it surely exists

your call

the name shape, whether pure should be public plugin API yet, and whether shapeSkipped is public or internal.

includes the fix from #1286 so the generated test can't hang. i'll drop it once that merges.

voiys added 12 commits November 13, 2026 00:46
`new EnvGraph({ mode: 'shape' })` builds a graph for tools that only
inspect a schema (type checkers, language servers, CI linters):

- only resolvers marked `pure: true` run, all others resolve to undefined
- an item whose value depends on a skipped resolver, directly or through
  references, is unknown: it is left unset and is not coerced, validated or
  reported as a missing or failed value
- a @disable or @import condition that is unknown keeps the file enabled
- root decorator `execute()` is skipped (the items they reference are still
  resolved, so they keep their final validation state)
- no cache store is created
- plugins that are not installed or cached are never downloaded
- process.env overrides are ignored

Skipped work is recorded in `graph.shapeSkipped`. Default mode is
unchanged. The built-in resolvers that only read other values (concat, ref,
fallback, forEnv, if, ...) and the builtin VARLOCK_* vars are marked pure;
VARLOCK_BRANCH skips its local `git branch` lookup in shape mode.
Documents shape mode for tool authors, including the `pure` flag for
plugin resolvers.
- builtin variables no longer read the real shell env (an env passed in
  explicitly is still used), so CI variables cannot change results
- generated code is only written inside the root schema's directory; a path
  that leaves it (.., absolute, or through a symlink) is a schema error
- an import that is not a regular file (a named pipe) is a loading error
  instead of a read that never returns
- guide: documents these, that plugin pure flags are trusted like the plugin
  code that already runs, and that schema regexes have no timeout
Tests for each fail on the previous commit.
…known parts

- the named-pipe check moves from the direct-import branch to
  FileBasedDataSource, so a pipe found through a directory import, or the root
  schema itself, is refused too instead of hanging the load
- an error is only treated as caused by a partial value when the resolver it
  was first thrown from had an unknown input, so an error from a fully known
  part of the same expression is still reported
- the CI-branch test also checks that git is not run
Tests for the pipe cases and the mixed expression fail on the previous commit.
- ConfigItem keeps the unknown flag computed when its value resolver ran,
  so each dependent read no longer walks the resolver tree
- FileBasedDataSource does one stat instead of an access check plus a stat
…lk values

A file kept only because its @import enabled= or @disable condition could
not be evaluated might be skipped entirely by a normal load, so a @plugin
in it (or in a file it imports) is no longer loaded.

@setValuesBulk now runs in shape mode: it only resolves its args and edits
the graph. Data written in the schema is applied as in a normal load. When
the data or its enabled= condition comes from a skipped resolver, the items
it could set are unknown while empty instead of failing the required check.

A local plugin whose package.json is a named pipe or device is refused in
both modes instead of blocking the read forever.
… project folder

In shape mode the loader does not clear or open a cache store.
… on a skipped value as unknown

A computed @required/@optional or @sensitive/@public built from a skipped resolver is now
unknown instead of whatever the empty value happened to give: the item is treated as optional
and sensitive, and isRequiredUnknownInShapeLoad / isSensitiveUnknownInShapeLoad say so.
forEnv() with an unknown @currentEnv is unknown instead of an error, so a @disable or
@required built on it no longer fails the load. Checks that only hold for a sensitive item are
skipped while sensitivity is unknown. Default mode and generated types are unchanged.
finishLoad returned before the cycle check when any item had a schema
error. An item on a cycle was then left unmarked, waited on itself when
values were resolved, and the CLI gave up with "varlock stopped before
the command finished" instead of showing either error.
…ith a generated soundness test

A shape load now reports an item as unknown, instead of as valid, when one
of its dependencies is unknown (a normal load fails an item whenever a
dependency is invalid, read or not), when it is empty and its requiredness
is unknown, or when its sensitivity is unknown and a sensitive-only check
would fail.

shape-soundness.test.ts generates 1,200 schemas and checks, for every item,
that a shape load says unknown or gives exactly the value and errors of a
normal load, and the same required and sensitive unless flagged unknown.
It fails on every seed without these rules.
…d list; classify every built-in resolver

A named pipe or device is skipped and recorded (kind nonRegularFile)
instead of failing the load: 1Password mounts a .env that way. It could
set any item, so every value is then unknown.

shapeSkipped no longer lists root decorators that have no execute().

A test lists every built-in resolver as pure or skipped, so a new one
fails until someone decides.

Also: resolution-context is imported once, and errors from unknown inputs
are tracked in a WeakSet instead of a property written onto the error.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant