@node9/proxy 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/cli.js +283 -36
- package/dist/cli.mjs +283 -36
- package/package.json +2 -3
- package/docs/README.md +0 -49
- package/docs/agents/README.md +0 -29
- package/docs/agents/antigravity.md +0 -33
- package/docs/agents/claude-code.md +0 -40
- package/docs/agents/claude-desktop.md +0 -32
- package/docs/agents/codex.md +0 -37
- package/docs/agents/copilot-cli.md +0 -34
- package/docs/agents/cursor.md +0 -36
- package/docs/agents/gemini-cli.md +0 -34
- package/docs/agents/hermes.md +0 -35
- package/docs/agents/opencode.md +0 -36
- package/docs/agents/pi.md +0 -34
- package/docs/agents/vscode.md +0 -33
- package/docs/agents/windsurf.md +0 -31
- package/docs/badges.md +0 -98
- package/docs/comparison.md +0 -66
- package/docs/egress.md +0 -119
package/dist/cli.mjs
CHANGED
|
@@ -20604,13 +20604,36 @@ function isPolicyStale(nowMs = Date.now(), health) {
|
|
|
20604
20604
|
if (Number.isNaN(last)) return false;
|
|
20605
20605
|
return nowMs - last > stalenessThresholdMs(effectiveSyncIntervalMs());
|
|
20606
20606
|
}
|
|
20607
|
-
function
|
|
20608
|
-
|
|
20607
|
+
function safeNode9Version() {
|
|
20608
|
+
let dir = __dirname;
|
|
20609
|
+
for (let up = 0; up < 5; up++) {
|
|
20610
|
+
try {
|
|
20611
|
+
const pkg = JSON.parse(fs41.readFileSync(path40.join(dir, "package.json"), "utf-8"));
|
|
20612
|
+
if (pkg.name === "@node9/proxy" || pkg.name === "node9-ai") {
|
|
20613
|
+
return pkg.version;
|
|
20614
|
+
}
|
|
20615
|
+
} catch {
|
|
20616
|
+
}
|
|
20617
|
+
const parent = path40.dirname(dir);
|
|
20618
|
+
if (parent === dir) break;
|
|
20619
|
+
dir = parent;
|
|
20620
|
+
}
|
|
20621
|
+
return void 0;
|
|
20622
|
+
}
|
|
20623
|
+
function buildPolicyPullHeaders(apiKey, ifNoneMatch, proxyVersion) {
|
|
20609
20624
|
const headers = {
|
|
20610
20625
|
Authorization: `Bearer ${apiKey}`,
|
|
20611
20626
|
"Content-Type": "application/json"
|
|
20612
20627
|
};
|
|
20613
20628
|
if (ifNoneMatch) headers["If-None-Match"] = `"${ifNoneMatch}"`;
|
|
20629
|
+
if (proxyVersion && proxyVersion !== "unknown") {
|
|
20630
|
+
headers["X-Node9-Version"] = proxyVersion;
|
|
20631
|
+
}
|
|
20632
|
+
return headers;
|
|
20633
|
+
}
|
|
20634
|
+
function fetchCloudPolicy(apiKey, apiUrl, ifNoneMatch) {
|
|
20635
|
+
const parsed = new URL(apiUrl);
|
|
20636
|
+
const headers = buildPolicyPullHeaders(apiKey, ifNoneMatch, safeNode9Version());
|
|
20614
20637
|
return new Promise((resolve2, reject) => {
|
|
20615
20638
|
const req = https4.request(
|
|
20616
20639
|
{
|
|
@@ -57928,6 +57951,50 @@ function readLocalTree(dir) {
|
|
|
57928
57951
|
}
|
|
57929
57952
|
return { source: root, files, notes };
|
|
57930
57953
|
}
|
|
57954
|
+
function readGitRefTree(dir, ref) {
|
|
57955
|
+
const root = dir.replace(/^~/, process.env.HOME ?? "~");
|
|
57956
|
+
if (!ref || ref.startsWith("-")) return null;
|
|
57957
|
+
const git = (args) => {
|
|
57958
|
+
try {
|
|
57959
|
+
return execFileSync2("git", ["-C", root, ...args], {
|
|
57960
|
+
encoding: "utf8",
|
|
57961
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57962
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
57963
|
+
timeout: 2e4
|
|
57964
|
+
});
|
|
57965
|
+
} catch {
|
|
57966
|
+
return null;
|
|
57967
|
+
}
|
|
57968
|
+
};
|
|
57969
|
+
const sha = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim();
|
|
57970
|
+
if (!sha) return null;
|
|
57971
|
+
const listing = git(["ls-tree", "-r", "--name-only", "-z", sha]);
|
|
57972
|
+
if (listing === null) return null;
|
|
57973
|
+
const all = listing.split("\0").filter(Boolean);
|
|
57974
|
+
const notes = [];
|
|
57975
|
+
const files = [];
|
|
57976
|
+
const seen = /* @__PURE__ */ new Set();
|
|
57977
|
+
const present = new Set(all);
|
|
57978
|
+
const add = (rel) => {
|
|
57979
|
+
if (seen.has(rel) || !present.has(rel)) return;
|
|
57980
|
+
seen.add(rel);
|
|
57981
|
+
const content = git(["show", `${sha}:${rel}`]);
|
|
57982
|
+
if (content !== null) files.push({ path: rel, content });
|
|
57983
|
+
};
|
|
57984
|
+
for (const rel of SURFACE_FILES) add(rel);
|
|
57985
|
+
const nested = all.filter((rel) => SURFACE_BASENAME.test(rel) && !isIgnoredDir(rel));
|
|
57986
|
+
if (nested.length > MAX_SURFACE_FILES) {
|
|
57987
|
+
notes.push(
|
|
57988
|
+
`repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files).`
|
|
57989
|
+
);
|
|
57990
|
+
}
|
|
57991
|
+
for (const rel of nested.slice(0, MAX_SURFACE_FILES)) add(rel);
|
|
57992
|
+
for (const rel of all) {
|
|
57993
|
+
if (rel.startsWith(`${WORKFLOW_DIR}/`) && /\.ya?ml$/.test(rel) && !rel.slice(WORKFLOW_DIR.length + 1).includes("/"))
|
|
57994
|
+
add(rel);
|
|
57995
|
+
}
|
|
57996
|
+
return { source: `${root}@${ref}`, files, notes };
|
|
57997
|
+
}
|
|
57931
57998
|
async function fetchTree(input, onProgress) {
|
|
57932
57999
|
if (isLocalPath(input)) return readLocalTree(input);
|
|
57933
58000
|
const parsed = parseRepoUrl(input);
|
|
@@ -58349,6 +58416,8 @@ function analyzeWorkflow(path77, content) {
|
|
|
58349
58416
|
const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
|
|
58350
58417
|
return {
|
|
58351
58418
|
check: "CI-2",
|
|
58419
|
+
// One verdict per workflow file — the finding IS the file's reachability score.
|
|
58420
|
+
rule: "CI-2.injectable-workflow",
|
|
58352
58421
|
dimension: "workflows",
|
|
58353
58422
|
severity,
|
|
58354
58423
|
title,
|
|
@@ -58445,6 +58514,7 @@ function analyzeWorkflowSecrets(path77, content) {
|
|
|
58445
58514
|
if (!worst) return null;
|
|
58446
58515
|
return {
|
|
58447
58516
|
check: "CI-4",
|
|
58517
|
+
rule: "CI-4.agent-reachable-secret",
|
|
58448
58518
|
dimension: "data",
|
|
58449
58519
|
severity: worst.severity,
|
|
58450
58520
|
title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
|
|
@@ -58491,6 +58561,10 @@ function analyzeAgentConfig(path77, content) {
|
|
|
58491
58561
|
const high = remoteExec || unpinned;
|
|
58492
58562
|
findings.push({
|
|
58493
58563
|
check: "CI-1",
|
|
58564
|
+
rule: "CI-1.hook-remote-code",
|
|
58565
|
+
// Identity is the command itself: the same hook keeps its identity when its
|
|
58566
|
+
// severity changes (pinned → unpinned), and two different hooks stay distinct.
|
|
58567
|
+
locator: cmd,
|
|
58494
58568
|
dimension: "toolRules",
|
|
58495
58569
|
severity: high ? "high" : "medium",
|
|
58496
58570
|
title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
|
|
@@ -58511,6 +58585,9 @@ function analyzeAgentConfig(path77, content) {
|
|
|
58511
58585
|
const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
|
|
58512
58586
|
findings.push({
|
|
58513
58587
|
check: "CI-1",
|
|
58588
|
+
rule: "CI-1.broad-allow",
|
|
58589
|
+
// File-level: one finding per config file. Adding a SECOND broad allow makes the
|
|
58590
|
+
// same statement about the same file, so it must not read as a new finding.
|
|
58514
58591
|
dimension: "toolRules",
|
|
58515
58592
|
severity: hasBackstop ? "medium" : "high",
|
|
58516
58593
|
title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
|
|
@@ -58544,6 +58621,8 @@ function analyzeMcpServers(servers, path77) {
|
|
|
58544
58621
|
if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
|
|
58545
58622
|
findings.push({
|
|
58546
58623
|
check: "CI-3",
|
|
58624
|
+
rule: "CI-3.mcp-unpinned",
|
|
58625
|
+
locator: name,
|
|
58547
58626
|
dimension: "mcp",
|
|
58548
58627
|
severity: "medium",
|
|
58549
58628
|
title: `MCP server "${name}" runs an unpinned executable`,
|
|
@@ -58558,6 +58637,8 @@ function analyzeMcpServers(servers, path77) {
|
|
|
58558
58637
|
if (hit) {
|
|
58559
58638
|
findings.push({
|
|
58560
58639
|
check: "CI-3",
|
|
58640
|
+
rule: "CI-3.mcp-inline-credential",
|
|
58641
|
+
locator: `${name}.env.${k}`,
|
|
58561
58642
|
dimension: "mcp",
|
|
58562
58643
|
severity: "high",
|
|
58563
58644
|
title: `MCP server "${name}" has an inline credential`,
|
|
@@ -58595,6 +58676,10 @@ function analyzeCodexConfig(path77, content) {
|
|
|
58595
58676
|
].filter((s) => s !== null);
|
|
58596
58677
|
findings.push({
|
|
58597
58678
|
check: "CI-1",
|
|
58679
|
+
// One finding per Codex config; `danger-full-access` vs `approval_policy = never`
|
|
58680
|
+
// are two severities of the same statement, so tightening one is a de-escalation
|
|
58681
|
+
// of THIS finding rather than the removal of one and the arrival of another.
|
|
58682
|
+
rule: "CI-1.codex-unsafe-defaults",
|
|
58598
58683
|
dimension: "toolRules",
|
|
58599
58684
|
severity: fullAccess ? "high" : "medium",
|
|
58600
58685
|
title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
|
|
@@ -58654,8 +58739,17 @@ function decodeSuspiciousBase64(text) {
|
|
|
58654
58739
|
}
|
|
58655
58740
|
return out;
|
|
58656
58741
|
}
|
|
58657
|
-
function mk(severity, title, signals, fix, path77) {
|
|
58658
|
-
return {
|
|
58742
|
+
function mk(rule, severity, title, signals, fix, path77) {
|
|
58743
|
+
return {
|
|
58744
|
+
check: "CI-6",
|
|
58745
|
+
rule,
|
|
58746
|
+
dimension: "instructions",
|
|
58747
|
+
severity,
|
|
58748
|
+
title,
|
|
58749
|
+
file: path77,
|
|
58750
|
+
signals,
|
|
58751
|
+
fix
|
|
58752
|
+
};
|
|
58659
58753
|
}
|
|
58660
58754
|
function analyzeInstructionFile(path77, content) {
|
|
58661
58755
|
const findings = [];
|
|
@@ -58663,6 +58757,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58663
58757
|
if (TAG_CHARS.test(content))
|
|
58664
58758
|
findings.push(
|
|
58665
58759
|
mk(
|
|
58760
|
+
"CI-6.unicode-tag-chars",
|
|
58666
58761
|
"critical",
|
|
58667
58762
|
"Unicode tag characters in an agent instruction file",
|
|
58668
58763
|
[
|
|
@@ -58675,6 +58770,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58675
58770
|
if (BIDI_OVERRIDE.test(content))
|
|
58676
58771
|
findings.push(
|
|
58677
58772
|
mk(
|
|
58773
|
+
"CI-6.bidi-override",
|
|
58678
58774
|
"critical",
|
|
58679
58775
|
"Bidirectional override characters in an agent instruction file",
|
|
58680
58776
|
[
|
|
@@ -58687,6 +58783,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58687
58783
|
else if (BIDI_EMBED_ISOLATE.test(content))
|
|
58688
58784
|
findings.push(
|
|
58689
58785
|
mk(
|
|
58786
|
+
"CI-6.bidi-formatting",
|
|
58690
58787
|
"advisory",
|
|
58691
58788
|
"Bidirectional formatting characters in an agent instruction file",
|
|
58692
58789
|
[
|
|
@@ -58701,6 +58798,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58701
58798
|
const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
|
|
58702
58799
|
findings.push(
|
|
58703
58800
|
mk(
|
|
58801
|
+
"CI-6.zero-width",
|
|
58704
58802
|
revealed ? "critical" : "medium",
|
|
58705
58803
|
"Zero-width characters splitting text in an agent instruction file",
|
|
58706
58804
|
[
|
|
@@ -58717,6 +58815,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58717
58815
|
const m = ov || ovEnc;
|
|
58718
58816
|
findings.push(
|
|
58719
58817
|
mk(
|
|
58818
|
+
"CI-6.prompt-override",
|
|
58720
58819
|
ovEnc ? "critical" : "high",
|
|
58721
58820
|
"Prompt-override directive in an agent instruction file",
|
|
58722
58821
|
[
|
|
@@ -58731,6 +58830,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58731
58830
|
if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
|
|
58732
58831
|
findings.push(
|
|
58733
58832
|
mk(
|
|
58833
|
+
"CI-6.fetch-and-obey",
|
|
58734
58834
|
"medium",
|
|
58735
58835
|
"Instruction directs the agent to fetch and run remote code",
|
|
58736
58836
|
[`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
|
|
@@ -58743,6 +58843,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58743
58843
|
if (sp && !isNegated(content, sp.index)) {
|
|
58744
58844
|
findings.push(
|
|
58745
58845
|
mk(
|
|
58846
|
+
"CI-6.secret-path",
|
|
58746
58847
|
"medium",
|
|
58747
58848
|
"Instruction points the agent at credential material",
|
|
58748
58849
|
[`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
|
|
@@ -58755,6 +58856,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58755
58856
|
if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
|
|
58756
58857
|
findings.push(
|
|
58757
58858
|
mk(
|
|
58859
|
+
"CI-6.exfil-directive",
|
|
58758
58860
|
"medium",
|
|
58759
58861
|
"Instruction directs the agent to send data to an external endpoint",
|
|
58760
58862
|
[`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
|
|
@@ -58766,8 +58868,82 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58766
58868
|
return findings;
|
|
58767
58869
|
}
|
|
58768
58870
|
|
|
58871
|
+
// src/ci-check/diff.ts
|
|
58872
|
+
import { createHash as createHash4 } from "crypto";
|
|
58873
|
+
function fingerprintOf(f) {
|
|
58874
|
+
const key = JSON.stringify([f.rule, f.file, f.locator ?? "", f.ordinal ?? 0]);
|
|
58875
|
+
return createHash4("sha256").update(key).digest("hex").slice(0, 16);
|
|
58876
|
+
}
|
|
58877
|
+
function assignOrdinals(findings) {
|
|
58878
|
+
const seen = /* @__PURE__ */ new Map();
|
|
58879
|
+
for (const f of findings) {
|
|
58880
|
+
const key = JSON.stringify([f.rule, f.file, f.locator ?? ""]);
|
|
58881
|
+
const n = seen.get(key) ?? 0;
|
|
58882
|
+
if (n > 0) f.ordinal = n;
|
|
58883
|
+
seen.set(key, n + 1);
|
|
58884
|
+
}
|
|
58885
|
+
return findings;
|
|
58886
|
+
}
|
|
58887
|
+
function worstOf(severities) {
|
|
58888
|
+
let worst = null;
|
|
58889
|
+
for (const s of severities) {
|
|
58890
|
+
if (!worst || SEVERITY_RANK2[s] > SEVERITY_RANK2[worst]) worst = s;
|
|
58891
|
+
}
|
|
58892
|
+
return worst;
|
|
58893
|
+
}
|
|
58894
|
+
function baseStateOf(base) {
|
|
58895
|
+
if (!base) return "did-not-run";
|
|
58896
|
+
return base.incomplete ? "incomplete" : "ok";
|
|
58897
|
+
}
|
|
58898
|
+
function diffScans(base, head) {
|
|
58899
|
+
const state = baseStateOf(base);
|
|
58900
|
+
if (state !== "ok" || !base) {
|
|
58901
|
+
return {
|
|
58902
|
+
base: state,
|
|
58903
|
+
added: [],
|
|
58904
|
+
removed: [],
|
|
58905
|
+
unchanged: [...head.findings],
|
|
58906
|
+
escalated: [],
|
|
58907
|
+
worstIntroduced: head.worst,
|
|
58908
|
+
incomplete: true
|
|
58909
|
+
};
|
|
58910
|
+
}
|
|
58911
|
+
const baseByFp = /* @__PURE__ */ new Map();
|
|
58912
|
+
for (const f of base.findings) baseByFp.set(fingerprintOf(f), f);
|
|
58913
|
+
const added = [];
|
|
58914
|
+
const unchanged = [];
|
|
58915
|
+
const escalated = [];
|
|
58916
|
+
const matched = /* @__PURE__ */ new Set();
|
|
58917
|
+
for (const f of head.findings) {
|
|
58918
|
+
const fp = fingerprintOf(f);
|
|
58919
|
+
const prior = baseByFp.get(fp);
|
|
58920
|
+
if (!prior) {
|
|
58921
|
+
added.push(f);
|
|
58922
|
+
continue;
|
|
58923
|
+
}
|
|
58924
|
+
matched.add(fp);
|
|
58925
|
+
if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[prior.severity]) {
|
|
58926
|
+
escalated.push({ finding: f, from: prior.severity, to: f.severity });
|
|
58927
|
+
} else {
|
|
58928
|
+
unchanged.push(f);
|
|
58929
|
+
}
|
|
58930
|
+
}
|
|
58931
|
+
const removed = base.findings.filter((f) => !matched.has(fingerprintOf(f)));
|
|
58932
|
+
return {
|
|
58933
|
+
base: state,
|
|
58934
|
+
added,
|
|
58935
|
+
removed,
|
|
58936
|
+
unchanged,
|
|
58937
|
+
escalated,
|
|
58938
|
+
worstIntroduced: worstOf([...added.map((f) => f.severity), ...escalated.map((e) => e.to)]),
|
|
58939
|
+
// The head side of the same guard: a scan that could not read every file has not
|
|
58940
|
+
// earned the word "clean", however trustworthy the base was.
|
|
58941
|
+
incomplete: head.incomplete
|
|
58942
|
+
};
|
|
58943
|
+
}
|
|
58944
|
+
|
|
58769
58945
|
// src/ci-check/index.ts
|
|
58770
|
-
function
|
|
58946
|
+
function worstOf2(findings) {
|
|
58771
58947
|
let worst = null;
|
|
58772
58948
|
for (const f of findings) {
|
|
58773
58949
|
if (!worst || SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[worst]) worst = f.severity;
|
|
@@ -58801,11 +58977,12 @@ function scanTree(tree) {
|
|
|
58801
58977
|
notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
|
|
58802
58978
|
}
|
|
58803
58979
|
}
|
|
58980
|
+
assignOrdinals(findings);
|
|
58804
58981
|
findings.sort(
|
|
58805
58982
|
(a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity] || a.file.localeCompare(b.file)
|
|
58806
58983
|
);
|
|
58807
58984
|
const incomplete = notes.some((nt) => /may be INCOMPLETE/i.test(nt));
|
|
58808
|
-
return { source: tree.source, findings, inspected, notes, worst:
|
|
58985
|
+
return { source: tree.source, findings, inspected, notes, worst: worstOf2(findings), incomplete };
|
|
58809
58986
|
}
|
|
58810
58987
|
async function scanRepo(input, onProgress) {
|
|
58811
58988
|
const tree = await fetchTree(input, onProgress);
|
|
@@ -58859,12 +59036,37 @@ function renderCta(res) {
|
|
|
58859
59036
|
function ownedHint(source) {
|
|
58860
59037
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
58861
59038
|
}
|
|
58862
|
-
function
|
|
59039
|
+
function findingMd(f, L) {
|
|
59040
|
+
L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
|
|
59041
|
+
L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.rule}`);
|
|
59042
|
+
L.push("");
|
|
59043
|
+
for (const s of f.signals) L.push(`- ${s}`);
|
|
59044
|
+
if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
|
|
59045
|
+
L.push("");
|
|
59046
|
+
L.push(`\u2192 **Fix:** ${f.fix}`);
|
|
59047
|
+
L.push("");
|
|
59048
|
+
}
|
|
59049
|
+
function baseWarning(base) {
|
|
59050
|
+
return base === "did-not-run" ? '\u26A0\uFE0F **Could not read the base commit**, so nothing below can be called "new" \u2014 every finding in this repo is listed. (A shallow clone is the usual cause: fetch the base ref.)' : "\u26A0\uFE0F **The base scan could not read every file**, so a finding missing from it would look new. Every finding in this repo is listed instead.";
|
|
59051
|
+
}
|
|
59052
|
+
function renderScan(res, diff) {
|
|
58863
59053
|
const L = [];
|
|
58864
59054
|
const n = res.findings.length;
|
|
58865
59055
|
const head = res.worst === "critical" || res.worst === "high" ? chalk32.red.bold("\u26A0\uFE0F agent-security risk found") : res.worst ? chalk32.yellow("agent-security notes") : res.incomplete ? chalk32.yellow.bold("\u26A0\uFE0F INCOMPLETE \u2014 could not read all files") : chalk32.green("\u2705 agent-security: clean");
|
|
58866
59056
|
L.push(`\u{1F6E1}\uFE0F ${chalk32.bold("node9 scan-repo")} \xB7 ${res.source} \xB7 ${head}`);
|
|
58867
59057
|
L.push(chalk32.gray(` inspected ${res.inspected.length} config file(s), ${n} finding(s)`));
|
|
59058
|
+
if (diff) {
|
|
59059
|
+
const introduced = diff.added.length + diff.escalated.length;
|
|
59060
|
+
L.push(
|
|
59061
|
+
diff.base !== "ok" ? chalk32.yellow.bold(
|
|
59062
|
+
` \u26A0\uFE0F base ${diff.base === "did-not-run" ? "could not be read" : "scan was incomplete"} \u2014 cannot say what is new; showing everything`
|
|
59063
|
+
) : introduced > 0 ? chalk32.red.bold(
|
|
59064
|
+
` \u26A0\uFE0F this change introduced ${introduced} finding(s)` + (diff.escalated.length ? ` (${diff.escalated.length} by widening an existing one)` : "")
|
|
59065
|
+
) : chalk32.green(
|
|
59066
|
+
` \u2705 this change introduced nothing` + (diff.unchanged.length ? ` (${diff.unchanged.length} pre-existing)` : "") + (diff.removed.length ? `, and fixed ${diff.removed.length}` : "")
|
|
59067
|
+
)
|
|
59068
|
+
);
|
|
59069
|
+
}
|
|
58868
59070
|
if (res.incomplete) {
|
|
58869
59071
|
const rateLimited = res.notes.some((nt) => /rate limit/i.test(nt));
|
|
58870
59072
|
L.push(
|
|
@@ -58904,7 +59106,7 @@ function renderScan(res) {
|
|
|
58904
59106
|
L.push(...renderCta(res));
|
|
58905
59107
|
return L.join("\n");
|
|
58906
59108
|
}
|
|
58907
|
-
function renderScanMarkdown(res) {
|
|
59109
|
+
function renderScanMarkdown(res, diff) {
|
|
58908
59110
|
const L = [];
|
|
58909
59111
|
const status = res.worst === "critical" || res.worst === "high" ? "\u26A0\uFE0F" : res.worst ? "\u{1F7E1}" : res.incomplete ? "\u26A0\uFE0F" : "\u2705";
|
|
58910
59112
|
L.push(`### \u{1F6E1}\uFE0F node9 agent-security \xB7 \`${res.source}\` \xB7 ${status}`);
|
|
@@ -58913,25 +59115,57 @@ function renderScanMarkdown(res) {
|
|
|
58913
59115
|
`Inspected ${res.inspected.length} config file(s) \xB7 **${res.findings.length} finding(s)**`
|
|
58914
59116
|
);
|
|
58915
59117
|
L.push("");
|
|
58916
|
-
|
|
58917
|
-
|
|
58918
|
-
|
|
58919
|
-
|
|
58920
|
-
|
|
58921
|
-
|
|
58922
|
-
|
|
58923
|
-
|
|
59118
|
+
if (diff && diff.base === "ok") {
|
|
59119
|
+
const introduced = [...diff.added, ...diff.escalated.map((e) => e.finding)];
|
|
59120
|
+
if (introduced.length === 0) {
|
|
59121
|
+
L.push(
|
|
59122
|
+
`\u2705 **This change introduces no agent-security findings.**` + (diff.removed.length ? ` It also fixes ${diff.removed.length}.` : "")
|
|
59123
|
+
);
|
|
59124
|
+
L.push("");
|
|
59125
|
+
} else {
|
|
59126
|
+
L.push(`#### \u26A0\uFE0F Introduced by this change \u2014 ${introduced.length} finding(s)`);
|
|
59127
|
+
L.push("");
|
|
59128
|
+
for (const f of diff.added) findingMd(f, L);
|
|
59129
|
+
for (const e of diff.escalated) {
|
|
59130
|
+
L.push(
|
|
59131
|
+
`> _Guardrail erosion: this finding already existed at **${e.from}** and this change widens it to **${e.to}**._`
|
|
59132
|
+
);
|
|
59133
|
+
L.push("");
|
|
59134
|
+
findingMd(e.finding, L);
|
|
59135
|
+
}
|
|
59136
|
+
}
|
|
59137
|
+
if (diff.removed.length) {
|
|
59138
|
+
L.push(`\u2705 Fixed by this change: ${diff.removed.length} finding(s).`);
|
|
59139
|
+
L.push("");
|
|
59140
|
+
}
|
|
59141
|
+
if (diff.unchanged.length) {
|
|
59142
|
+
L.push(
|
|
59143
|
+
`<details><summary>${diff.unchanged.length} pre-existing finding(s) \u2014 not introduced by this change</summary>`
|
|
59144
|
+
);
|
|
59145
|
+
L.push("");
|
|
59146
|
+
for (const f of diff.unchanged) findingMd(f, L);
|
|
59147
|
+
L.push("</details>");
|
|
59148
|
+
L.push("");
|
|
59149
|
+
}
|
|
59150
|
+
return L.join("\n");
|
|
59151
|
+
}
|
|
59152
|
+
if (diff) {
|
|
59153
|
+
L.push(baseWarning(diff.base));
|
|
58924
59154
|
L.push("");
|
|
58925
59155
|
}
|
|
59156
|
+
for (const f of res.findings) findingMd(f, L);
|
|
58926
59157
|
if (res.findings.length === 0) L.push("No committed agent-security issues found.");
|
|
58927
59158
|
return L.join("\n");
|
|
58928
59159
|
}
|
|
58929
|
-
function
|
|
58930
|
-
if (
|
|
58931
|
-
if (
|
|
58932
|
-
if (
|
|
59160
|
+
function exitCodeForSeverity(worst, incomplete) {
|
|
59161
|
+
if (worst === "critical" || worst === "high") return 2;
|
|
59162
|
+
if (worst === "medium") return 1;
|
|
59163
|
+
if (incomplete) return 3;
|
|
58933
59164
|
return 0;
|
|
58934
59165
|
}
|
|
59166
|
+
function exitCodeFor(res) {
|
|
59167
|
+
return exitCodeForSeverity(res.worst, res.incomplete);
|
|
59168
|
+
}
|
|
58935
59169
|
|
|
58936
59170
|
// src/cli/commands/scan-repo.ts
|
|
58937
59171
|
var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
@@ -58957,23 +59191,36 @@ function makeProgress(target, quiet) {
|
|
|
58957
59191
|
};
|
|
58958
59192
|
}
|
|
58959
59193
|
function registerScanRepoCommand(program2) {
|
|
58960
|
-
program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").
|
|
58961
|
-
|
|
58962
|
-
|
|
58963
|
-
|
|
58964
|
-
|
|
58965
|
-
|
|
58966
|
-
|
|
58967
|
-
|
|
58968
|
-
|
|
58969
|
-
|
|
58970
|
-
|
|
58971
|
-
|
|
58972
|
-
|
|
58973
|
-
|
|
59194
|
+
program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").option(
|
|
59195
|
+
"--base <ref>",
|
|
59196
|
+
"also scan this git ref and report what the working tree INTRODUCED (local path targets only)"
|
|
59197
|
+
).option(
|
|
59198
|
+
"--fail-on-introduced",
|
|
59199
|
+
"exit non-zero only for findings this change introduced (requires --base)"
|
|
59200
|
+
).action(
|
|
59201
|
+
async (target, opts) => {
|
|
59202
|
+
const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
|
|
59203
|
+
let res;
|
|
59204
|
+
try {
|
|
59205
|
+
res = await scanRepo(target, onProgress);
|
|
59206
|
+
} finally {
|
|
59207
|
+
done();
|
|
59208
|
+
}
|
|
59209
|
+
let diff;
|
|
59210
|
+
if (opts.base) {
|
|
59211
|
+
const baseTree = readGitRefTree(target, opts.base);
|
|
59212
|
+
diff = diffScans(baseTree ? scanTree(baseTree) : null, res);
|
|
59213
|
+
}
|
|
59214
|
+
if (opts.json) {
|
|
59215
|
+
console.log(JSON.stringify(diff ? { ...res, diff } : res, null, 2));
|
|
59216
|
+
} else if (opts.markdown) {
|
|
59217
|
+
console.log(renderScanMarkdown(res, diff));
|
|
59218
|
+
} else {
|
|
59219
|
+
console.log(renderScan(res, diff));
|
|
59220
|
+
}
|
|
59221
|
+
process.exitCode = opts.failOnIntroduced && diff ? exitCodeForSeverity(diff.worstIntroduced, diff.incomplete) : exitCodeFor(res);
|
|
58974
59222
|
}
|
|
58975
|
-
|
|
58976
|
-
});
|
|
59223
|
+
);
|
|
58977
59224
|
}
|
|
58978
59225
|
|
|
58979
59226
|
// src/cli/commands/egress.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node9": "./dist/cli.js"
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=
|
|
19
|
+
"node": ">=20"
|
|
20
20
|
},
|
|
21
21
|
"workspaces": [
|
|
22
22
|
"packages/*"
|
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
"license": "Apache-2.0",
|
|
55
55
|
"files": [
|
|
56
56
|
"dist",
|
|
57
|
-
"docs",
|
|
58
57
|
"README.md",
|
|
59
58
|
"LICENSE"
|
|
60
59
|
],
|
package/docs/README.md
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# node9 documentation
|
|
2
|
-
|
|
3
|
-
These pages are the source of truth for how node9 behaves. They live next to the code so the tests
|
|
4
|
-
in this repository can check them: a page that names a command the CLI does not have, or a command
|
|
5
|
-
with no page at all, fails CI.
|
|
6
|
-
|
|
7
|
-
The site at [node9.ai/docs](https://node9.ai/docs) renders these same files.
|
|
8
|
-
|
|
9
|
-
## Protections
|
|
10
|
-
|
|
11
|
-
| Page | What it covers |
|
|
12
|
-
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
-
| [Egress Control](egress.md) | Which hosts an agent may reach, the always-on floor around cloud metadata and private ranges, and what a destination gate does not cover |
|
|
14
|
-
|
|
15
|
-
## Per agent
|
|
16
|
-
|
|
17
|
-
[One page per agent](agents/README.md): how node9 is wired in, what that covers, and what it does
|
|
18
|
-
not. Twelve agents, split by control model.
|
|
19
|
-
|
|
20
|
-
## Reference
|
|
21
|
-
|
|
22
|
-
| Page | What it covers |
|
|
23
|
-
| --------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
24
|
-
| [Comparison](comparison.md) | Where node9 sits among agent-security tools, on the axis of what a tool can read before an agent runs |
|
|
25
|
-
| [Badges](badges.md) | The `scanned by node9` badge for your own README |
|
|
26
|
-
|
|
27
|
-
## Writing a page here
|
|
28
|
-
|
|
29
|
-
Every page starts with front matter:
|
|
30
|
-
|
|
31
|
-
```yaml
|
|
32
|
-
---
|
|
33
|
-
id: egress
|
|
34
|
-
label: Egress Control
|
|
35
|
-
description: One sentence. It becomes the page's search description.
|
|
36
|
-
group: Protections
|
|
37
|
-
order: 20
|
|
38
|
-
---
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
`id` becomes the URL (`/docs/egress`), `group` places it in the site's left navigation, and `order`
|
|
42
|
-
sorts it within that group. Use GitHub-flavoured Markdown plus GitHub alerts (`> [!NOTE]`,
|
|
43
|
-
`> [!WARNING]`), which render in both places.
|
|
44
|
-
|
|
45
|
-
Two rules the tests enforce:
|
|
46
|
-
|
|
47
|
-
- Every command a page names must exist. Check with `node9 <command> --help` before you write it.
|
|
48
|
-
- Every page must state what the feature does **not** do. A page that only sells is a page that
|
|
49
|
-
will be wrong within a release.
|
package/docs/agents/README.md
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# node9 per agent
|
|
2
|
-
|
|
3
|
-
One page per agent: what node9 wires into it, what that covers, and what it does not. The
|
|
4
|
-
"what is not covered" section is the one to read before you tell your team an agent is governed.
|
|
5
|
-
|
|
6
|
-
| Agent | Control model | Prompt scan | Guide |
|
|
7
|
-
| --------------------------- | ------------- | ----------- | -------------------------------------- |
|
|
8
|
-
| Claude Code | hooks + MCP | yes | [claude-code.md](claude-code.md) |
|
|
9
|
-
| Codex CLI | hooks + MCP | yes | [codex.md](codex.md) |
|
|
10
|
-
| GitHub Copilot CLI | hooks + MCP | yes | [copilot-cli.md](copilot-cli.md) |
|
|
11
|
-
| Gemini CLI | hooks + MCP | no | [gemini-cli.md](gemini-cli.md) |
|
|
12
|
-
| Antigravity | hooks + MCP | no | [antigravity.md](antigravity.md) |
|
|
13
|
-
| Hermes Agent | hooks | no | [hermes.md](hermes.md) |
|
|
14
|
-
| OpenCode | plugin | yes | [opencode.md](opencode.md) |
|
|
15
|
-
| Pi | extension | yes | [pi.md](pi.md) |
|
|
16
|
-
| Cursor | MCP only | no | [cursor.md](cursor.md) |
|
|
17
|
-
| Windsurf | MCP only | no | [windsurf.md](windsurf.md) |
|
|
18
|
-
| VS Code (Copilot extension) | MCP only | no | [vscode.md](vscode.md) |
|
|
19
|
-
| Claude Desktop | MCP only | no | [claude-desktop.md](claude-desktop.md) |
|
|
20
|
-
|
|
21
|
-
"Hooks" means node9 sees every tool call before it runs. "MCP only" means node9 sees the tools
|
|
22
|
-
that go through MCP servers and nothing the editor does on its own.
|
|
23
|
-
|
|
24
|
-
Every page ends with the same two commands, and they are the real check:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
node9 doctor
|
|
28
|
-
node9 explain Bash 'cat ~/.ssh/id_rsa'
|
|
29
|
-
```
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# node9 with Antigravity
|
|
2
|
-
|
|
3
|
-
| Surface | How node9 is wired | What it does |
|
|
4
|
-
| ----------------- | ------------------------------------------------------ | ------------------------------------------- |
|
|
5
|
-
| Every tool call | `PreToolUse` hook in `~/.gemini/config/hooks.json` | allow / review / block before the tool runs |
|
|
6
|
-
| Every tool result | `PostToolUse` hook | audit record |
|
|
7
|
-
| MCP servers | `~/.gemini/config/mcp_config.json` entries are wrapped | per-tool allow / review / block |
|
|
8
|
-
|
|
9
|
-
Payload shape verified against Antigravity 1.0.6.
|
|
10
|
-
|
|
11
|
-
## Set it up
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
node9 agents add antigravity
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
`node9 init` does this for every agent it detects on the machine. Either command is safe to
|
|
18
|
-
re-run; it repairs a hook that an agent update removed and leaves everything else alone.
|
|
19
|
-
|
|
20
|
-
## What is not covered
|
|
21
|
-
|
|
22
|
-
- **No prompt scan.** There is no prompt event to hook.
|
|
23
|
-
- Cost is not tracked for Antigravity.
|
|
24
|
-
|
|
25
|
-
## Verify it on this machine
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
node9 doctor # is the hook (or MCP wrap) actually in place?
|
|
29
|
-
node9 explain Bash 'cat ~/.ssh/id_rsa' # shows the verdict the live hook enforces: BLOCK
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
`node9 explain` prints the exact rule that fires and where the decision came from. If `doctor`
|
|
33
|
-
says the agent is not wired, the guard is not running, whatever the config looks like.
|