🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

agent-guards

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

agent-guards - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+55
cli/exec.js
// Running the real command after the check passes.
//
// Windows is the whole reason this file exists. `npm` there is `npm.cmd`, a batch file, and Windows
// cannot execute a batch file without a shell — so the command has to go through cmd.exe. Handing
// cmd.exe a command line means its metacharacters apply, and `^` is one of them, which matters
// because `npm install lodash@^4.17.21` is an ordinary thing to type and cmd would silently eat the
// caret. Inside double quotes cmd leaves `^` alone, so every argument is quoted and the line is
// passed verbatim.
//
// POSIX needs none of this: spawn the binary with an argument array and no shell is involved.
const { spawnSync } = require('child_process');
function quoteForCmd(arg) {
const s = String(arg);
// A double quote inside an argument has to be escaped for cmd, and any trailing backslashes have
// to be doubled or they escape the closing quote.
const escaped = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1');
return `"${escaped}"`;
}
// Returns the exit code of the command that ran, or null if it could not be started.
function passthrough(command, args, opts = {}) {
const stdio = opts.stdio || 'inherit';
if (process.platform === 'win32') {
const comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe';
// The outer pair of quotes is load-bearing. `/s` tells cmd to strip the first and last character
// of the argument when both are quotes and run what is left verbatim; without the extra pair it
// strips the quotes around the executable instead, and any path with a space in it — which on
// Windows means most of them, starting with C:\Program Files — becomes two arguments.
const line = '"' + [command, ...args].map(quoteForCmd).join(' ') + '"';
const r = spawnSync(comspec, ['/d', '/s', '/c', line], {
stdio,
windowsVerbatimArguments: true,
cwd: opts.cwd,
env: opts.env,
});
if (r.error) return { code: null, error: r.error.message };
return { code: r.status === null ? 1 : r.status, stdout: r.stdout && String(r.stdout), stderr: r.stderr && String(r.stderr) };
}
const r = spawnSync(command, args, { stdio, cwd: opts.cwd, env: opts.env });
if (r.error) return { code: null, error: r.error.message };
return { code: r.status === null ? 1 : r.status, stdout: r.stdout && String(r.stdout), stderr: r.stderr && String(r.stderr) };
}
// git, for `guard diff`. Captured rather than inherited, and a missing git is reported as a missing
// git instead of an empty diff — an empty diff would read as "nothing to review".
function git(args, cwd) {
const r = passthrough('git', args, { stdio: 'pipe', cwd });
if (r.error) return { ok: false, error: `git could not be run: ${r.error}` };
if (r.code !== 0) return { ok: false, error: (r.stderr || '').trim() || `git exited ${r.code}` };
return { ok: true, out: r.stdout || '' };
}
module.exports = { passthrough, git, quoteForCmd };
// The `guard` command line.
//
// Same engines as the hooks and the MCP server; a different way in. The shape that matters most is
// `guard npm install <pkg>`: it checks first, then hands the real command straight through, so it can
// be aliased over `npm` and forgotten about. The rest exist because a check nobody can run on demand
// is a check nobody trusts — `guard scan`, `guard diff` for what is staged, `guard package` for the
// full online verdict, and `guard stats` for what the week actually caught.
//
// Exit codes, because this belongs in pre-commit hooks and CI:
// 0 nothing at or above the failure threshold
// 1 something at or above it
// 2 the command could not run at all (bad usage, unreadable input)
// The threshold is `danger` by default and moves with --fail-on.
const fs = require('fs');
const path = require('path');
const { badge, skipped, findingLine, plural, DIM, BOLD, RED, YELLOW, GREEN } = require('./render');
const { passthrough, git } = require('./exec');
const ledger = require('../lib/ledger');
const cache = require('../lib/cache');
// Verdicts and --fail-on levels share one ladder. `any` is a threshold rather than a verdict: it
// sits at the lowest rung anything can reach, so --fail-on any trips on a low finding.
const LEVEL = { safe: 0, ok: 0, pass: 0, allow: 0, clear: 0, any: 1, note: 1, low: 1, caution: 2, review: 2, warn: 2, unknown: 2, danger: 3, block: 3 };
const SEVERITY_TO_VERDICT = { critical: 'danger', high: 'danger', medium: 'caution', low: 'note' };
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', 'vendor', '__pycache__', '.venv', 'venv', '.cache']);
const SCANNABLE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|java|php|sh|bash|zsh|ps1|yml|yaml|json|env|toml|ini|cfg|conf|tf|sql|md|txt)$/i;
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_FILES = 2000;
function parseArgs(argv) {
const flags = {};
const rest = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--') { rest.push(...argv.slice(i + 1)); break; }
if (a.startsWith('--')) {
const [k, v] = a.slice(2).split('=');
if (v !== undefined) flags[k] = v;
else if (argv[i + 1] && !argv[i + 1].startsWith('-') && ['fail-on', 'ecosystem', 'days', 'lang'].includes(k)) { flags[k] = argv[++i]; }
else flags[k] = true;
} else if (/^-[a-zA-Z]$/.test(a)) {
flags[a.slice(1)] = true;
} else rest.push(a);
}
return { flags, rest };
}
function out(s = '') { process.stdout.write(s + '\n'); }
function err(s = '') { process.stderr.write(s + '\n'); }
function threshold(flags) {
const t = String(flags['fail-on'] || 'danger').toLowerCase();
return LEVEL[t] === undefined ? LEVEL.danger : LEVEL[t];
}
function exitFor(verdict, flags) {
return (LEVEL[verdict] || 0) >= threshold(flags) ? 1 : 0;
}
function record(entry, flags) {
if (flags['no-ledger']) return;
ledger.record({ source: 'cli', ...entry });
}
// ---------------------------------------------------------------- install passthrough
const MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'bunx', 'pip', 'pip3', 'pipx', 'uv', 'poetry', 'python', 'python3']);
async function cmdInstall(argv, flags) {
const { parse } = require('../engines/shellcmd');
const { inspect } = require('../engines/pkgname');
const commandLine = argv.join(' ');
const parsed = parse(commandLine);
if (!parsed.installs.length) {
// Not an install we can read. Running it unchecked without saying so would be the fail-open bug
// this whole project is about, so say it and run it.
err(`guard: "${commandLine}" is not an install command this parser recognises, so nothing was checked. Running it unchanged.`);
const r = passthrough(argv[0], argv.slice(1));
return r.code === null ? 2 : r.code;
}
const results = [];
const online = !flags.offline;
for (const install of parsed.installs) {
for (const pkg of install.packages) {
const local = inspect(pkg.name, install.ecosystem);
let full = null;
if (online) {
full = await fullPackageCheck(pkg.name, install.ecosystem, pkg.version);
}
results.push({ pkg, install, local, full });
}
}
let worst = 'safe';
for (const r of results) {
const v = r.full && r.full.verdict ? r.full.verdict : r.local.verdict;
if ((LEVEL[v] || 0) > (LEVEL[worst] || 0)) worst = v;
}
for (const r of results) {
printPackage(r, flags);
}
for (const risk of parsed.risky) {
out(`${badge('caution')} ${risk.id}: ${risk.message}`);
if ((LEVEL.caution) > (LEVEL[worst] || 0)) worst = 'caution';
}
const willBlock = worst === 'danger' && !flags.force;
record({
event: 'install_check', engine: online ? 'packages' : 'pkgname', verdict: worst,
action: willBlock ? 'blocked' : (worst === 'safe' ? 'none' : 'reported'),
subject: results.map((r) => r.pkg.name).join(' ').slice(0, 100),
rules: results.flatMap((r) => (r.local.findings || []).map((f) => f.id)),
findings: results.reduce((a, r) => a + (r.local.findings || []).length, 0),
}, flags);
if (willBlock) {
err('');
err(RED('guard: not running this install.') + ' Re-run with --force to do it anyway, after reading the reasons above.');
return 1;
}
if (worst === 'danger' && flags.force) {
err(YELLOW('guard: --force given, running the install anyway.'));
}
const r = passthrough(argv[0], argv.slice(1));
if (r.code === null) { err(`guard: could not run ${argv[0]}: ${r.error}`); return 2; }
return r.code;
}
// The online check, run through the same tool the MCP server exposes so the CLI and the tool cannot
// disagree. The result is written to the local cache, which is what lets the offline hook say
// something useful about this package next time.
async function fullPackageCheck(name, ecosystem, version) {
try {
const registry = require('../tools/index.js');
const tool = registry.byName('verify_package');
if (!tool) return null;
const res = await registry.runTool(tool, { name, ecosystem: ecosystem === 'pypi' ? 'pypi' : 'npm', version }, { offline: false, disabled: new Set() });
if (res && res.ok && res.verdict) {
cache.put(ecosystem, name, res.verdict, res.reasons, {
malicious: res.vulnerabilities && res.vulnerabilities.malicious,
vulnerabilities: res.vulnerabilities && res.vulnerabilities.count,
source: 'verify_package',
});
}
return res;
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
function printPackage({ pkg, install, local, full }, flags) {
if (flags.json) return;
const verdict = full && full.verdict ? full.verdict : local.verdict;
const label = `${pkg.name}${pkg.version ? '@' + pkg.version : ''} (${install.ecosystem})`;
out(`${badge(verdict)} ${BOLD(label)}`);
for (const f of local.findings || []) out(findingLine({ id: f.id, severity: f.severity, message: f.message }));
if (full) {
if (full.ok === false) {
out(DIM(` the online check did not run: ${full.error || full.reason || 'unknown error'}`));
} else {
for (const reason of full.reasons || []) out(` ${reason}`);
if (full.checks_skipped) out(skipped(full.checks_skipped).trimEnd());
}
} else {
out(skipped([
{ id: 'registry-existence', reason: 'offline: not checked' },
{ id: 'osv-advisories', reason: 'offline: not checked' },
]).trimEnd());
}
}
// ---------------------------------------------------------------- scan
function walk(dir, acc) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
for (const e of entries) {
if (acc.files.length >= MAX_FILES) { acc.truncated = true; return acc; }
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') && e.name !== '.github') continue;
walk(full, acc);
} else if (e.isFile()) {
if (!SCANNABLE.test(e.name) && !/^\.env/.test(e.name)) { acc.skippedByType++; continue; }
acc.files.push(full);
}
}
return acc;
}
function readStdin() {
try { return fs.readFileSync(0, 'utf8'); } catch { return null; }
}
function scanText(text, label, langHint) {
const { scanCode } = require('../engines/code');
const { scan: scanInjection } = require('../engines/injection');
const code = scanCode(text, langHint);
const injection = scanInjection(text);
const findings = [];
for (const f of code.findings) findings.push({ id: f.id, line: f.line, severity: f.severity, message: f.message, remediation: f.remediation });
for (const f of injection.findings) findings.push({ id: f.id, severity: injection.score >= 35 ? 'high' : 'medium', message: `${f.category}: ${String(f.match).replace(/\s+/g, ' ').slice(0, 120)}` });
let verdict = 'safe';
for (const f of findings) {
const v = SEVERITY_TO_VERDICT[f.severity] || 'note';
if ((LEVEL[v] || 0) > (LEVEL[verdict] || 0)) verdict = v;
}
return { label, verdict, findings, code_rules_version: code.rules_version, injection_rules_version: injection.rules_version };
}
async function cmdScan(argv, flags) {
const target = argv[0];
if (!target) { err('guard scan <path|-> (- reads stdin)'); return 2; }
const results = [];
let truncated = false;
let skippedByType = 0;
if (target === '-') {
const text = readStdin();
if (text === null) { err('guard: could not read stdin'); return 2; }
results.push(scanText(text, 'stdin', flags.lang));
} else {
let stat;
try { stat = fs.statSync(target); } catch { err(`guard: cannot read ${target}`); return 2; }
let files = [];
if (stat.isDirectory()) {
const acc = walk(target, { files: [], truncated: false, skippedByType: 0 });
files = acc.files; truncated = acc.truncated; skippedByType = acc.skippedByType;
} else files = [target];
for (const f of files) {
let text;
try {
if (fs.statSync(f).size > MAX_FILE_BYTES) { skippedByType++; continue; }
text = fs.readFileSync(f, 'utf8');
} catch { continue; }
const r = scanText(text, path.relative(process.cwd(), f) || f, path.extname(f).slice(1));
if (r.findings.length) results.push(r);
else results.push({ ...r, quiet: true });
}
}
const withFindings = results.filter((r) => r.findings.length);
let worst = 'safe';
for (const r of results) if ((LEVEL[r.verdict] || 0) > (LEVEL[worst] || 0)) worst = r.verdict;
if (flags.json) {
out(JSON.stringify({ target, verdict: worst, files_scanned: results.length, files_with_findings: withFindings.length, truncated, results: withFindings }, null, 2));
} else {
for (const r of withFindings) {
out(`${badge(r.verdict)} ${BOLD(r.label)}`);
for (const f of r.findings) out(findingLine(f));
}
out('');
out(`${results.length} file(s) scanned, ${withFindings.length} with findings. ${badge(worst)}`);
if (truncated) out(DIM(` stopped at ${MAX_FILES} files; the rest of the tree was not scanned`));
if (skippedByType) out(DIM(` ${skippedByType} file(s) skipped: not a recognised source type, or over ${MAX_FILE_BYTES / 1024 / 1024}MB`));
out(skipped([
'package dependencies: use `guard package <name>`',
'anything the code and injection rulesets do not have a pattern for. These are regex rulesets, not static analysis.',
]).trimEnd());
}
record({
event: 'cli_scan', engine: 'code+injection', verdict: worst, action: 'reported', subject: target === '-' ? 'stdin' : path.basename(String(target)),
findings: withFindings.reduce((a, r) => a + r.findings.length, 0),
rules: withFindings.flatMap((r) => r.findings.map((f) => f.id)),
}, flags);
return exitFor(worst, flags);
}
// ---------------------------------------------------------------- diff
async function cmdDiff(argv, flags) {
const args = flags.unstaged ? ['diff', '--unified=3'] : ['diff', '--cached', '--unified=3'];
const r = git(args);
if (!r.ok) { err(`guard: ${r.error}`); return 2; }
const diff = r.out;
if (!diff.trim()) {
out(`Nothing ${flags.unstaged ? 'unstaged' : 'staged'} to scan.`);
return 0;
}
const { scanDiff } = require('../engines/code');
const { scan: scanInjection } = require('../engines/injection');
// One scan per file in the diff, so line numbers and the language guess are per file.
const perFile = diff.split(/^diff --git /m).filter(Boolean).map((chunk) => 'diff --git ' + chunk);
const results = [];
for (const chunk of perFile) {
const m = chunk.match(/^\+\+\+ b\/(.+)$/m);
const file = m ? m[1] : '(unknown file)';
const res = scanDiff(chunk, path.extname(file).slice(1));
const added = chunk.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1)).join('\n');
const inj = scanInjection(added);
const findings = res.findings.map((f) => ({ id: f.id, line: f.line, severity: f.severity, message: f.message, remediation: f.remediation }));
for (const f of inj.findings) findings.push({ id: f.id, severity: inj.score >= 35 ? 'high' : 'medium', message: `${f.category}: ${String(f.match).replace(/\s+/g, ' ').slice(0, 120)}` });
let verdict = 'safe';
for (const f of findings) { const v = SEVERITY_TO_VERDICT[f.severity] || 'note'; if ((LEVEL[v] || 0) > (LEVEL[verdict] || 0)) verdict = v; }
results.push({ label: file, verdict, findings, addedLines: res.addedLines });
}
const withFindings = results.filter((r) => r.findings.length);
let worst = 'safe';
for (const r of results) if ((LEVEL[r.verdict] || 0) > (LEVEL[worst] || 0)) worst = r.verdict;
if (flags.json) {
out(JSON.stringify({ mode: flags.unstaged ? 'unstaged' : 'staged', verdict: worst, files: results.length, results: withFindings }, null, 2));
} else {
for (const r of withFindings) {
out(`${badge(r.verdict)} ${BOLD(r.label)}`);
for (const f of r.findings) out(findingLine(f));
}
const addedTotal = results.reduce((a, r) => a + (r.addedLines || 0), 0);
out('');
out(`${results.length} file(s), ${addedTotal} added line(s) scanned. ${badge(worst)}`);
out(skipped(['removed lines and unchanged context: only added lines are scanned']).trimEnd());
}
record({
event: 'cli_diff', engine: 'code+injection', verdict: worst, action: 'reported',
subject: `${results.length} file(s)`, findings: withFindings.reduce((a, r) => a + r.findings.length, 0),
rules: withFindings.flatMap((r) => r.findings.map((f) => f.id)),
}, flags);
return exitFor(worst, flags);
}
// ---------------------------------------------------------------- package
async function cmdPackage(argv, flags) {
const name = argv[0];
if (!name) { err('guard package <name> [--ecosystem npm|pypi]'); return 2; }
const ecosystem = String(flags.ecosystem || (flags.pypi ? 'pypi' : 'npm')).toLowerCase();
const { inspect } = require('../engines/pkgname');
const local = inspect(name, ecosystem);
const full = flags.offline ? null : await fullPackageCheck(name, ecosystem, flags.version);
const verdict = full && full.verdict ? full.verdict : local.verdict;
if (flags.json) {
out(JSON.stringify({ name, ecosystem, verdict, local, online: full }, null, 2));
} else {
printPackage({ pkg: { name, version: flags.version }, install: { ecosystem }, local, full }, flags);
}
record({ event: 'cli_package', engine: full ? 'packages' : 'pkgname', verdict, action: 'reported', subject: name, rules: (local.findings || []).map((f) => f.id) }, flags);
return exitFor(verdict, flags);
}
// ---------------------------------------------------------------- email
async function cmdEmail(argv, flags) {
const file = argv[0];
if (!file) { err('guard email <file.eml> (- reads stdin)'); return 2; }
let raw;
try { raw = file === '-' ? readStdin() : fs.readFileSync(file, 'utf8'); } catch { err(`guard: cannot read ${file}`); return 2; }
if (raw === null) { err('guard: could not read stdin'); return 2; }
const registry = require('../tools/index.js');
const tool = registry.byName('scan_inbound');
if (!tool) { err('guard: the email tool is not available in this build'); return 2; }
const res = await registry.runTool(tool, { raw }, { offline: !!flags.offline, disabled: new Set() });
const verdict = res.verdict || (res.ok === false ? 'unknown' : 'safe');
if (flags.json) out(JSON.stringify(res, null, 2));
else {
out(`${badge(verdict)} ${BOLD(file === '-' ? 'stdin' : path.basename(file))}`);
if (res.subject) out(` subject: ${String(res.subject).slice(0, 120)}`);
if (res.from) out(` from: ${String(res.from).slice(0, 120)}`);
for (const f of res.findings || []) out(findingLine({ id: f.id || f.type, severity: f.severity, message: f.match || f.message || f.note }));
for (const n of res.notes || []) out(` ${n}`);
if (res.checks_skipped) out(skipped(res.checks_skipped).trimEnd());
}
record({ event: 'cli_email', engine: 'email', verdict, action: 'reported', subject: file === '-' ? 'stdin' : path.basename(file), findings: (res.findings || []).length }, flags);
return exitFor(verdict, flags);
}
// ---------------------------------------------------------------- stats
function cmdStats(argv, flags) {
const days = Number(flags.days || 7);
const since = new Date(Date.now() - days * 86400000).toISOString();
const { entries, unreadable, exists, path: file } = ledger.read({ since });
const s = ledger.summarize(entries);
if (flags.json) { out(JSON.stringify({ since, ...s, ledger: file, unreadable_lines: unreadable }, null, 2)); return 0; }
if (!exists) {
out(`No ledger yet at ${file}.`);
out('It gets written the first time a hook or a guard command runs a check.');
return 0;
}
out(BOLD(`agent-guards, last ${days === 1 ? 'day' : days + ' days'}`));
out('');
out(` ${String(s.checks).padStart(5)} ${plural(s.checks, 'check run', 'checks run').replace(/^\d+ /, '')}`);
out(` ${String(s.blocked).padStart(5)} stopped before ${s.blocked === 1 ? 'it' : 'they'} ran`);
out(` ${String(s.reported).padStart(5)} reported after the fact`);
out(` ${String(s.incomplete).padStart(5)} ${s.incomplete === 1 ? 'check' : 'checks'} that could not finish`);
out('');
if (s.caught.length) {
out(BOLD(' what it found'));
for (const e of s.caught.slice(0, 15)) {
const when = String(e.ts).slice(0, 16).replace('T', ' ');
const did = e.action === 'blocked' ? 'stopped' : 'reported';
out(` ${badge(e.verdict)} ${DIM(when)} ${DIM(did.padEnd(8))} ${e.subject || ''} ${DIM(`(${(e.rules || []).join(', ') || e.engine})`)}`);
}
if (s.caught.length > 15) out(DIM(` … and ${s.caught.length - 15} more`));
out('');
} else {
out(DIM(' Nothing was flagged in this window.'));
out('');
}
if (s.rules.length) {
out(BOLD(' rules that fired'));
for (const [rule, n] of s.rules.slice(0, 10)) out(` ${String(n).padStart(5)} ${rule}`);
out('');
}
const events = Object.entries(s.events).sort((a, b) => b[1] - a[1]);
if (events.length) {
out(BOLD(' by kind'));
for (const [ev, n] of events) out(` ${String(n).padStart(5)} ${ev}`);
out('');
}
const rules = require('../lib/rulesets').status();
out(DIM(` rules: ${rules.provenance}${rules.generated ? `, built ${String(rules.generated).slice(0, 10)}` : ''}`));
for (const s of rules.stale_sources || []) out(YELLOW(` stale: ${s.id} — ${s.note}`));
out(DIM(` ledger: ${file}`));
out(DIM(` ${cache.stats().entries} package verdict(s) cached at ${cache.cachePath()}`));
if (unreadable) out(DIM(` ${unreadable} line(s) in the ledger could not be parsed and were skipped`));
out(DIM(' Local file. Nothing in it is uploaded, and it holds no file contents, secret values or message bodies.'));
return 0;
}
// ---------------------------------------------------------------- update
// Which surface the feed is told asked. The plugin bundles this same CLI and sets the variable in
// its bin shim, so a pull from `/guard` is counted as a plugin install rather than a bare CLI one.
function surfaceTag() {
const s = String(process.env.AGENT_GUARDS_SURFACE || 'cli').trim();
return ['cli', 'plugin', 'mcp', 'facade'].includes(s) ? s : 'cli';
}
async function cmdUpdate(argv, flags) {
const feed = require('../lib/feed');
const rulesets = require('../lib/rulesets');
if (flags.status) {
const st = rulesets.status();
const state = feed.readState();
if (flags.json) { out(JSON.stringify({ ...st, last_attempt: state.last_attempt || null, feed_url: feed.manifestUrl(), disabled: feed.disabledReason(flags) }, null, 2)); return 0; }
out(BOLD('rules'));
out(` in use ${st.provenance}`);
if (st.generated) out(` built ${st.generated}`);
if (st.counts) out(` ${st.counts.injection} injection, ${st.counts.secrets} secret, ${st.counts.pii} PII, ${st.counts.code} code rules`);
if (st.counts) out(` ${st.counts.ofac_addresses} sanctioned addresses, ${st.counts.scam_addresses} scam addresses, ${st.counts.malicious_packages} malicious packages`);
for (const s of st.stale_sources || []) out(YELLOW(` stale: ${s.id} — ${s.note}`));
const off = feed.disabledReason(flags);
out(DIM(` feed ${off ? `off (${off})` : feed.manifestUrl()}`));
if (state.last_attempt) out(DIM(` last checked ${state.last_attempt}`));
return 0;
}
const r = await feed.update({ surface: surfaceTag(), force: true, allowRollback: !!flags['allow-rollback'] });
if (flags.json) { out(JSON.stringify({ ...r, bundle: undefined }, null, 2)); return r.ok ? 0 : 1; }
if (r.action === 'applied') {
out(`${GREEN('updated')} rules are now ${r.version}${r.previous ? ` (was ${r.previous})` : ''}`);
if (r.packages && r.packages.action === 'updated') out(DIM(` package list refreshed, ${(r.packages.bytes / 1048576).toFixed(1)} MB`));
if (r.packages && r.packages.action === 'skipped') out(YELLOW(` ${r.packages.reason}`));
for (const s of r.stale_sources || []) out(YELLOW(` the bundle says ${s} could not be refreshed upstream`));
return 0;
}
if (r.action === 'up-to-date') { out(`already current: ${r.reason}`); return 0; }
if (r.action === 'skipped') { out(`no check made: ${r.reason}`); return 0; }
// Refused and failed both land here, and both leave the previous rules in place.
err(`${r.action}: ${r.reason}`);
for (const e of (r.errors || []).slice(0, 5)) err(DIM(` ${e}`));
err(DIM(` still using ${rulesets.provenance()}`));
return 1;
}
// ---------------------------------------------------------------- help
const HELP = `guard — deterministic security checks, in the path you already use.
guard npm install <pkg…> check the packages, then run the real command
guard pnpm add <pkg…> same for pnpm, yarn, bun, npx, pip, uv, poetry
guard pip install <pkg…>
guard scan <path|-> secrets, code rules and injection patterns on a file, a tree or stdin
guard diff the same, on what is staged in git (--unstaged for the working tree)
guard package <name> the full online check for one package: registry, OSV, downloads
guard email <file.eml> parse and scan an inbound message
guard stats what the guards have caught on this machine (--days N)
guard update pull the latest rules now (--status to see what is in use)
Options
--json machine-readable output
--force run an install the check called danger
--offline local engines only; says what it could not check
--fail-on <danger|caution|any> exit 1 at this level or worse (default: danger)
--no-ledger do not record this run
--ecosystem <npm|pypi> for guard package
--allow-rollback for guard update: accept rules older than the ones installed
Exit codes: 0 nothing at or above the threshold, 1 something at or above it, 2 could not run.
Every check here is a pattern, a list, or a lookup. There is no model in any detection path, so the
same input always gives the same verdict, and every verdict names the rule behind it. What that also
means: novel attacks that no rule describes are not detected. Each command prints what it did not
check underneath what it did.
Rule updates. About once a day, guard asks the rules feed whether there is a newer ruleset, and
applies it if there is. The request carries two things: which surface asked (here, "cli") and the
rules version already installed. It does not carry a machine id, a user id, a file name, or anything
you scanned. Bundles are signed, and one that fails its signature, its schema or its ReDoS check is
discarded with the previous rules left in place. Turn it off with AGENT_GUARDS_NO_FEED=1, with
--offline, or with {"feed": false} in ~/.agent-guards/config.json. Point it somewhere else with
AGENT_GUARDS_FEED_URL. Run "guard update --status" to see what is in use and when it last checked.
Format and verification steps: https://github.com/mlawsonking/MCP/blob/main/rules/README.md`;
async function main(argv) {
const { flags, rest } = parseArgs(argv);
if (flags.help || flags.h || (!rest.length && !flags.version)) { out(HELP); return rest.length ? 0 : (flags.help || flags.h ? 0 : 2); }
if (flags.version || flags.v) {
let v = 'unknown';
try { v = require('../package.json').version || 'unknown'; } catch { /* vendored copies may not ship one */ }
out(v);
return 0;
}
const cmd = rest[0];
const args = rest.slice(1);
try {
if (MANAGERS.has(cmd)) return await cmdInstall(rest, flags);
if (cmd === 'scan') return await cmdScan(args, flags);
if (cmd === 'diff') return await cmdDiff(args, flags);
if (cmd === 'package' || cmd === 'pkg') return await cmdPackage(args, flags);
if (cmd === 'email') return await cmdEmail(args, flags);
if (cmd === 'stats') return cmdStats(args, flags);
if (cmd === 'update') return await cmdUpdate(args, flags);
err(`guard: unknown command "${cmd}"`);
err('');
err(HELP);
return 2;
} catch (e) {
err(`guard: ${(e && e.stack) || e}`);
return 2;
}
}
module.exports = { main, parseArgs, scanText, HELP, LEVEL };
// Turning results into text a person reads in a terminal.
//
// Two rules. No colour codes unless stdout is a TTY, because half of these runs are in CI logs and a
// pre-commit hook, and escape sequences in a log are worse than plain text. And every clean result
// says what it did not check: "nothing found" and "nothing looked for" print differently.
const isTTY = !!process.stdout.isTTY && !process.env.NO_COLOR;
const c = (code, s) => (isTTY ? `[${code}m${s}` : s);
const RED = (s) => c('31', s);
const YELLOW = (s) => c('33', s);
const GREEN = (s) => c('32', s);
const DIM = (s) => c('2', s);
const BOLD = (s) => c('1', s);
// The badge names the SEVERITY, not what was done about it. `guard stats` prints findings that
// stopped nothing next to findings that stopped an install, and a badge reading BLOCK on a line that
// blocked nothing is the summary claiming credit it has not earned.
const MARK = { danger: 'DANGER', caution: 'WARN', safe: 'ok', unknown: 'UNKNOWN' };
function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }
function badge(verdict) {
if (verdict === 'danger' || verdict === 'block') return RED(MARK.danger);
if (verdict === 'caution' || verdict === 'review' || verdict === 'warn') return YELLOW(MARK.caution);
if (verdict === 'unknown') return YELLOW(MARK.unknown);
return GREEN(MARK.safe);
}
// The list of checks that did not run, printed under every result that has one. This is the part
// people skip writing and it is the part that keeps the output honest.
function skipped(entries) {
if (!entries || !entries.length) return '';
const lines = entries.map((e) => ` - ${typeof e === 'string' ? e : `${e.id}: ${e.reason}`}`);
return DIM(` not checked:\n${lines.join('\n')}`) + '\n';
}
function findingLine(f) {
const where = f.line !== undefined ? `:${f.line}` : '';
const sev = f.severity ? ` [${f.severity}]` : '';
return ` ${f.id}${where}${sev} ${f.message || f.type || ''}`.replace(/\s+$/, '');
}
function duration(ms) {
return DIM(`${Math.round(ms)}ms`);
}
module.exports = { badge, skipped, findingLine, duration, plural, RED, YELLOW, GREEN, DIM, BOLD, isTTY };
+60
-2

