Read the script
'use strict';
// Gate (Write|Edit, via pre-tool-use.js) — soft gate on new dependencies
// entering as code.
//
// Agents rarely run `npm install axios`; they write
// `const axios = require('axios')` and move on — the install is a later or
// human step, so a Bash-side gate never sees the moment the dependency is
// actually chosen. This gate watches that moment: a Write/Edit whose payload
// imports a package that is neither a builtin, a local file, nor declared in
// the project manifest is denied once with the reuse-first reason (rungs
// 3-5); re-issuing the same tool call passes. Deny-once per package, never a
// hard block, silent on the pass path.
//
// Bounded on purpose:
// - fires ONLY when an ecosystem manifest exists up-tree (greenfield code
// with no declared-deps baseline stays ungated),
// - counts only imports the payload ADDS (anything the file already
// imports on disk is grandfathered),
// - JS/TS and Python only; other ecosystems are covered by the Bash-side
// dep-guard when an install is attempted,
// - test files are exempt (a test-framework import in a test is
// convention, not a shipped dependency).
//
// Known limit: name extraction is regex, not AST (a parser to police
// dependency additions would be rung-5 irony), so exotic import forms may
// slip through — the ladder still covers those in prompt-space.
const fs = require('fs');
const path = require('path');
const { settingOff } = require('./razor-lib');
const { installedDeps, evidenceReason, ledgerName } = require('./dep-guard');
const { simulate } = require('./manifest-guard');
// Node core modules — importing one is never a new dependency.
const NODE_BUILTINS = new Set([
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring',
'readline', 'repl', 'stream', 'string_decoder', 'test', 'timers', 'tls',
'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
'worker_threads', 'zlib',
]);
// Python stdlib top-level names (3.9+ common surface; additions are cheap).
const PY_STDLIB = new Set([
'__future__', 'abc', 'argparse', 'array', 'ast', 'asyncio', 'atexit',
'base64', 'bdb', 'binascii', 'bisect', 'builtins', 'bz2', 'calendar',
'cmath', 'cmd', 'code', 'codecs', 'collections', 'colorsys',
'concurrent', 'configparser', 'contextlib', 'contextvars', 'copy',
'copyreg', 'cProfile', 'csv', 'ctypes', 'curses', 'dataclasses',
'datetime', 'dbm', 'decimal', 'difflib', 'dis', 'doctest', 'email',
'encodings', 'ensurepip', 'enum', 'errno', 'faulthandler', 'fcntl', 'filecmp',
'fileinput', 'fnmatch', 'fractions', 'ftplib', 'functools', 'gc',
'getopt', 'getpass', 'gettext', 'glob', 'graphlib', 'grp', 'gzip',
'hashlib', 'heapq', 'hmac', 'html', 'http', 'idlelib', 'imaplib', 'importlib',
'inspect', 'io', 'ipaddress', 'itertools', 'json', 'keyword', 'linecache',
'locale', 'logging', 'lzma', 'mailbox', 'marshal', 'math', 'mimetypes',
'mmap', 'msvcrt', 'multiprocessing', 'netrc', 'ntpath', 'numbers',
'operator', 'os',
'pathlib', 'pdb', 'pickle', 'pickletools', 'pkgutil', 'platform',
'plistlib', 'poplib', 'posixpath', 'pprint', 'profile', 'pstats', 'pty',
'pwd', 'py_compile', 'pyclbr', 'pydoc', 'queue', 'quopri', 'random',
're', 'readline', 'reprlib', 'resource', 'rlcompleter', 'runpy', 'sched',
'secrets', 'select', 'selectors', 'shelve', 'shlex', 'shutil', 'signal',
'site', 'smtplib', 'socket', 'socketserver', 'sqlite3', 'ssl', 'stat',
'statistics', 'string', 'stringprep', 'struct', 'subprocess', 'symtable',
'sys', 'sysconfig', 'syslog', 'tarfile', 'tempfile', 'termios', 'test',
'textwrap', 'threading', 'time', 'timeit', 'token', 'tokenize', 'tomllib',
'tkinter', 'trace', 'traceback', 'tracemalloc', 'tty', 'turtle',
'turtledemo', 'types', 'typing',
'unicodedata', 'unittest', 'urllib', 'uuid', 'venv', 'warnings', 'wave',
'weakref', 'webbrowser', 'winreg', 'winsound', 'wsgiref', 'xml',
'xmlrpc', 'zipapp', 'zipfile', 'zipimport', 'zlib', 'zoneinfo',
]);
const JS_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/i;
const PY_EXT = /\.py$/i;
function ecosystemOf(filePath) {
if (JS_EXT.test(filePath)) return 'node';
if (PY_EXT.test(filePath)) return 'python';
return null;
}
// Same convention as the benchmark scorers and common runners: test files
// never gate.
// One pattern over the whole JS/TS extension family, so a `.test.tsx` or a
// `.spec.mjs` is exempt for the same reason a `.test.js` is.
const TEST_SUFFIX = /[._](test|spec)\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/;
function isTestFile(filePath) {
const parts = String(filePath).split(/[\\/]/);
const name = (parts[parts.length - 1] || '').toLowerCase();
return (
name.startsWith('test_') || name.endsWith('_test.py') || TEST_SUFFIX.test(name)
|| parts.some((p) => /^(test|tests|__tests__)$/i.test(p))
);
}
// The package a specifier belongs to, or null when it can never be one
// (relative, absolute, subpath-imports, or a runtime builtin).
function specRoot(spec) {
if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return null;
// `@/x` and `~/x` are the path-alias forms nearly every modern TS/JS setup
// configures for its OWN source (tsconfig paths, Vite, Next, Remix, Nuxt).
// Neither can name a package: an npm scope needs a name after the `@`, and
// `~` is not a legal package name. Read as roots they became `@/components`
// and `~`, and every internal import in such a project was denied as an
// undeclared dependency. A missed nudge costs nothing; a false deny costs a
// turn, and this one fired on ordinary local code.
if (spec === '~' || spec.startsWith('~/') || spec.startsWith('@/')) return null;
// A node:/bun: prefix can only resolve a runtime builtin, never a package.
if (/^(node|bun):/.test(