aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
300 lines (269 loc) • 10.5 kB
JavaScript
/**
* dep-source.mjs — A20 / #1300
*
* Scans package.json (direct + dev + optional + peer deps) and package-lock.json
* (transitive `resolved` URLs) for dependency sources that match a forbidden
* pattern set:
*
* - git+* (any git scheme prefix in a version spec)
* - git:// (raw git scheme)
* - github:owner/repo (GitHub shorthand)
* - file: (local filesystem path)
* - link: (workspace symlink)
* - https?://*.tgz | https?://*.tar.gz from a non-registry host
*
* Allowlist: ci/dep-source-allowlist.yaml. Each entry permits a single dep with
* an otherwise-policy-violating source. Adding an entry is a security-relevant
* change and must be reviewed.
*
* Exits non-zero on violation. Designed for CI; safe to run locally.
*
* See:
* - .aiwg/architecture/adr-dep-source-policy.md
* - docs/contributing/dependency-sources.md
* - Threat model: Mini Shai-Hulud / control C22 / scenario S5 (dep-injection variant)
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
import yaml from 'js-yaml';
import { classifyDependencySource } from './lib/dep-source.js';
// ---------------------------------------------------------------------------
// CLI flag parsing
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
allowlist: { type: 'string', default: 'ci/dep-source-allowlist.yaml' },
manifest: { type: 'string', default: 'package.json' },
lockfile: { type: 'string', default: 'package-lock.json' },
quiet: { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
});
if (args.help) {
console.log(`Usage: node tools/lint/dep-source.mjs [options]
Options:
--allowlist <path> Path to allowlist YAML (default: ci/dep-source-allowlist.yaml)
--manifest <path> Path to package.json (default: package.json)
--lockfile <path> Path to package-lock.json (default: package-lock.json)
--quiet Suppress success output (failures still print)
--help Show this help
Exits 0 on success, non-zero on policy violation or fatal error.
See docs/contributing/dependency-sources.md for the contributor guide.
`);
process.exit(0);
}
// ---------------------------------------------------------------------------
// Pattern detection
// ---------------------------------------------------------------------------
/**
* Known npm registries whose tarball URLs are normal/expected. A `resolved`
* URL pointing here is just the standard npm tarball location, not an exotic
* dep source. Anything else is suspicious.
*/
// ---------------------------------------------------------------------------
// Allowlist
// ---------------------------------------------------------------------------
function loadAllowlist(path) {
if (!existsSync(path)) {
return { entries: [], path };
}
let raw;
try {
raw = readFileSync(path, 'utf8');
} catch (err) {
throw new Error(`Failed to read allowlist ${path}: ${err.message}`);
}
let doc;
try {
doc = yaml.load(raw);
} catch (err) {
throw new Error(`Failed to parse allowlist ${path} as YAML: ${err.message}`);
}
if (doc == null) return { entries: [], path };
if (!doc.allowlist) return { entries: [], path };
if (!Array.isArray(doc.allowlist)) {
throw new Error(`Allowlist ${path}: top-level 'allowlist' must be a list`);
}
// Validate entry shape.
const entries = doc.allowlist.map((entry, i) => {
if (entry == null || typeof entry !== 'object') {
throw new Error(`Allowlist ${path}: entry ${i} is not an object`);
}
const required = ['name', 'source-pattern', 'rationale', 'last-reviewed-date'];
for (const field of required) {
if (!entry[field]) {
throw new Error(
`Allowlist ${path}: entry ${i} ("${entry.name || '?'}") missing required field "${field}"`,
);
}
}
return entry;
});
return { entries, path };
}
function matchesAllowlist(name, source, allowlist) {
for (const entry of allowlist.entries) {
if (entry.name !== name) continue;
// source-pattern can be an exact string match OR a regex (when wrapped in /.../)
const pattern = entry['source-pattern'];
if (typeof pattern === 'string') {
if (pattern === source) return entry;
// Allow regex form: /.../flags
const re = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
if (re) {
try {
const compiled = new RegExp(re[1], re[2]);
if (compiled.test(source)) return entry;
} catch {
// bad regex — fall through to false
}
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// Scan: package.json direct/dev/optional/peer deps
// ---------------------------------------------------------------------------
function scanManifest(manifestPath, allowlist) {
const violations = [];
if (!existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`);
}
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'));
const buckets = [
['dependencies', pkg.dependencies],
['devDependencies', pkg.devDependencies],
['optionalDependencies', pkg.optionalDependencies],
['peerDependencies', pkg.peerDependencies],
];
let scanned = 0;
for (const [bucketName, deps] of buckets) {
if (!deps || typeof deps !== 'object') continue;
for (const [name, version] of Object.entries(deps)) {
scanned++;
const classification = classifyDependencySource(version);
if (!classification) continue;
const allowed = matchesAllowlist(name, version, allowlist);
if (allowed) continue;
violations.push({
location: `${manifestPath} > ${bucketName} > "${name}"`,
name,
source: version,
pattern: classification.pattern,
label: classification.label,
});
}
}
return { violations, scanned };
}
// ---------------------------------------------------------------------------
// Scan: package-lock.json transitive resolved URLs
// ---------------------------------------------------------------------------
function scanLockfile(lockfilePath, allowlist) {
const violations = [];
if (!existsSync(lockfilePath)) {
// Not fatal — some projects don't commit lockfiles. Report but continue.
return { violations, scanned: 0, present: false };
}
const lf = JSON.parse(readFileSync(lockfilePath, 'utf8'));
// lockfileVersion 2 / 3: entries live under `packages`. Each key is a path
// like "node_modules/foo" or "" (root). The package name comes from the key
// (last `node_modules/` segment) or from entry.name.
const packages = lf.packages || {};
let scanned = 0;
for (const [path, entry] of Object.entries(packages)) {
if (path === '') continue; // root package; covered by manifest scan
if (!entry || !entry.resolved) continue;
scanned++;
const classification = classifyDependencySource(entry.resolved);
if (!classification) continue;
const name = extractPackageName(path, entry);
const allowed = matchesAllowlist(name, entry.resolved, allowlist);
if (allowed) continue;
violations.push({
location: `${lockfilePath} > ${path}`,
name,
source: entry.resolved,
pattern: classification.pattern,
label: classification.label,
});
}
return { violations, scanned, present: true };
}
function extractPackageName(path, entry) {
if (entry.name) return entry.name;
// Path like "node_modules/foo" or "node_modules/@scope/bar" or
// "node_modules/foo/node_modules/bar" — take last segment after the last
// `node_modules/`.
const idx = path.lastIndexOf('node_modules/');
if (idx === -1) return path;
return path.slice(idx + 'node_modules/'.length);
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function formatViolation(v) {
return [
` ${v.location}`,
` source: ${v.source}`,
` pattern: ${v.pattern} (${v.label})`,
` fix: swap to a registry version, or allowlist it in`,
` ci/dep-source-allowlist.yaml with name, source-pattern,`,
` rationale, last-reviewed-date`,
].join('\n');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const cwd = process.cwd();
const manifestPath = resolve(cwd, args.manifest);
const lockfilePath = resolve(cwd, args.lockfile);
const allowlistPath = resolve(cwd, args.allowlist);
let allowlist;
try {
allowlist = loadAllowlist(allowlistPath);
} catch (err) {
console.error(`✗ ${err.message}`);
process.exit(2);
}
let manifestResult, lockfileResult;
try {
manifestResult = scanManifest(manifestPath, allowlist);
lockfileResult = scanLockfile(lockfilePath, allowlist);
} catch (err) {
console.error(`✗ Scan failed: ${err.message}`);
process.exit(2);
}
const allViolations = [...manifestResult.violations, ...lockfileResult.violations];
if (allViolations.length > 0) {
console.error(
`✗ Dependency source policy violation: ${allViolations.length} issue${
allViolations.length === 1 ? '' : 's'
}`,
);
console.error('');
for (const v of allViolations) {
console.error(formatViolation(v));
console.error('');
}
console.error('See docs/contributing/dependency-sources.md for the policy rationale.');
console.error('Allowlist file: ci/dep-source-allowlist.yaml');
process.exit(1);
}
if (!args.quiet) {
const lockNote = lockfileResult.present
? `, ${args.lockfile} (${lockfileResult.scanned} locked entries)`
: `, ${args.lockfile} (not present, skipped)`;
console.log(`✓ Dependency source policy: 0 violations`);
console.log(
` scanned: ${args.manifest} (${manifestResult.scanned} direct entries)${lockNote}`,
);
console.log(` allowlist: ${allowlist.entries.length} entries (${args.allowlist})`);
}
process.exit(0);
}
main();