@@ -29,2 +29,60 @@ // Reading a shell command well enough to know what it is about to install.

// A heredoc is data for the command on the line above it, not another shell command. Claude Code
// passes the whole Bash input to this parser, including heredoc bodies. Treating those bodies as
// commands made prose in a commit message look like install arguments. Keep the command lines and
// remove each body (including its delimiter) before splitting on newlines.
function heredocsOnLine(line) {
const found = [];
let quote = null;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (escaped) { escaped = false; continue; }
if (ch === '\\' && quote !== "'") { escaped = true; continue; }
if (quote) { if (ch === quote) quote = null; continue; }
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) break;
if (ch !== '<' || line[i + 1] !== '<' || line[i + 2] === '<') continue;
i += 2;
let stripTabs = false;
if (line[i] === '-') { stripTabs = true; i++; }
while (/\s/.test(line[i] || '')) i++;
if (i >= line.length) continue;
let delimiter = '';
const delimiterQuote = line[i] === '"' || line[i] === "'" ? line[i++] : null;
if (!delimiterQuote && line[i] === '\\') i++;
while (i < line.length) {
const c = line[i];
if (delimiterQuote ? c === delimiterQuote : /[\s;|&()<>]/.test(c)) break;
delimiter += c;
i++;
}
if (delimiter) found.push({ delimiter, stripTabs });
}
return found;
}
function stripHeredocBodies(command) {
const lines = String(command || '').split('\n');
const kept = [];
const pending = [];
for (const raw of lines) {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (pending.length) {
const current = pending[0];
const candidate = current.stripTabs ? line.replace(/^\t+/, '') : line;
if (candidate === current.delimiter) pending.shift();
kept.push('');
continue;
}
kept.push(raw);
pending.push(...heredocsOnLine(line));
}
return kept.join('\n');
}
// Tokenize on whitespace while keeping quoted runs together. Quotes are removed; escapes inside

