Breaking
-
@vibe-agent-toolkit/utils/fsno longer re-exports the pure path-string helpers. Seven
symbols moved from./fsto the new./pathentry:safePath,toForwardSlash,
isAbsolutePath,isAbsoluteAnyPlatform,hasParentTraversalSegment,toAbsolutePath, and
getRelativePath../fswas a published subpath before this release and went from 14 exports to
7; anything importing one of those seven from@vibe-agent-toolkit/utils/fsmust change the
specifier to@vibe-agent-toolkit/utils/path. The two entries are disjoint by design —./fsnow
holds only the helpers that genuinely touchnode:fs/node:os/node:url, which is what lets
./pathreachnode:pathand nothing else. The.barrel is unaffected: it still exports all
seven, so consumers importing from@vibe-agent-toolkit/utilsneed no edit. Permitted under the
pre-1.0 policy; called out here because a silently narrowed published subpath is not.A new guard test enumerates the
.barrel's full export set, so a future removal from it cannot
ship unremarked the way this one nearly did. -
verifyCaseSensitiveFilename(filePath)now requires a second argument:verifyCaseSensitiveFilename(filePath, fsCache).
Library-only API break — no CLI behaviour changes. Answering the question needs a listing of the
target's parent directory, and it was doing an uncachedreaddirper call: measured at 9,963
readdircalls validating a 3,437-document tree, and 7,443 on a 1,132-document monorepo, over a
few hundred distinct directories. The listing now comes from a caller-suppliedFsLookupCache
(new, exported from@vibe-agent-toolkit/utils/fsand the.barrel), which memoizesreaddir
andrealpathand shares in-flight promises so concurrent callers collapse to one syscall.
What to do: construct onenew FsLookupCache()per validation run and pass it to every call
in that run.verifyCaseSensitiveFilename(p, new FsLookupCache())at each call site reproduces
the old behaviour exactly if you want a mechanical migration first. The cache is deliberately
instance-based, never a module singleton — it holds a snapshot of directory contents, so a
watch-mode or server process must let each run have its own and drop it afterwards. The parameter
is required rather than defaulted for the same reason: a default lets an unmigrated call site keep
the un-memoized path silently, which is a no-op wearing the shape of a fix.ValidateLinkOptionsin@vibe-agent-toolkit/resourcesgains a matching requiredfsCache
field, so anything constructing that options object must supply the run's cache. -
The vestigial
zodpeerDependency is gone from@vibe-agent-toolkit/utils. It was a
required peer, so anyone importing only./pathwas still told by their package manager to
installzod. The package importszodnowhere: all six occurrences offrom 'zod'in the
shippeddistare inside JSDoc@exampleblocks, and the version-introspection helpers
deliberately duck-type_def.typeNamerather than importing the library — which is exactly what
makes them work across v3 and v4. The declared range (^3.25.0 || ^4.0.0) would additionally have
rejected a future major that the duck typing handles by design.zodremains a devDependency, so
the test that exercises the introspection against a realzodis unaffected.Listed as breaking, not merely removed, because of who it breaks: not anyone importing from
utils, but a consumer that was relying on this package to pullzodinto their tree and now
finds it absent. If you importzodyourself, declare it yourself. (Reported twice by an adopter
who went looking for this under Breaking and did not find it — it was filed under Added, beside
the subpath work that prompted it.)
Added
-
A
@vibe-agent-toolkit/utils/eslintsubpath — 21 ESLint rules that enforce the safety helpers
in the rest of the package. The helpers exist becausepath.join(),os.tmpdir(),
fs.realpathSync(),child_process.execSync()andawait import(absolutePath)each have a
platform pothole; until now nothing stopped a call to the raw primitive, so the API shipped
without its enforcement. The rules were maintained privately in this repo and had never been
installable. Most auto-fix, and every message names the replacement and theutilssubpath it
lives on.// eslint.config.js import vat from '@vibe-agent-toolkit/utils/eslint'; export default [vat.configs.recommended];
configs.recommendedregisters the rules under the@vibe-agent-toolkitnamespace and enables
the cross-platform safety core — 18 of the 21 rules,errorexcept three atwarn
(no-path-join,no-path-resolve,no-path-relative), the ones whose first run on an existing
codebase produces a migration rather than a bug list — measured at 4,336 findings on a
4,670-file tree, all autofixable. Three rules ship without riding inrecommended:
require-justified-skipandno-test-scoped-functionsencode a position on test style rather
than a portability fact, andno-unsafe-root-joinis held back on correctness — it keys on
whether an identifier's name ends inrootrather than on taint, so it fires on all-literal
calls and stays silent onsafePath.join(base, userInput), the shape it exists to catch. All
three are enabled by naming them.--fixwrites the import to the narrow subpath that owns the helper, matching the rule
table:path.join()becomessafePath.join()imported from@vibe-agent-toolkit/utils/path,
thefsrules point at./fs, andno-child-process-execSyncat./process. A file that
already reaches the helper through the.barrel keeps its existing import and only has the
call rewritten — a second binding of the same name would beSyntaxError: Identifier 'safePath' has already been declared, so the fixer checks whether the name is bound at all rather than
whether it was imported from the module the fixer prefers. That check is scope-based, so a
top-levelconst safePath = …is a conflict too.A per-rule
safeModuleoption redirects both the fix and the message at your own re-export
seam —['error', { safeModule: '@acme/dev-tools/paths' }]. Necessary because in a workspace
with isolatednode_modulesan import of an undeclared package does not degrade, it fails to
resolve: an adopter measured that the defaults would write a specifier resolving in 0 of their
top 25 affected packages while their own seam resolved in 24 — 620 files across 52 packages
that declare no dependency on this one. Per-rule rather than a single shared key because a seam
need not split its symbols the way this package does (theirs carriednormalizedTmpdir()but not
safePath, so thefsandpathfamilies needed different targets). Every rule that names a
module accepts it, including the six that only advise and never fix, so configured advice never
points at a module you don't use.Rules take an
exemptFiles
option naming the file(s) allowed to call the banned primitive — the one that implements your
wrapper. There are deliberately no built-in exemptions: those paths are a claim about one
repo's layout, and matching is anchored at a path segment, so declaringsrc/paths.tsnever
exemptstools/hooks/paths.ts. An entry with no/at all is reported as
unanchoredExemptFilerather than accepted: because ESLint reports absolute filenames, a bare
paths.tsexempts every file of that name anywhere in the tree, including ones added later.
Requires ESLint 9+ (flat config) and Node >= 22. Full rule table
in the subpath's README.--fixis safe to run across a whole migration: every rule that rewrites a call and edits
imports fixes all of a file's call sites without leaving a reference to something it just
un-imported, never deletes atype-only, aliased or re-exported specifier, and leaves a
suppressed call site working. Enforced by a suite that runs--fixto its fixpoint per rule and
checks the result withno-undef.It also finishes the job, which matters in a repo gating at
--max-warnings=0. Rewriting the
lastpath.*call in a file leavesimport path from 'node:path'bound to nothing — not a
dangling reference, so ano-undefcheck cannot see it, and an adopter measured 536 such errors
surviving a converged--fixacross 232 files. The rules now report that orphaned binding
themselves, as a separate finding on the import line with its own fix, so it is visible and
suppressible rather than a rewrite quietly deleting a declaration. Deliberately narrow: a closed
list of Node builtins (node:path,node:os,node:fs,node:fs/promises,
node:child_process, and their bare spellings), only in a file where the safe symbol is already
bound, only whole declarations with no references left. Bareimport 'node:path'side-effect
imports,typespecifiers and partially-used declarations are left alone. This is not a general
unused-import rule and will not become one — the ecosystem's rules abstain here for good reason,
and in any case cannot help:@typescript-eslint/no-unused-varsdeclaresmeta.fixable: 'code'
yet emits only a suggestion for an unused import, which--fixnever applies.The member-call rules (
no-os-tmpdirand friends) check the receiver rather than the method name,
and now recognise a namespace bound byconst os = require('node:os')or
const os = await import('node:os')as well as by a staticimport * as os. An unrelated object
with a same-named method is still not a finding.There is no separate plugin package to install, and
eslintis declared as an optional peer
dependency, so nothing changes for consumers who takeutilsforsafePath.join()alone: they
get no unmet-peer warning and no new dependency. An ESLint plugin is data rather than code that
runs — the rule modules export plain objects and neverrequire('eslint')— so this entry
reaches no Node builtin and no third-party package, and the other twelve subpaths keep resolving
in a tree with no ESLint anywhere in it. The cost is bytes on disk and nothing else: the packed
tarball goes 148,953 → 187,753 bytes (+38,800 compressed; 135,381 unpacked across 27.cjs
files, a README and a type declaration) for code nothing loads unless you lint. Both endpoints
are measured in the same tree, by packing with and without theeslintentry infiles, so the
delta is the subpath's cost and not the drift of adist/built months apart. What it buys is
one install, one version, and no way for a rule to name a helper signature the installedutils
no longer has. -
@vibe-agent-toolkit/utilsis now a first-class public package with narrow subpath exports.
Theexportsmap goes from 3 keys to 15:./path,./fs,./process,./git,./glob,
./zod,./yaml,./template,./testing,./asset,./crawl,./project,./eslint
(see below), and./package.json, plus the.barrel../projectcarriesfindProjectRoot,
findConfigFile,findNodeWorkspaceRootandresetProjectRootCaches— functions whose own code
imports nothing butnode:fsandnode:path, so reaching them no longer requires the.barrel
and its five third-party dependencies. They remain VAT-shaped (findProjectRootlooks for
vibe-agent-toolkit.config.yaml, then.git/), which the README says plainly; the entry exists
so that finding out costs nothing. Projects
building skills with VAT write Node code that has to run on Windows, macOS, and Linux, and hit the
same platform potholes VAT does —.cmdshims needing a shell,tmpdir()returning 8.3 short
paths, backslash-vs-forward-slash comparisons,await import()of an absolute path failing on
Windows. Those primitives are now importable without taking the whole toolkit. The.barrel's
export set is unchanged, so consumers importing from it need no edit — consumers of the
pre-existing./fssubpath do; see Breaking above.The narrow entries are narrow in their dependency graph, not merely in name:
./pathand
./globreach onlynode:path, nevernode:fs,node:os, ornode:url. A guard test walks
each entry's transitive source graph and asserts both itsnode:builtin set and its third-party
set, so the README's "resolves with zero deps installed" column is enforced rather than
documented. It fails loudly when it cannot resolve a module, so it cannot pass vacuously, and a
fixture with a dangling import exercises that failure.This is not a bundle-size change: the package has set
"sideEffects": falsesince 0.1.40, and
a tree-shaking bundler already dropped unused code from the barrel. What subpaths control is what
a build must resolve and what a module graph reaches — the barrel reachesyaml,handlebars,
andnode:fsregardless of what you destructure, so it cannot be bundled for a browser target and
requires every dependency installed. -
@vibe-agent-toolkit/utils/processnow exports the Windows spawn safety it was missing.
spawnHardened(async spawn with correct.cmd/.batlaunching),shouldUseShell,
windowsShellQuote, andbuildWindowsShellLinewere reachable only through the.barrel, so the
one subpath meant to make command execution safe on Windows covered synchronous exec only. -
engines: { node: ">=22.0.0" }on all 21 published packages. Exactly one of the 21 declared a
Node floor before this release, so an adopter installing on an older Node got no install-time
signal from any of the other 20 — they simply failed later, at a syntax or API error, with nothing
pointing at the Node version. -
A
./crawlsubpath, promotingcrawlDirectory/crawlDirectorySyncand the crawl-exclusion
glob constants. It is deliberately kept out of./glob: it is the only subpath that
reachespicomatch(linkAuth's host matching reaches it too, but only from the.barrel), and
folding it in would break./glob's guarantee of reaching nothing butnode:pathand no
third-party package at all.A
./projectsubpath (findProjectRoot,findConfigFile,findNodeWorkspaceRoot,
resetProjectRootCaches) was prototyped and deliberately dropped before release. Validated
against the package's primary real-world consumer, its four exports had zero replaceable call
sites:findNodeWorkspaceRootneeds apackage.jsoncarrying a"workspaces"key and returned
nullfrom every directory in that pnpm workspace;findConfigFilehardcodes VAT's config
filename; andfindProjectRoot's config-then-.gitladder contradicted all six of that repo's
own marker walk-ups — one of them a published runtime package, where keying on.git/would be a
bug, since it is absent at install time. The two sites that genuinely wanted a.gitwalk-up are
served bygitFindRooton./git. All four functions remain on the.barrel, where VAT's own
internals use them; only the narrow entry is gone. -
PLUGIN_TOPLEVEL_BIN_DIR— surface a top-levelbin/in a published plugin (warning).bin/andscripts/mean different things: Anthropic documentsbin/as "Executables added to the Bash tool'sPATH… invokable as bare commands", whilescripts/is the conventional home for helper scripts invoked by path. A plugin whose executables are only ever invoked by explicit path is usingbin/without using whatbin/provides — and a claude.ai-hosted marketplace sync has been observed to skip a plugin containing one, silently: the publish succeeds and the plugin simply never appears, surfacing only on the org admin console. VAT now names the shape at audit time so it is visible in the publishing repo. Advisory only —bin/is a supported, documented CLI feature, VAT has a single undocumented observation of the hosted rejection, and per validation-rule-design.md that is not grounds for a build-blocking error. It is deliberately not escalated by strict marketplace validation, and a test pins that. Opt out withseverity.PLUGIN_TOPLEVEL_BIN_DIR: ignoreor a scopedvalidation.allowentry. -
docs/contributing/plugin-distribution-findings.md— a running evidence log behind VAT's plugin-shape rules. validation-rule-design.md requires evidence to justify a rule's severity; this is where that evidence now lives, so awarningshipped on one observation stays distinguishable from awarningshipped on principle, and can be promoted (or dropped) when evidence changes. Entries carry an explicit DOCUMENTED / OBSERVED (n=) / INFERRED label. Also names the silent hosted-sync divergence failure class — publish succeeds, plugin never appears — and carries a "rules NOT to add" list recording proposals that were investigated and rejected, with reasons, so they are not re-proposed. Adopter-sourced findings are recorded as shapes, never identities. -
Authoring guidance — where a script shared by several skills should live.
vat-skill-authoringgains a section on the per-skill vs. plugin-levelfiles:fork. Per-skill duplication keeps each skill self-contained and standalone-mountable at the cost of duplicated bytes; a plugin-levelfiles:entry (whosedestmay not resolve underskills/) ships one copy but forces skill bodies onto${CLAUDE_PLUGIN_ROOT}, giving up standalone mounting — whichNON_PORTABLE_ASSET_REFERENCEcorrectly flags. The section names the deciding question (does this skill ever run outside its plugin?), and shows recording the answer as a scopedvalidation.allowentry with a requiredreasonrather than a repo-wideseverity: ignore.
Changed
-
@vibe-agent-toolkit/utils/gitexposes exactly one git-root finder. The subpath previously
re-exported whole modules, surfacing bothgitFindRootand the deprecatedfindGitRoot— two
differently-named functions for the same job, which guarantees consumers split between them. The
subpath is now an explicit, curated export list carryinggitFindRoot; see Removed for the
alias itself. -
Guidance for building the Node scripts a skill ships, in the
vat-skill-authoringskill:
bundling to a self-contained tree-shaken.mjs, statically scanning the artifact for surviving
external imports, and clean-room booting it outside anynode_modules. It documents a trap VAT
itself creates:files:injects a bundle under a differentdestbasename, so a script guarding
its entry point onbasename(process.argv[1])evaluates that guard as false under the shipped
name and exits 0 having printed nothing — inert, while reading as success to anything watching
exit codes.It also documents that trap's sibling, which an adopter found the hard way across three of their
own bins: npm writesnode_modules/.bin/<name>as a symlink, soprocess.argv[1]is the link
path whileimport.meta.urlis the realpath target — meaning the obvious remedy
(import.meta.url === pathToFileURL(process.argv[1]).href) fails the same fail-open way on the
most common invocation path of all. The guidance therefore recommends shipping a guard-free bin
entry module, and specifies clean-room verification on three legs — shippeddestname,
through a symlink, and from a packed tarball installed outside the workspace. A copy-only clean
room cannot see the symlink case at all: a copy has no symlink, so it certifies fail-open bins as
healthy.
Removed
-
findGitRootis gone from@vibe-agent-toolkit/utils. UsegitFindRoot— the behavior is
identical, becausefindGitRoot's entire body wasreturn gitFindRoot(startDir). It had carried
an@deprecatedtag for some time. Curating it off the new./gitsubpath (see Changed)
addressed only the symptom: the alias stayed on the.barrel, the entry with the most consumers,
so both names remained one import away and the coin flip just moved. Under the pre-1.0 policy
(never maintain two APIs for the same job) the alias is deleted rather than relocated. No
production code in this repository ever called it.This also removes one of the symbols that were reachable only from the wide
.barrel — the
shape that undercuts "import the one you need" — and it is the one whose narrow home already
existed.
Security
-
16 advisories cleared from the dependency tree via the root
overridesblock:undici
7.28.0 → 7.29.0 (5 advisories),ip-address10.1.1 → 10.3.1 (3),hono4.12.27 → 4.12.34,
fast-uri3.1.4 → 3.1.5,js-yaml4.3.0 → 4.3.1, andpostcss8.5.18 → 8.5.23, plus a new
nanoid3.3.16 → 3.3.17 pin closing GHSA-2v37-7h3g-55p8 (CVSS 8.2).nanoidreaches the tree
only throughpostcss, whose^3.3.16range the patched version satisfies, so no other pin
moved. All are within-major bumps of transitive packages; no declared dependency changed and no
consumer-facing API is affected.One advisory is accepted rather than fixed and recorded in
osv-scanner.tomlwith its
reasoning:brace-expansion(GHSA-rgw5-rvv9-x895) resolves to 1.x, 2.x, and 5.x simultaneously
in this tree, and the fix lands separately in each line (1.1.18 / 2.1.4 / 5.0.9), so no single
value in a globaloverridesblock can patch all three — pinning any one forces the other two
majors onto an incompatible version. It is a ReDoS against attacker-controlled brace patterns;
VAT only ever expands patterns it authors. Same shape, and the same deferral, as the existing
minimatchandpicomatchentries.
Fixed
-
isGitIgnored()spawned a git subprocess per ancestor directory when the path was not in a git
repository at all —vat resources validateon a 3,437-document tree outside any repository went
from 196 s to 20.6 s, with a byte-identical report.git check-ignoreexits 128 for two
unrelated conditions: "beyond a symbolic link" and "not a git repository". The code treated any
non-0/non-1 status as the first, whose recovery is to walk up the ancestor directories re-spawning
git for each one. Outside a repository every ancestor also exits 128, so the walk never broke,
climbed to the filesystem root, and returnedfalseafter (1 + depth) spawns — per call, and it is
called per link. It was the right answer by the wrong route, which is why no assertion ever caught
it; on the tree above,spawnSyncwas 87.6% of a 225.6-second run. "Is there a repository here?"
is now settled from the filesystem (viagitFindRoot) before anything is spawned, so outside a
repository the answer costs zero subprocesses. In-repository behaviour, including the symlink
ancestor walk, is unchanged and pinned by tests that assert the spawn count rather than only the
return value. -
VAT crawls walked into
.turbo. turborepo's per-package directory was on neither
NEVER_CRAWL_GLOBSnorBUILD_OUTPUT_GLOBS, so any crawl withrespectGitignore: false— the
path those lists exist for — descended into it and reported turbo's task logs, and, where
cacheDirpoints inside.turbo, files out of the hash-keyed cache. That cache holds copies of
package build output, so the crawl reported the same file twice under two paths: the duplicate
reading**/.worktrees/**is on the never-crawl list to prevent. It is now on
NEVER_CRAWL_GLOBS(notBUILD_OUTPUT_GLOBS— a lane that spreads only the never-crawl list is
by definition one that wants to see build output, and is precisely the lane that must not walk a
cache of copies). Turborepo is common enough for this to matter: every package in this repo has a
.turbo/. -
windowsShellQuoteproduced command lines thatCommandLineToArgvWmis-parses, corrupting
arguments and silently merging them with the argument that follows. The function knew none of
Windows' backslash-escaping rules: a backslash run preceding a quote — or preceding the closing
quote it adds — is escape-processed by the child's parser, soC:\Program Files\was emitted as
"C:\Program Files\"whose final\"reads as an escaped quote rather than a terminator. A path
with a trailing separator and a space is the everyday case, and the space is exactly what triggers
quoting, so the two conditions coincide constantly.Now implemented as the canonical algorithm: every backslash run preceding a quote or end-of-string
is doubled, and quotes escape as\". Measured by round-tripping through an implementation of
CommandLineToArgvWover every string up to length 4 across{a, \, ", space, %}— the old
implementation fails 85 of 781 cases, 74 of them swallowing the next argument; the new one
fails 0. That harness ships as a test, self-checked against Microsoft's published worked
examples so it cannot be silently wrong.One documented trade: no byte sequence is correct under both parsers in the chain, because
cmd.execounts every quote while the child needs an odd count to represent a literal one.\"is
chosen because it is understood identically by every known implementation, whereas""is absent
fromCommandLineToArgvW's documented rules and CRT variants disagree about it. The residual cost
is bounded and stated in the code: cmd's quote tracking desyncs only for an argument containing
both a quote and a shell metacharacter.Two safety claims in the same module were also overstated and are now documented honestly rather
than changed.%and!trigger quoting but are not neutralized by it —cmd.exestill
expands%VAR%inside double quotes, which also corrupts a literal%in a filename (legal on
Windows). AndshouldUseShell's JSDoc asserted "arguments passed as array, preventing injection"
and "never concatenate user input into command strings" while the Windows shell branch does
exactly that concatenation. Both now nameshell: falseas the escape hatch. -
buildWindowsShellLinesilently produced a broken command line when handed a command path it
could not safely place in the command position — it now throws instead. Two shapes reached it:
an unquoted path containing spaces, whichcmd.exesplits at the first space; and'', which
promoted the caller's first argument into the command position, so
buildWindowsShellLine('', ['calc', 'b'])returned" calc b"— a line whose command iscalc.
It now requires a single shell token and throws with a message naming the offending token.
Separately,safeExecSync/safeExecResultnow quote a path-like command the wayspawnHardened
already did; the sibling paths previously disagreed and only one was correct.What changes for you: a call that used to return a subtly wrong command line now raises. If
you pass a command path through these helpers, resolve it first (both of VAT's own call sites go
throughwhich.sync, which is why neither could reach the empty-token case). -
@vibe-agent-toolkit/utils/package.jsonwas not exported, so
require('@vibe-agent-toolkit/utils/package.json')threwERR_PACKAGE_PATH_NOT_EXPORTED. Version
reporting and resolution assertions all reach for it. Now exported. -
The
@vibe-agent-toolkit/utilsREADME documented four functions that do not exist
(normalizeFilePath,readFileContent,getGitRootDir,ensureGitRepository) and named a fifth
wrongly (setupTestTempDir). The reference is rewritten against verified exports and organized by
subpath. A../../docs/link that resolved to nothing on npm is now absolute. -
NON_PORTABLE_ASSET_REFERENCEno longer advises an impossible fix forCLAUDE_PROJECT_DIR. The family emitted one shared remediation — "reference bundled files by a path relative to the skill directory" — for every variant. That is right forCLAUDE_PLUGIN_ROOTand absolute script paths, and wrong forCLAUDE_PROJECT_DIR, which denotes the user's repository rather than a bundled asset: no skill-relative path can express it, and substituting one silently re-anchors user artifacts onto the plugin install directory. An adopter reported the rule advising them to revert a fix for exactly that bug. Theclaude-project-dirvariant now carries its own remediation (take the location as an explicit parameter with$CLAUDE_PROJECT_DIRas fallback; make declaredtargetsreflect the Claude Code coupling), and the shared headline no longer asserts the skill-relative advice. -
NON_PORTABLE_ASSET_REFERENCEover-captured a closing brace in nested shell expansion. The variant patterns used\$\{?NAME\}?, whose optional trailing\}?consumed the closing brace of an enclosing expansion —"${VAR:-$CLAUDE_PROJECT_DIR}"was reported as"$CLAUDE_PROJECT_DIR}". The malformed token reads exactly like the typo$FOO}, sending reviewers to source that was in fact valid shell. Matching is now brace-balanced via alternation. (Adopter-reported; independently reproduced.)