@@ -54,3 +112,3 @@ // double quotes are left alone because nothing downstream cares about the difference.

function split(command) {
const text = String(command || '');
const text = stripHeredocBodies(command);
const pipelines = [];

@@ -235,2 +293,2 @@ let cur = '';

module.exports = { parse, split, tokenize, parseNpmSpec, parsePipSpec, readStage, notPackage };
module.exports = { parse, split, tokenize, parseNpmSpec, parsePipSpec, readStage, notPackage, stripHeredocBodies };
+5
-2
{
"name": "agent-guards",
"version": "0.2.0",
"version": "0.2.1",
"description": "Deterministic local security engines for AI agents: prompt-injection and obfuscation scanning, secret detection and redaction, code scanning, package reputation, email analysis, payment screening, and an SSRF-safe fetch.",
"main": "index.js",
"bin": {
"agent-guards": "bin/agent-guards.mjs"
"agent-guards": "bin/agent-guards.mjs",
"guard": "bin/guard.mjs"
},
"mcpName": "io.github.mlawsonking/agent-guards",
"files": [

@@ -15,2 +17,3 @@ "index.js",

"mcp/",
"cli/",
"bin/",

@@ -17,0 +20,0 @@ "README.md"

@@ -26,2 +26,28 @@ # agent-guards core

The same package installs the `guard` CLI:
```bash
npx --yes --package agent-guards guard scan src/
npx --yes --package agent-guards guard diff
npx --yes --package agent-guards guard stats --json
```
## pre-commit
This repository publishes a staged-diff hook for the [pre-commit](https://pre-commit.com) framework.
It needs Node 18 or newer. Add this to `.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/mlawsonking/MCP
rev: main # pin this to a commit SHA in a shared repository
hooks:
- id: agent-guards-diff
```
Then run `pre-commit install`. The hook scans added lines only and uses the rules in that pinned
checkout. It does not call a model or send file contents anywhere. A second manual hook,
`agent-guards-scan`, scans the whole working tree when you run
`pre-commit run agent-guards-scan --hook-stage manual`.
## Local and cloud, and why the difference matters

@@ -28,0 +54,0 @@