@node9/proxy 2.17.0 → 2.18.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 +76 -331
- package/dist/cli.js +136 -49
- package/dist/cli.mjs +136 -49
- package/dist/dashboard.mjs +4 -2
- package/dist/index.js +38 -8
- package/dist/index.mjs +38 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,53 @@
|
|
|
11
11
|
<a href="https://github.com/node9-ai/node9-proxy/blob/main/.github/workflows/agent-security.yml"><img src="https://img.shields.io/badge/node9-self--scanned-a855f7?style=flat&labelColor=%231A1A2E&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNCAxNCI+PHBhdGggZmlsbD0iI0Y1RTlGRiIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik03IDAuNCAxLjYgMi41djQuMmMwIDMuMSAyLjMgNS42IDUuNCA2LjkgMy4xLTEuMyA1LjQtMy44IDUuNC02LjlWMi41TDcgMC40Wm0wIDEuNSAzLjkgMS41djMuM2MwIDIuMy0xLjYgNC4yLTMuOSA1LjMtMi4zLTEuMS0zLjktMy0zLjktNS4zVjMuNEw3IDEuOVptMCAyLjJhMS45IDEuOSAwIDAgMC0xIDMuNXYxLjZoMlY3LjZhMS45IDEuOSAwIDAgMC0xLTMuNVoiLz48L3N2Zz4K" alt="node9 self-scanned" /></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
+
**Credential jail** · **secrets and PII** · **destructive git, SQL and shell held for review** ·
|
|
15
|
+
**MCP tool pinning** · **network egress allowlist** · **loop breaker** · **one record across twelve agents**
|
|
16
|
+
|
|
17
|
+
Works with **Claude Code · Codex CLI · Antigravity (agy) · GitHub Copilot CLI · Gemini CLI · Cursor · Windsurf · VSCode · Claude Desktop · Opencode · Pi · Hermes Agent · any MCP server**.
|
|
18
|
+
|
|
19
|
+
## What it looks like
|
|
20
|
+
|
|
21
|
+
Your agent on the left, node9 on the right. Every tool call the agent makes is
|
|
22
|
+
checked before it runs: allowed and recorded, held for your approval, or blocked.
|
|
23
|
+
The agent here was launched with `--dangerously-skip-permissions`, and node9 still
|
|
24
|
+
decides.
|
|
25
|
+
|
|
26
|
+
<!-- VIDEO: drag monitor-loop.mp4 into a GitHub comment to get its user-attachments
|
|
27
|
+
URL, then replace this block. GitHub plays mp4 from that host; npm will not
|
|
28
|
+
render it, which is why the scan screenshot below stays. -->
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="https://github.com/user-attachments/assets/4661da97-c174-4bae-ae54-4c52a1d69213" width="760" alt="node9 monitor: live tool calls, decisions, shields and score" />
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
brew tap node9-ai/node9 && brew install node9 # macOS / Linux
|
|
38
|
+
npm install -g node9-ai # any platform
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then, in any project:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
node9 init # finds your agents and MCP servers and puts node9 in front of every tool call
|
|
45
|
+
node9 posture # scores this machine 0-100: what a compromised agent could read, reach and run
|
|
46
|
+
node9 login # optional: adds this machine to a shared dashboard
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Node.js 22+.
|
|
50
|
+
|
|
51
|
+
**`init` is the whole product.** It writes the hooks, turns on the credential jail
|
|
52
|
+
and the always-on rules, and starts enforcing immediately. Nothing leaves the
|
|
53
|
+
machine and no account is needed.
|
|
54
|
+
|
|
55
|
+
**`login` adds nothing to enforcement.** It connects the machine to a workspace so
|
|
56
|
+
a team can see one record across everyone's laptops and CI, set policy centrally,
|
|
57
|
+
and approve held actions from a dashboard or Slack. Skip it and node9 works exactly
|
|
58
|
+
the same, alone, offline. `node9 logout` disconnects again and local enforcement
|
|
59
|
+
keeps running.
|
|
60
|
+
|
|
14
61
|
## The problem
|
|
15
62
|
|
|
16
63
|
In August 2025, compromised releases of the
|
|
@@ -54,341 +101,43 @@ that file. `node9 egress protect` gates destinations as well, and it covers shel
|
|
|
54
101
|
|
|
55
102
|
## Verify it yourself
|
|
56
103
|
|
|
57
|
-
|
|
58
|
-
npx node9-ai scan # every past agent session on this machine, ~10s, nothing uploads
|
|
59
|
-
npx node9-ai scan-repo node9-ai/agent-security-demo # a public repo with a real, hijackable agent workflow
|
|
60
|
-
npx node9-ai posture # this machine's exposure in 60s, nothing uploads
|
|
61
|
-
gh attestation verify cli.js --repo node9-ai/node9-proxy # every release artifact is signed
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
---
|
|
65
|
-
|
|
66
|
-
Three jobs, one tool: **discover** what your agents have already done, **protect** against risky actions in real time, and **review** what happened over any time window.
|
|
67
|
-
|
|
68
|
-
Works with **Claude Code · Codex CLI · Antigravity (agy) · GitHub Copilot CLI · Gemini CLI · Cursor · Windsurf · VSCode · Claude Desktop · Opencode · Pi · Hermes Agent · any MCP server**.
|
|
69
|
-
|
|
70
|
-
## What node9 does
|
|
71
|
-
|
|
72
|
-
- 🔍 **Discover**: scan every past AI session for credential leaks, agent loops, blocked operations, and every secret on disk an agent could reach right now
|
|
73
|
-
- 🛡 **Protect**: review or block risky commands before they run, such as `rm -rf`, `git push --force`, `DROP TABLE`, credential reads, `curl | bash`, and AWS/GitHub/Stripe key leaks
|
|
74
|
-
- 📊 **Review**: a period-windowed report (today / week / month / 90 days) of cost per agent, top tools, shields fired, and blast radius
|
|
75
|
-
|
|
76
|
-
## Retrospective scan
|
|
77
|
-
|
|
78
|
-
This is my own machine, 90 days while building node9. Score 25/100, 5 credential files an AI agent could reach right now.
|
|
104
|
+
Nothing below needs an account, and nothing uploads.
|
|
79
105
|
|
|
80
106
|
```bash
|
|
81
|
-
npx node9-ai scan
|
|
82
|
-
node9 scan
|
|
107
|
+
npx node9-ai scan # every past agent session on this machine
|
|
108
|
+
npx node9-ai scan-repo node9-ai/agent-security-demo # a public repo with a real, hijackable agent workflow
|
|
109
|
+
gh attestation verify cli.js --repo node9-ai/node9-proxy # every release artifact is signed
|
|
83
110
|
```
|
|
84
111
|
|
|
85
112
|
<p align="center">
|
|
86
113
|
<img src="https://github.com/user-attachments/assets/7c5b30f1-1ca1-40b4-bfd5-d6671002e98e" width="720" alt="node9 scan scorecard" />
|
|
87
114
|
</p>
|
|
88
115
|
|
|
89
|
-
##
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
→ node9 shield enable project-jail — or shrink the blast radius, keep host access
|
|
115
|
-
⚠️ Network exposure 4 services on 0.0.0.0 (node :3000/:4000, PostgreSQL :5432, Redis :6379)
|
|
116
|
-
Reachable from your whole network, not just this laptop.
|
|
117
|
-
→ node9 shield enable postgres|redis — node9 blocks DROP TABLE / FLUSHALL
|
|
118
|
-
→ bind to 127.0.0.1 / firewall the port (your part)
|
|
119
|
-
|
|
120
|
-
✅ Supply chain no issues found
|
|
121
|
-
✅ Coverage no issues found
|
|
122
|
-
|
|
123
|
-
Track this across your fleet & keep it green → node9.ai
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
## Scan a repo: agent-CI security
|
|
127
|
-
|
|
128
|
-
`node9 scan-repo` checks any repo (or a local folder) for ways an AI agent wired into GitHub Actions could be **hijacked by an outsider**: injectable workflows, agent-reachable secrets, unpinned MCP servers, over-broad agent config, and poisoned instruction files. Static and parse-only: it reads only committed config, never executes repo code. No install or token needed for public repos.
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
npx node9-ai scan-repo <owner/repo> # any public repo, no install
|
|
132
|
-
node9 scan-repo . # a local checkout, no network
|
|
133
|
-
node9 scan-repo <owner/repo> --json # machine-readable
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
```text
|
|
137
|
-
🛡️ node9 scan-repo · node9-ai/agent-security-demo · ⚠️ agent-security risk found
|
|
138
|
-
inspected 2 config file(s), 2 finding(s)
|
|
139
|
-
|
|
140
|
-
🔴 CRITICAL Injectable agent workflow — untrusted input reaches a tool-using agent with secrets
|
|
141
|
-
.github/workflows/vulnerable-example.yml · CI-2
|
|
142
|
-
• runs with base-repo secrets (pull_request_target)
|
|
143
|
-
• checks out the untrusted PR head into the workspace root
|
|
144
|
-
• allowed_non_write_users: "*" — any user can trigger the agent
|
|
145
|
-
• no effective actor gate
|
|
146
|
-
|
|
147
|
-
🔴 CRITICAL Exfiltratable secrets reachable by an injectable agent
|
|
148
|
-
.github/workflows/vulnerable-example.yml · CI-4
|
|
149
|
-
• agent has arbitrary shell (bare Bash) → can read env and exfiltrate
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
What it checks:
|
|
153
|
-
|
|
154
|
-
| Check | Flags |
|
|
155
|
-
| -------- | -------------------------------------------------------------------------------- |
|
|
156
|
-
| **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
|
|
157
|
-
| **CI-2** | injectable agent workflows: an outsider can trigger the agent and hijack it |
|
|
158
|
-
| **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
|
|
159
|
-
| **CI-4** | secrets an injected agent could exfiltrate |
|
|
160
|
-
| **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / `.cursorrules` |
|
|
161
|
-
|
|
162
|
-
**Gate every PR.** The same engine runs as a GitHub Action, so a hijackable config can't get merged:
|
|
163
|
-
|
|
164
|
-
```yaml
|
|
165
|
-
# .github/workflows/agent-security.yml
|
|
166
|
-
- uses: node9-ai/node9-proxy@v2
|
|
167
|
-
with:
|
|
168
|
-
fail-on: high # or 'never' to just comment
|
|
169
|
-
fail-on-scope: introduced # only what THIS PR added; 'all' (default) judges the whole repo
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
`fail-on-scope: introduced` is what makes the gate adoptable on a repository that already
|
|
173
|
-
has findings: the PR comment leads with what the change introduced, pre-existing findings
|
|
174
|
-
stay listed but do not block, and a base commit that cannot be read falls back to judging
|
|
175
|
-
everything rather than passing.
|
|
176
|
-
|
|
177
|
-
Marketplace: **[node9 Agent Security](https://github.com/marketplace/actions/node9-agent-security)**
|
|
178
|
-
|
|
179
|
-
Running it? Add the **[`scanned by node9` badge](https://node9.ai/docs/badges)** to your README.
|
|
180
|
-
|
|
181
|
-
## Live monitoring
|
|
182
|
-
|
|
183
|
-
<p align="center">
|
|
184
|
-
<img src="https://github.com/user-attachments/assets/4661da97-c174-4bae-ae54-4c52a1d69213" width="720" alt="node9 monitor dashboard" />
|
|
185
|
-
</p>
|
|
186
|
-
|
|
187
|
-
`node9 monitor` opens an interactive terminal dashboard with two views:
|
|
188
|
-
|
|
189
|
-
- **`[1]` Realtime**: live activity, approvals, security alerts, current risk score
|
|
190
|
-
- **`[2]` Report**: period-windowed summary of cost, top tools, shields fired, blast radius
|
|
191
|
-
|
|
192
|
-
## Report
|
|
193
|
-
|
|
194
|
-
Press `[2]` in monitor for a period-windowed summary. Toggle the window with `[T]oday` · `[W]eek` · `[M]onth` · `[N]inety`. Same panels as the scan above, driven by your post-install audit log.
|
|
195
|
-
|
|
196
|
-
<p align="center">
|
|
197
|
-
<img src="https://github.com/user-attachments/assets/66c02a72-e477-443d-807f-d65a21d096cd" width="720" alt="node9 monitor [2] Report" />
|
|
198
|
-
</p>
|
|
199
|
-
|
|
200
|
-
```bash
|
|
201
|
-
node9 monitor # press [2] for Report view
|
|
202
|
-
node9 report --period 7d # CLI form, no TUI
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
## Install
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
# macOS / Linux
|
|
209
|
-
brew tap node9-ai/node9 && brew install node9
|
|
210
|
-
|
|
211
|
-
# or via npm (any platform)
|
|
212
|
-
npm install -g node9-ai
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
```bash
|
|
216
|
-
node9 init # auto-wires all detected agents + MCP servers
|
|
217
|
-
node9 login # connect this machine to your workspace (approve it in the browser)
|
|
218
|
-
node9 doctor # verify everything is wired and reporting
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Requires Node.js 22+.
|
|
222
|
-
|
|
223
|
-
`init` on its own gives you full local enforcement: rules, shields, DLP and
|
|
224
|
-
approvals all work offline, on this machine.
|
|
225
|
-
|
|
226
|
-
`login` is what puts the machine on your dashboard. It prints a code, opens
|
|
227
|
-
the browser, and you approve the machine there; if you don't have an account
|
|
228
|
-
yet, signing up mid-flow returns you to the same approval with the code
|
|
229
|
-
intact. Until you run it, everything is enforced locally but nothing reaches
|
|
230
|
-
Mission Control, so the dashboard stays empty.
|
|
231
|
-
|
|
232
|
-
`node9 logout` disconnects a machine again. It revokes that machine's key;
|
|
233
|
-
local enforcement keeps running.
|
|
234
|
-
|
|
235
|
-
## Shields and apps
|
|
236
|
-
|
|
237
|
-
A **shield** is a curated rule pack for a service an agent touches: Postgres, MongoDB, Redis,
|
|
238
|
-
AWS, Kubernetes, Docker, GitHub, the shell, the filesystem, and the credential jail. Three of
|
|
239
|
-
them, `project-jail`, `bash-safe` and `filesystem`, are on after `node9 init`. The rest you
|
|
240
|
-
enable per service. Each shield mixes hard blocks with actions that come to you for review, and
|
|
241
|
-
the docs say which is which, rule by rule.
|
|
242
|
-
|
|
243
|
-
Any **MCP app** your agents use, Gmail, Slack, your database, is governed tool by tool from the
|
|
244
|
-
Apps page in the dashboard: which tools an agent may call, which need review, which are off.
|
|
245
|
-
|
|
246
|
-
```bash
|
|
247
|
-
node9 shield list # every shield and its status
|
|
248
|
-
node9 shield enable postgres # or enable it fleet-wide from the dashboard
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
The full list, with what each shield blocks and what it sends to review:
|
|
252
|
-
**[node9.ai/docs/shields](https://node9.ai/docs/shields)**.
|
|
253
|
-
|
|
254
|
-
## Always on, no config needed
|
|
255
|
-
|
|
256
|
-
- **Git**: catches `git push --force`, `git reset --hard`, `git clean -fd`
|
|
257
|
-
- **SQL**: catches `DELETE` / `UPDATE` without `WHERE`, `DROP TABLE`, `TRUNCATE`
|
|
258
|
-
- **Shell**: catches `curl | bash`, unauthorized `sudo`
|
|
259
|
-
- **DLP**: flags AWS keys, GitHub tokens, Stripe keys, PEM private keys in any tool argument, file contents, or shell config (`~/.zshrc`, `~/.bashrc`)
|
|
260
|
-
- **Response DLP**: a background scanner reads Claude's conversation history and alerts you if Claude _wrote_ a secret in its response text
|
|
261
|
-
- **Skills pinning**: SHA-256 verification of installed Claude skills / plugins between sessions
|
|
262
|
-
|
|
263
|
-
## Review prompts: approve inline, in your agent
|
|
264
|
-
|
|
265
|
-
When node9 flags an action for **review** (e.g. `git push --force`, a `DROP TABLE`), the approve/deny prompt renders **inline in the agent conversation**: no frozen session, no separate terminal, no hook-timeout race. node9 still runs the full evaluator and makes the decision; only the prompt _surface_ moves to the agent.
|
|
266
|
-
|
|
267
|
-
- **On by default** for **Claude Code** and **GitHub Copilot CLI**, the agents whose hook contract honors a native `ask`. Every other agent (Codex, Gemini, Antigravity, Hermes, Cursor, OpenCode, Pi) uses node9's own approver.
|
|
268
|
-
- **Control it** with `reviewChannel` in `~/.node9/config.json` (or `--no-ask` on the hook):
|
|
269
|
-
|
|
270
|
-
```jsonc
|
|
271
|
-
{
|
|
272
|
-
"settings": {
|
|
273
|
-
"reviewChannel": "ask", // "ask" = inline agent prompt (default) | "approver" = node9's own approver
|
|
274
|
-
},
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
- **Team setups:** when a cloud/team approver is configured (`approvers.cloud: true`), reviews route to that approver instead. node9 won't let an inline self-approval bypass routed/second-party approval.
|
|
279
|
-
|
|
280
|
-
## Sandbox: run an agent in a jail
|
|
281
|
-
|
|
282
|
-
When watching isn't enough, **`node9 sandbox`** runs the agent inside a disposable container with a **kernel-enforced egress allowlist** and **scoped mounts**, while node9's hooks govern and audit every tool call _inside_ the box. The hard version of protection: the agent can only touch the folder you mount and reach the hosts you allow; everything else is dropped at the kernel.
|
|
283
|
-
|
|
284
|
-
```bash
|
|
285
|
-
cd ~/my-project
|
|
286
|
-
node9 sandbox new # write node9.sandbox.yaml: what to mount + which hosts to allow
|
|
287
|
-
node9 sandbox run # build + boot the jailed agent (your project at /workspace)
|
|
288
|
-
node9 sandbox tail # watch the agent's actions live, from the host
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
- **Disposable**: the container is destroyed on exit; your project edits land on your real disk, nothing else survives.
|
|
292
|
-
- **Same policy**: your existing shields / egress rules / approvals apply inside the box, streamed to the same audit log and dashboard.
|
|
293
|
-
- **Closes the posture loop**: running it flips the Isolation / Egress findings green.
|
|
294
|
-
|
|
295
|
-
Honest scope (Phase 1): single container, **Claude first** (Codex next); the agent still holds its _own_ credentials in the box (the kernel egress allowlist confines them to the allowed hosts). _"The agent never holds a secret"_ is the credential-broker phase on the roadmap. Requires Docker.
|
|
296
|
-
|
|
297
|
-
## MCP gateway
|
|
298
|
-
|
|
299
|
-
Wrap any MCP server transparently. The agent sees the same server. node9 intercepts every tool call.
|
|
300
|
-
|
|
301
|
-
```json
|
|
302
|
-
{
|
|
303
|
-
"mcpServers": {
|
|
304
|
-
"postgres": {
|
|
305
|
-
"command": "node9",
|
|
306
|
-
"args": ["mcp", "--upstream", "npx -y @modelcontextprotocol/server-postgres postgresql://..."]
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
Or just run `node9 init`, which wraps your existing MCP servers automatically.
|
|
313
|
-
|
|
314
|
-
<details>
|
|
315
|
-
<summary><strong>🔐 MCP tool pinning: rug-pull defense</strong></summary>
|
|
316
|
-
|
|
317
|
-
MCP servers can change their tool definitions between sessions. A compromised or malicious server could silently add, remove, or modify tools after you first trusted it, a **rug pull** attack.
|
|
318
|
-
|
|
319
|
-
node9 pins tool definitions on first use:
|
|
320
|
-
|
|
321
|
-
1. **First connection**: the gateway records a SHA-256 hash of every tool's name, description, and schema
|
|
322
|
-
2. **Subsequent connections**: the hash is compared; if tools changed, the session is **quarantined** and every tool call is blocked until a human reviews and approves the change
|
|
323
|
-
3. **Corrupt pin state**: fails closed (blocks), never silently re-trusts
|
|
324
|
-
|
|
325
|
-
```bash
|
|
326
|
-
node9 mcp pin list # show all pinned servers and hashes
|
|
327
|
-
node9 mcp pin update <serverKey> # remove pin, re-pin on next connection
|
|
328
|
-
node9 mcp pin reset # clear all pins
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
</details>
|
|
332
|
-
|
|
333
|
-
## Other commands
|
|
334
|
-
|
|
335
|
-
Beyond the three flow commands above (`scan` / `monitor` / `report`):
|
|
336
|
-
|
|
337
|
-
| Command | What it shows | When to use |
|
|
338
|
-
| ---------------- | --------------------------------------------------------- | -------------------------------------- |
|
|
339
|
-
| `node9 blast` | What an AI agent can reach right now: files, creds, env | First thing to run on any machine |
|
|
340
|
-
| `node9 tail` | Live stream of every tool call (text-only, no TUI) | Piping into other tools, CI, logs |
|
|
341
|
-
| `node9 sessions` | Session history with prompt, tool trace, and cost | Reviewing a handoff or past work |
|
|
342
|
-
| `node9 dlp` | Credential-leak findings in Claude response text | Any time a DLP desktop alert fires |
|
|
343
|
-
| `node9 mask` | Redact plaintext secrets from local session history files | After a DLP finding, cleans local disk |
|
|
344
|
-
|
|
345
|
-
Plus a **live HUD** in your Claude Code statusline:
|
|
346
|
-
|
|
347
|
-
```
|
|
348
|
-
🛡 node9 | standard | [bash-safe] | ✅ 12 allowed 🛑 2 blocked 🚨 0 dlp | ~$0.43
|
|
349
|
-
📊 claude-opus-4-7 | ctx [████████░░░] 54% | 5h [██░░░░░░░░] 12% | 7d [█░░░░░░░] 7%
|
|
350
|
-
🗂 2 CLAUDE.md | 8 rules | 3 MCPs | 4 hooks
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
## Reading the data: what the numbers mean
|
|
354
|
-
|
|
355
|
-
node9 surfaces the signal. Here are the patterns worth knowing:
|
|
356
|
-
|
|
357
|
-
| Signal | Likely meaning |
|
|
358
|
-
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
359
|
-
| `Would have blocked` ≥ 5 in a week | Agent is attempting high-impact ops; shields are worth reviewing |
|
|
360
|
-
| Single `review-git-push` rule >50% of findings | Your own rule is firing as intended: not a risk, just supervision |
|
|
361
|
-
| DLP finding in `user-prompt` tool | You pasted a secret into your own prompt. Rotate the key |
|
|
362
|
-
| Agent Loop ×50+ on same file | Agent stuck in an edit/test/fix cycle. Check context or slow down |
|
|
363
|
-
| MCP tool pin mismatch | Server changed its tools. Review before re-trusting |
|
|
364
|
-
| Large MCP response warning | That server is inflating your context window for every subsequent turn |
|
|
365
|
-
| `Response DLP` alert | Claude wrote a secret in its response text. Not blocked, rotate immediately |
|
|
366
|
-
| DLP finding in `tool-result` | Claude read a file containing a secret (`.env`, credentials). Rotate the key and run `node9 mask` |
|
|
367
|
-
| DLP finding in `[Shell]` | Plaintext secret in `~/.zshrc` or `~/.bashrc`. Every AI session can see it |
|
|
368
|
-
|
|
369
|
-
One-off signals are normal; persistent patterns are what you act on.
|
|
370
|
-
|
|
371
|
-
## Python SDK: govern any Python agent
|
|
372
|
-
|
|
373
|
-
```python
|
|
374
|
-
from node9 import configure, protect
|
|
375
|
-
|
|
376
|
-
configure(agent_name="my-agent", policy="require_approval")
|
|
377
|
-
|
|
378
|
-
@protect("bash")
|
|
379
|
-
def run_command(cmd: str) -> str:
|
|
380
|
-
...
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
**[Python SDK →](https://github.com/node9-ai/node9-python)** · **[GitHub Action →](https://github.com/marketplace/actions/node9-agent-security)**
|
|
384
|
-
|
|
385
|
-
## Under the hood
|
|
386
|
-
|
|
387
|
-
- **Scan** reads raw agent history from `~/.claude/projects/`, `~/.gemini/tmp/`, `~/.gemini/antigravity-*/brain/`, `~/.copilot/session-state/`, `~/.codex/sessions/`. No API calls, fully offline
|
|
388
|
-
- **Runtime** intercepts tool calls via pre-execution hooks (Claude Code, Codex, Antigravity, GitHub Copilot CLI, Gemini CLI, Opencode, Pi) or via the MCP gateway (Cursor, Windsurf, VSCode, Claude Desktop). All decisions land in `~/.node9/audit.log` atomically.
|
|
389
|
-
- **MCP gateway** is a stdio proxy; intercepts `tools/list` + `tools/call` JSON-RPC, forwards the rest
|
|
390
|
-
- **Policy engine** uses [mvdan-sh](https://github.com/mvdan/sh) for bash AST analysis, which defeats obfuscation via backslash escaping, variable substitution, eval of remote download
|
|
391
|
-
- **Sandbox** generates a Dockerfile + entrypoint that seal an `ipset`/`iptables` deny-by-default egress allowlist, then drop to a non-root agent with node9's daemon + hooks running inside; only the agent's credential file is mounted, never your whole `~/.claude`
|
|
116
|
+
## What it governs
|
|
117
|
+
|
|
118
|
+
Each line is one capability, with the page that documents it. The docs are the
|
|
119
|
+
reference; this file is the map.
|
|
120
|
+
|
|
121
|
+
| | | |
|
|
122
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
|
123
|
+
| **Credential jail** | `~/.ssh`, `~/.aws`, `.env` and private keys are blocked from every tool, not just the shell | [docs](https://node9.ai/docs/shields) |
|
|
124
|
+
| **Always-on rules** | destructive git, SQL without a `WHERE`, `curl \| bash` and unauthorised `sudo`, with no config | [docs](https://node9.ai/docs/smart-rules) |
|
|
125
|
+
| **Secrets and PII** | AWS keys, GitHub and Stripe tokens, PEM keys and card or SSN shapes, in any tool argument, plus a background scan of what the agent wrote back | [docs](https://node9.ai/docs/dlp) |
|
|
126
|
+
| **Per-service shields** | curated rule packs for Postgres, MongoDB, Redis, AWS, Kubernetes, Docker, GitHub and the filesystem | [docs](https://node9.ai/docs/shields) |
|
|
127
|
+
| **Inline review** | a held action asks you inside the agent conversation, or through a team approver | [docs](https://node9.ai/docs/cloud-policy) |
|
|
128
|
+
| **Egress allowlist** | gate where a shell command may send data, off by default | [docs](https://node9.ai/docs/egress) |
|
|
129
|
+
| **MCP gateway** | wrap any MCP server, authorise each tool, and pin tool definitions so a server cannot change them behind your back | [docs](https://node9.ai/docs/mcp-gateway) |
|
|
130
|
+
| **Sandbox** | run an agent in a container with a kernel egress allowlist and scoped mounts | [docs](https://node9.ai/docs/sandbox) |
|
|
131
|
+
| **Posture score** | how exposed this machine is, with the command that fixes each finding | [docs](https://node9.ai/docs/posture) |
|
|
132
|
+
| **Repo scanning** | find workflows where an outsider could hijack an agent that holds your secrets, in CI or from the CLI | [docs](https://node9.ai/docs/repo-scanning) |
|
|
133
|
+
| **Session history** | read what every agent already did on this machine, before node9 was installed | [docs](https://node9.ai/docs/sessions) |
|
|
134
|
+
| **Live monitor and report** | a terminal dashboard, and a windowed summary of cost, tools, blocks and blast radius | [docs](https://node9.ai/docs/report) |
|
|
135
|
+
| **Skills pinning** | SHA-256 verification of installed Claude skills and plugins between sessions | [docs](https://node9.ai/docs/skill-pinning) |
|
|
136
|
+
| **Canary credentials** | planted fake keys that prove an exfiltration attempt happened | [docs](https://node9.ai/docs/canary-credentials) |
|
|
137
|
+
| **Python SDK** | govern any Python agent, not only the CLIs | [docs](https://node9.ai/docs/python-sdk) |
|
|
138
|
+
|
|
139
|
+
Full CLI and config reference: **[node9.ai/docs](https://node9.ai/docs)**.
|
|
140
|
+
How the layers fit together: **[how it works](https://node9.ai/docs/how-it-works)**.
|
|
392
141
|
|
|
393
142
|
## Learn
|
|
394
143
|
|
|
@@ -405,10 +154,6 @@ Background reading, written to stand on its own. Each page says what node9 does
|
|
|
405
154
|
- **[node9 against the alternatives](https://node9.ai/compare)**: a matrix, including the rows where node9 scores worse
|
|
406
155
|
- **[Per-agent coverage](https://node9.ai/agents)**: what is governed on each of the twelve supported agents
|
|
407
156
|
|
|
408
|
-
## Full docs
|
|
409
|
-
|
|
410
|
-
Config reference, smart rules, stateful rules, trusted hosts, approval modes, CLI reference, at **[node9.ai/docs](https://node9.ai/docs)**.
|
|
411
|
-
|
|
412
157
|
## Related projects
|
|
413
158
|
|
|
414
159
|
- **[node9-python](https://github.com/node9-ai/node9-python)**: Python SDK
|
package/dist/cli.js
CHANGED
|
@@ -2251,15 +2251,44 @@ function classifySsrf(host) {
|
|
|
2251
2251
|
return null;
|
|
2252
2252
|
}
|
|
2253
2253
|
}
|
|
2254
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
2255
|
+
if (!entries?.length || !normalized) return false;
|
|
2256
|
+
const target = bitsOf(normalized);
|
|
2257
|
+
for (const raw of entries) {
|
|
2258
|
+
const entry = raw.trim().toLowerCase();
|
|
2259
|
+
if (!entry) continue;
|
|
2260
|
+
const slash = entry.indexOf("/");
|
|
2261
|
+
if (slash === -1) {
|
|
2262
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
if (!target) continue;
|
|
2266
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
2267
|
+
const prefixText = entry.slice(slash + 1);
|
|
2268
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
2269
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
2270
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
2271
|
+
base.length !== target.length || prefix > base.length) {
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
2275
|
+
}
|
|
2276
|
+
return false;
|
|
2277
|
+
}
|
|
2278
|
+
function bitsOf(normalized) {
|
|
2279
|
+
const o = v4Octets(normalized);
|
|
2280
|
+
if (o) {
|
|
2281
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
2282
|
+
}
|
|
2283
|
+
const g = expandIpv6(normalized);
|
|
2284
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
2285
|
+
}
|
|
2254
2286
|
function ssrfFloor(tokens, opts = {}) {
|
|
2255
|
-
const exempt2 = new Set(
|
|
2256
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2257
|
-
);
|
|
2258
2287
|
for (const { token, binary } of tokens) {
|
|
2259
2288
|
const m = classifySsrf(token);
|
|
2260
2289
|
if (!m) continue;
|
|
2261
2290
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2262
|
-
if (m.overridable &&
|
|
2291
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2263
2292
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2264
2293
|
}
|
|
2265
2294
|
return null;
|
|
@@ -2568,7 +2597,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2568
2597
|
try {
|
|
2569
2598
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2570
2599
|
if (!paths) return null;
|
|
2571
|
-
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2572
2600
|
for (const path78 of paths) {
|
|
2573
2601
|
for (const value of valuesAt(args, path78)) {
|
|
2574
2602
|
const host = hostOf(value);
|
|
@@ -2576,7 +2604,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2576
2604
|
const m = classifySsrf(host);
|
|
2577
2605
|
if (!m) continue;
|
|
2578
2606
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2579
|
-
if (m.overridable &&
|
|
2607
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2580
2608
|
return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
|
|
2581
2609
|
}
|
|
2582
2610
|
}
|
|
@@ -6799,10 +6827,12 @@ var init_api_url = __esm({
|
|
|
6799
6827
|
function sanitizeSsrfAllow(entries, source) {
|
|
6800
6828
|
const kept = [];
|
|
6801
6829
|
for (const entry of entries) {
|
|
6802
|
-
const
|
|
6830
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
6831
|
+
const m = classifySsrf(base);
|
|
6803
6832
|
if (m && !m.overridable) {
|
|
6833
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
6804
6834
|
process.emitWarning(
|
|
6805
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
6835
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
6806
6836
|
);
|
|
6807
6837
|
continue;
|
|
6808
6838
|
}
|
|
@@ -20044,19 +20074,19 @@ function evaluateEgressConfig(egress) {
|
|
|
20044
20074
|
function checkEgressFloor(egress) {
|
|
20045
20075
|
const detail = [
|
|
20046
20076
|
"the cloud instance-metadata endpoint",
|
|
20047
|
-
"link-local
|
|
20048
|
-
egress.ssrfStrict ? "the strict tier is on: loopback
|
|
20077
|
+
"link-local and multicast addresses",
|
|
20078
|
+
egress.ssrfStrict ? "the strict tier is on: loopback, the private ranges and CGNAT are blocked too" : `the strict tier is off: loopback, the private ranges and CGNAT (100.64/10) stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
|
|
20049
20079
|
];
|
|
20050
20080
|
if (egress.ssrfAllow.length) {
|
|
20051
20081
|
detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
|
|
20052
20082
|
}
|
|
20053
|
-
detail.push("not covered:
|
|
20083
|
+
detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
|
|
20054
20084
|
return [
|
|
20055
20085
|
{
|
|
20056
20086
|
category: "Egress",
|
|
20057
20087
|
severity: "advisory",
|
|
20058
|
-
title: "The cloud metadata endpoint is blocked
|
|
20059
|
-
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh)
|
|
20088
|
+
title: "The cloud metadata endpoint is blocked",
|
|
20089
|
+
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh) and tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate); an interpreter one-liner does not reach it.",
|
|
20060
20090
|
why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
|
|
20061
20091
|
who: "An agent talked into fetching that address hands over the keys and cannot, here.",
|
|
20062
20092
|
owner: "node9",
|
|
@@ -57103,10 +57133,11 @@ function addEgressHost(list, host) {
|
|
|
57103
57133
|
writeEgressRawConfig(config);
|
|
57104
57134
|
}
|
|
57105
57135
|
function addSsrfExemption(address) {
|
|
57106
|
-
const
|
|
57136
|
+
const slash = address.indexOf("/");
|
|
57137
|
+
const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
|
|
57107
57138
|
if (m && !m.overridable) {
|
|
57108
57139
|
throw new Error(
|
|
57109
|
-
`${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57140
|
+
`${address} ${slash === -1 ? "is a protected address" : "covers only protected addresses"} (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57110
57141
|
);
|
|
57111
57142
|
}
|
|
57112
57143
|
const config = readEgressRawConfig();
|
|
@@ -57378,7 +57409,7 @@ var TOOLS = [
|
|
|
57378
57409
|
},
|
|
57379
57410
|
{
|
|
57380
57411
|
name: "node9_egress_status",
|
|
57381
|
-
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked
|
|
57412
|
+
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked before any policy is consulted (cloud metadata, link-local, multicast), the carriers it covers, whether the strict tier (loopback, private ranges, CGNAT) is on, and the exemptions in force. Read-only.",
|
|
57382
57413
|
inputSchema: { type: "object", properties: {}, required: [] }
|
|
57383
57414
|
},
|
|
57384
57415
|
{
|
|
@@ -57538,11 +57569,12 @@ function handleEgressStatus() {
|
|
|
57538
57569
|
// The SSRF floor. Without these lines an agent reading this answer
|
|
57539
57570
|
// concludes that internal addresses are reachable, because nothing said
|
|
57540
57571
|
// otherwise. The LIMITS are here for the same reason and matter more on
|
|
57541
|
-
// this surface than on any other: an agent treats this as ground truth
|
|
57542
|
-
//
|
|
57543
|
-
// shell
|
|
57544
|
-
|
|
57545
|
-
"
|
|
57572
|
+
// this surface than on any other: an agent treats this as ground truth.
|
|
57573
|
+
// The first version said the floor was absolute; the correction said it
|
|
57574
|
+
// was shell-only; both were wrong by the time they were read. What it
|
|
57575
|
+
// actually covers is measured in egress.integration.test.ts.
|
|
57576
|
+
"Protected addresses: cloud metadata, link-local and multicast are blocked before any of the above is consulted, in shell commands AND in tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate). No setting releases them, though `node9 pause` suspends all enforcement.",
|
|
57577
|
+
"NOT covered by that: an interpreter one-liner (node -e, python3 -c) carries its destination inside a program and does not reach this gate. CGNAT (100.64/10) is NOT in the always-blocked set \u2014 it is reachable until the strict tier is on, and an exemption can release it.",
|
|
57546
57578
|
`Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
|
|
57547
57579
|
`Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
|
|
57548
57580
|
];
|
|
@@ -60301,26 +60333,76 @@ function exempt(address) {
|
|
|
60301
60333
|
if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
|
|
60302
60334
|
return guard(() => addSsrfExemption(address));
|
|
60303
60335
|
}
|
|
60336
|
+
var INTERNAL_FIELDS = {
|
|
60337
|
+
allowed: { ssrfStrict: false, allowPrivate: true },
|
|
60338
|
+
listed: { ssrfStrict: false, allowPrivate: false },
|
|
60339
|
+
blocked: { ssrfStrict: true, allowPrivate: false }
|
|
60340
|
+
};
|
|
60341
|
+
var INTERNAL_SAID = {
|
|
60342
|
+
allowed: "reachable without listing them",
|
|
60343
|
+
listed: "reachable only if they are on your allowlist",
|
|
60344
|
+
blocked: "blocked"
|
|
60345
|
+
};
|
|
60346
|
+
function readInternalState(e) {
|
|
60347
|
+
if (e.ssrfStrict === true) return "blocked";
|
|
60348
|
+
return e.allowPrivate === false ? "listed" : "allowed";
|
|
60349
|
+
}
|
|
60350
|
+
function setInternal(value) {
|
|
60351
|
+
const state = value.trim().toLowerCase();
|
|
60352
|
+
if (!(state in INTERNAL_FIELDS)) {
|
|
60353
|
+
console.error(
|
|
60354
|
+
import_chalk34.default.red(`
|
|
60355
|
+
\u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + import_chalk34.default.gray(
|
|
60356
|
+
"\n allowed loopback, 10/172.16/192.168 and CGNAT are reachable (default)\n listed they are reachable only if you allowlist them\n blocked they are blocked at the floor\n"
|
|
60357
|
+
)
|
|
60358
|
+
);
|
|
60359
|
+
process.exitCode = 1;
|
|
60360
|
+
return;
|
|
60361
|
+
}
|
|
60362
|
+
if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
|
|
60363
|
+
_resetConfigCache();
|
|
60364
|
+
const effective = readInternalState(getConfig().policy.egress);
|
|
60365
|
+
if (effective !== state) {
|
|
60366
|
+
console.log(
|
|
60367
|
+
import_chalk34.default.yellow(
|
|
60368
|
+
`
|
|
60369
|
+
\u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
|
|
60370
|
+
Change it in the dashboard, Enforcement \u2192 Network.
|
|
60371
|
+
`
|
|
60372
|
+
)
|
|
60373
|
+
);
|
|
60374
|
+
return;
|
|
60375
|
+
}
|
|
60376
|
+
const line = `
|
|
60377
|
+
\u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
|
|
60378
|
+
`;
|
|
60379
|
+
console.log(state === "allowed" ? import_chalk34.default.yellow(line) : import_chalk34.default.green(line));
|
|
60380
|
+
}
|
|
60304
60381
|
function showFloor(e, ssrfStrictSource) {
|
|
60305
|
-
console.log(
|
|
60382
|
+
console.log(
|
|
60383
|
+
import_chalk34.default.gray("\n Protected addresses") + import_chalk34.default.gray(" \u2014 in shell commands and declared URLs")
|
|
60384
|
+
);
|
|
60306
60385
|
console.log(
|
|
60307
60386
|
import_chalk34.default.gray(
|
|
60308
|
-
" always blocked: cloud metadata, link-local, multicast
|
|
60387
|
+
" always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
|
|
60309
60388
|
)
|
|
60310
60389
|
);
|
|
60311
|
-
const
|
|
60390
|
+
const state = readInternalState(e);
|
|
60312
60391
|
const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
|
|
60392
|
+
const STATE_LABEL = {
|
|
60393
|
+
allowed: "allowed",
|
|
60394
|
+
listed: "allowlist only",
|
|
60395
|
+
blocked: "blocked"
|
|
60396
|
+
};
|
|
60313
60397
|
console.log(
|
|
60314
|
-
` Internal addresses: ${
|
|
60315
|
-
strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
|
|
60316
|
-
)
|
|
60398
|
+
` Internal addresses: ${state === "blocked" ? import_chalk34.default.green(STATE_LABEL[state]) : import_chalk34.default.yellow(STATE_LABEL[state])}` + import_chalk34.default.gray(` loopback, 10/172.16/192.168 and CGNAT are ${INTERNAL_SAID[state]}`)
|
|
60317
60399
|
);
|
|
60318
60400
|
console.log(import_chalk34.default.gray(` set by: ${by}`));
|
|
60319
60401
|
const exemptions = e.ssrfAllow ?? [];
|
|
60320
60402
|
console.log(import_chalk34.default.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
|
|
60321
60403
|
console.log(
|
|
60322
60404
|
import_chalk34.default.gray(
|
|
60323
|
-
"
|
|
60405
|
+
" Covered: shell commands, and tools that declare a URL (WebFetch, an MCP\n fetch tool, browser navigate).\n Not covered: an interpreter one-liner (node -e, python3 -c) hides its\n destination inside a program and does not reach this gate.\n CGNAT (100.64/10) is NOT in the always-blocked set: it is reachable\n until Internal addresses is on, and an exemption can release it."
|
|
60324
60406
|
)
|
|
60325
60407
|
);
|
|
60326
60408
|
}
|
|
@@ -60385,7 +60467,10 @@ function registerEgressCommand(program2) {
|
|
|
60385
60467
|
import_chalk34.default.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
|
|
60386
60468
|
);
|
|
60387
60469
|
});
|
|
60388
|
-
egress.command("
|
|
60470
|
+
egress.command("internal <allowed|listed|blocked>").description(
|
|
60471
|
+
"How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
|
|
60472
|
+
).action((value) => setInternal(value));
|
|
60473
|
+
egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
|
|
60389
60474
|
const v = value.trim().toLowerCase();
|
|
60390
60475
|
if (v !== "on" && v !== "off") {
|
|
60391
60476
|
console.error(import_chalk34.default.red(`
|
|
@@ -60394,38 +60479,40 @@ function registerEgressCommand(program2) {
|
|
|
60394
60479
|
process.exitCode = 1;
|
|
60395
60480
|
return;
|
|
60396
60481
|
}
|
|
60397
|
-
|
|
60398
|
-
|
|
60399
|
-
|
|
60400
|
-
|
|
60401
|
-
|
|
60402
|
-
|
|
60403
|
-
|
|
60404
|
-
|
|
60405
|
-
|
|
60406
|
-
|
|
60482
|
+
const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
|
|
60483
|
+
console.log(import_chalk34.default.gray(`
|
|
60484
|
+
(\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
|
|
60485
|
+
setInternal(state);
|
|
60486
|
+
});
|
|
60487
|
+
egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
|
|
60488
|
+
const a = normalizeEgressHost(address);
|
|
60489
|
+
const slash = a.indexOf("/");
|
|
60490
|
+
const base = slash === -1 ? a : a.slice(0, slash);
|
|
60491
|
+
const prefixText = slash === -1 ? null : a.slice(slash + 1);
|
|
60492
|
+
if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
|
|
60493
|
+
console.error(
|
|
60494
|
+
import_chalk34.default.red(`
|
|
60495
|
+
\u2717 "${address}" is not an address or a range.`) + import_chalk34.default.gray(
|
|
60496
|
+
"\n Exemptions are matched as an address (10.0.0.5) or a CIDR\n range (100.64.0.0/10), never a name.\n"
|
|
60407
60497
|
)
|
|
60408
60498
|
);
|
|
60499
|
+
process.exitCode = 1;
|
|
60409
60500
|
return;
|
|
60410
60501
|
}
|
|
60411
|
-
|
|
60412
|
-
|
|
60413
|
-
);
|
|
60414
|
-
});
|
|
60415
|
-
egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
|
|
60416
|
-
const a = normalizeEgressHost(address);
|
|
60417
|
-
if (!normalizeIpLiteral(a)) {
|
|
60502
|
+
const m = classifySsrf(base);
|
|
60503
|
+
if (m && !m.overridable) {
|
|
60418
60504
|
console.error(
|
|
60419
60505
|
import_chalk34.default.red(`
|
|
60420
|
-
\u2717
|
|
60421
|
-
|
|
60506
|
+
\u2717 ${a} cannot be exempted.`) + import_chalk34.default.gray(
|
|
60507
|
+
`
|
|
60508
|
+
${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
|
|
60509
|
+
`
|
|
60422
60510
|
)
|
|
60423
60511
|
);
|
|
60424
60512
|
process.exitCode = 1;
|
|
60425
60513
|
return;
|
|
60426
60514
|
}
|
|
60427
60515
|
if (!exempt(a)) return;
|
|
60428
|
-
const m = classifySsrf(a);
|
|
60429
60516
|
console.log(import_chalk34.default.green(`
|
|
60430
60517
|
\u2713 ${a} is exempt from the floor.`));
|
|
60431
60518
|
if (!m)
|
package/dist/cli.mjs
CHANGED
|
@@ -2262,15 +2262,44 @@ function classifySsrf(host) {
|
|
|
2262
2262
|
return null;
|
|
2263
2263
|
}
|
|
2264
2264
|
}
|
|
2265
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
2266
|
+
if (!entries?.length || !normalized) return false;
|
|
2267
|
+
const target = bitsOf(normalized);
|
|
2268
|
+
for (const raw of entries) {
|
|
2269
|
+
const entry = raw.trim().toLowerCase();
|
|
2270
|
+
if (!entry) continue;
|
|
2271
|
+
const slash = entry.indexOf("/");
|
|
2272
|
+
if (slash === -1) {
|
|
2273
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (!target) continue;
|
|
2277
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
2278
|
+
const prefixText = entry.slice(slash + 1);
|
|
2279
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
2280
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
2281
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
2282
|
+
base.length !== target.length || prefix > base.length) {
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
2286
|
+
}
|
|
2287
|
+
return false;
|
|
2288
|
+
}
|
|
2289
|
+
function bitsOf(normalized) {
|
|
2290
|
+
const o = v4Octets(normalized);
|
|
2291
|
+
if (o) {
|
|
2292
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
2293
|
+
}
|
|
2294
|
+
const g = expandIpv6(normalized);
|
|
2295
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
2296
|
+
}
|
|
2265
2297
|
function ssrfFloor(tokens, opts = {}) {
|
|
2266
|
-
const exempt2 = new Set(
|
|
2267
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2268
|
-
);
|
|
2269
2298
|
for (const { token, binary } of tokens) {
|
|
2270
2299
|
const m = classifySsrf(token);
|
|
2271
2300
|
if (!m) continue;
|
|
2272
2301
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2273
|
-
if (m.overridable &&
|
|
2302
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2274
2303
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2275
2304
|
}
|
|
2276
2305
|
return null;
|
|
@@ -2579,7 +2608,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2579
2608
|
try {
|
|
2580
2609
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2581
2610
|
if (!paths) return null;
|
|
2582
|
-
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2583
2611
|
for (const path78 of paths) {
|
|
2584
2612
|
for (const value of valuesAt(args, path78)) {
|
|
2585
2613
|
const host = hostOf(value);
|
|
@@ -2587,7 +2615,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2587
2615
|
const m = classifySsrf(host);
|
|
2588
2616
|
if (!m) continue;
|
|
2589
2617
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2590
|
-
if (m.overridable &&
|
|
2618
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2591
2619
|
return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
|
|
2592
2620
|
}
|
|
2593
2621
|
}
|
|
@@ -6806,10 +6834,12 @@ import os4 from "os";
|
|
|
6806
6834
|
function sanitizeSsrfAllow(entries, source) {
|
|
6807
6835
|
const kept = [];
|
|
6808
6836
|
for (const entry of entries) {
|
|
6809
|
-
const
|
|
6837
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
6838
|
+
const m = classifySsrf(base);
|
|
6810
6839
|
if (m && !m.overridable) {
|
|
6840
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
6811
6841
|
process.emitWarning(
|
|
6812
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
6842
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
6813
6843
|
);
|
|
6814
6844
|
continue;
|
|
6815
6845
|
}
|
|
@@ -20042,19 +20072,19 @@ function evaluateEgressConfig(egress) {
|
|
|
20042
20072
|
function checkEgressFloor(egress) {
|
|
20043
20073
|
const detail = [
|
|
20044
20074
|
"the cloud instance-metadata endpoint",
|
|
20045
|
-
"link-local
|
|
20046
|
-
egress.ssrfStrict ? "the strict tier is on: loopback
|
|
20075
|
+
"link-local and multicast addresses",
|
|
20076
|
+
egress.ssrfStrict ? "the strict tier is on: loopback, the private ranges and CGNAT are blocked too" : `the strict tier is off: loopback, the private ranges and CGNAT (100.64/10) stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
|
|
20047
20077
|
];
|
|
20048
20078
|
if (egress.ssrfAllow.length) {
|
|
20049
20079
|
detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
|
|
20050
20080
|
}
|
|
20051
|
-
detail.push("not covered:
|
|
20081
|
+
detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
|
|
20052
20082
|
return [
|
|
20053
20083
|
{
|
|
20054
20084
|
category: "Egress",
|
|
20055
20085
|
severity: "advisory",
|
|
20056
|
-
title: "The cloud metadata endpoint is blocked
|
|
20057
|
-
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh)
|
|
20086
|
+
title: "The cloud metadata endpoint is blocked",
|
|
20087
|
+
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh) and tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate); an interpreter one-liner does not reach it.",
|
|
20058
20088
|
why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
|
|
20059
20089
|
who: "An agent talked into fetching that address hands over the keys and cannot, here.",
|
|
20060
20090
|
owner: "node9",
|
|
@@ -57093,10 +57123,11 @@ function addEgressHost(list, host) {
|
|
|
57093
57123
|
writeEgressRawConfig(config);
|
|
57094
57124
|
}
|
|
57095
57125
|
function addSsrfExemption(address) {
|
|
57096
|
-
const
|
|
57126
|
+
const slash = address.indexOf("/");
|
|
57127
|
+
const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
|
|
57097
57128
|
if (m && !m.overridable) {
|
|
57098
57129
|
throw new Error(
|
|
57099
|
-
`${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57130
|
+
`${address} ${slash === -1 ? "is a protected address" : "covers only protected addresses"} (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57100
57131
|
);
|
|
57101
57132
|
}
|
|
57102
57133
|
const config = readEgressRawConfig();
|
|
@@ -57368,7 +57399,7 @@ var TOOLS = [
|
|
|
57368
57399
|
},
|
|
57369
57400
|
{
|
|
57370
57401
|
name: "node9_egress_status",
|
|
57371
|
-
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked
|
|
57402
|
+
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked before any policy is consulted (cloud metadata, link-local, multicast), the carriers it covers, whether the strict tier (loopback, private ranges, CGNAT) is on, and the exemptions in force. Read-only.",
|
|
57372
57403
|
inputSchema: { type: "object", properties: {}, required: [] }
|
|
57373
57404
|
},
|
|
57374
57405
|
{
|
|
@@ -57528,11 +57559,12 @@ function handleEgressStatus() {
|
|
|
57528
57559
|
// The SSRF floor. Without these lines an agent reading this answer
|
|
57529
57560
|
// concludes that internal addresses are reachable, because nothing said
|
|
57530
57561
|
// otherwise. The LIMITS are here for the same reason and matter more on
|
|
57531
|
-
// this surface than on any other: an agent treats this as ground truth
|
|
57532
|
-
//
|
|
57533
|
-
// shell
|
|
57534
|
-
|
|
57535
|
-
"
|
|
57562
|
+
// this surface than on any other: an agent treats this as ground truth.
|
|
57563
|
+
// The first version said the floor was absolute; the correction said it
|
|
57564
|
+
// was shell-only; both were wrong by the time they were read. What it
|
|
57565
|
+
// actually covers is measured in egress.integration.test.ts.
|
|
57566
|
+
"Protected addresses: cloud metadata, link-local and multicast are blocked before any of the above is consulted, in shell commands AND in tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate). No setting releases them, though `node9 pause` suspends all enforcement.",
|
|
57567
|
+
"NOT covered by that: an interpreter one-liner (node -e, python3 -c) carries its destination inside a program and does not reach this gate. CGNAT (100.64/10) is NOT in the always-blocked set \u2014 it is reachable until the strict tier is on, and an exemption can release it.",
|
|
57536
57568
|
`Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
|
|
57537
57569
|
`Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
|
|
57538
57570
|
];
|
|
@@ -60291,26 +60323,76 @@ function exempt(address) {
|
|
|
60291
60323
|
if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
|
|
60292
60324
|
return guard(() => addSsrfExemption(address));
|
|
60293
60325
|
}
|
|
60326
|
+
var INTERNAL_FIELDS = {
|
|
60327
|
+
allowed: { ssrfStrict: false, allowPrivate: true },
|
|
60328
|
+
listed: { ssrfStrict: false, allowPrivate: false },
|
|
60329
|
+
blocked: { ssrfStrict: true, allowPrivate: false }
|
|
60330
|
+
};
|
|
60331
|
+
var INTERNAL_SAID = {
|
|
60332
|
+
allowed: "reachable without listing them",
|
|
60333
|
+
listed: "reachable only if they are on your allowlist",
|
|
60334
|
+
blocked: "blocked"
|
|
60335
|
+
};
|
|
60336
|
+
function readInternalState(e) {
|
|
60337
|
+
if (e.ssrfStrict === true) return "blocked";
|
|
60338
|
+
return e.allowPrivate === false ? "listed" : "allowed";
|
|
60339
|
+
}
|
|
60340
|
+
function setInternal(value) {
|
|
60341
|
+
const state = value.trim().toLowerCase();
|
|
60342
|
+
if (!(state in INTERNAL_FIELDS)) {
|
|
60343
|
+
console.error(
|
|
60344
|
+
chalk34.red(`
|
|
60345
|
+
\u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + chalk34.gray(
|
|
60346
|
+
"\n allowed loopback, 10/172.16/192.168 and CGNAT are reachable (default)\n listed they are reachable only if you allowlist them\n blocked they are blocked at the floor\n"
|
|
60347
|
+
)
|
|
60348
|
+
);
|
|
60349
|
+
process.exitCode = 1;
|
|
60350
|
+
return;
|
|
60351
|
+
}
|
|
60352
|
+
if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
|
|
60353
|
+
_resetConfigCache();
|
|
60354
|
+
const effective = readInternalState(getConfig().policy.egress);
|
|
60355
|
+
if (effective !== state) {
|
|
60356
|
+
console.log(
|
|
60357
|
+
chalk34.yellow(
|
|
60358
|
+
`
|
|
60359
|
+
\u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
|
|
60360
|
+
Change it in the dashboard, Enforcement \u2192 Network.
|
|
60361
|
+
`
|
|
60362
|
+
)
|
|
60363
|
+
);
|
|
60364
|
+
return;
|
|
60365
|
+
}
|
|
60366
|
+
const line = `
|
|
60367
|
+
\u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
|
|
60368
|
+
`;
|
|
60369
|
+
console.log(state === "allowed" ? chalk34.yellow(line) : chalk34.green(line));
|
|
60370
|
+
}
|
|
60294
60371
|
function showFloor(e, ssrfStrictSource) {
|
|
60295
|
-
console.log(
|
|
60372
|
+
console.log(
|
|
60373
|
+
chalk34.gray("\n Protected addresses") + chalk34.gray(" \u2014 in shell commands and declared URLs")
|
|
60374
|
+
);
|
|
60296
60375
|
console.log(
|
|
60297
60376
|
chalk34.gray(
|
|
60298
|
-
" always blocked: cloud metadata, link-local, multicast
|
|
60377
|
+
" always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
|
|
60299
60378
|
)
|
|
60300
60379
|
);
|
|
60301
|
-
const
|
|
60380
|
+
const state = readInternalState(e);
|
|
60302
60381
|
const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
|
|
60382
|
+
const STATE_LABEL = {
|
|
60383
|
+
allowed: "allowed",
|
|
60384
|
+
listed: "allowlist only",
|
|
60385
|
+
blocked: "blocked"
|
|
60386
|
+
};
|
|
60303
60387
|
console.log(
|
|
60304
|
-
` Internal addresses: ${
|
|
60305
|
-
strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
|
|
60306
|
-
)
|
|
60388
|
+
` Internal addresses: ${state === "blocked" ? chalk34.green(STATE_LABEL[state]) : chalk34.yellow(STATE_LABEL[state])}` + chalk34.gray(` loopback, 10/172.16/192.168 and CGNAT are ${INTERNAL_SAID[state]}`)
|
|
60307
60389
|
);
|
|
60308
60390
|
console.log(chalk34.gray(` set by: ${by}`));
|
|
60309
60391
|
const exemptions = e.ssrfAllow ?? [];
|
|
60310
60392
|
console.log(chalk34.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
|
|
60311
60393
|
console.log(
|
|
60312
60394
|
chalk34.gray(
|
|
60313
|
-
"
|
|
60395
|
+
" Covered: shell commands, and tools that declare a URL (WebFetch, an MCP\n fetch tool, browser navigate).\n Not covered: an interpreter one-liner (node -e, python3 -c) hides its\n destination inside a program and does not reach this gate.\n CGNAT (100.64/10) is NOT in the always-blocked set: it is reachable\n until Internal addresses is on, and an exemption can release it."
|
|
60314
60396
|
)
|
|
60315
60397
|
);
|
|
60316
60398
|
}
|
|
@@ -60375,7 +60457,10 @@ function registerEgressCommand(program2) {
|
|
|
60375
60457
|
chalk34.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
|
|
60376
60458
|
);
|
|
60377
60459
|
});
|
|
60378
|
-
egress.command("
|
|
60460
|
+
egress.command("internal <allowed|listed|blocked>").description(
|
|
60461
|
+
"How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
|
|
60462
|
+
).action((value) => setInternal(value));
|
|
60463
|
+
egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
|
|
60379
60464
|
const v = value.trim().toLowerCase();
|
|
60380
60465
|
if (v !== "on" && v !== "off") {
|
|
60381
60466
|
console.error(chalk34.red(`
|
|
@@ -60384,38 +60469,40 @@ function registerEgressCommand(program2) {
|
|
|
60384
60469
|
process.exitCode = 1;
|
|
60385
60470
|
return;
|
|
60386
60471
|
}
|
|
60387
|
-
|
|
60388
|
-
|
|
60389
|
-
|
|
60390
|
-
|
|
60391
|
-
|
|
60392
|
-
|
|
60393
|
-
|
|
60394
|
-
|
|
60395
|
-
|
|
60396
|
-
|
|
60472
|
+
const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
|
|
60473
|
+
console.log(chalk34.gray(`
|
|
60474
|
+
(\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
|
|
60475
|
+
setInternal(state);
|
|
60476
|
+
});
|
|
60477
|
+
egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
|
|
60478
|
+
const a = normalizeEgressHost(address);
|
|
60479
|
+
const slash = a.indexOf("/");
|
|
60480
|
+
const base = slash === -1 ? a : a.slice(0, slash);
|
|
60481
|
+
const prefixText = slash === -1 ? null : a.slice(slash + 1);
|
|
60482
|
+
if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
|
|
60483
|
+
console.error(
|
|
60484
|
+
chalk34.red(`
|
|
60485
|
+
\u2717 "${address}" is not an address or a range.`) + chalk34.gray(
|
|
60486
|
+
"\n Exemptions are matched as an address (10.0.0.5) or a CIDR\n range (100.64.0.0/10), never a name.\n"
|
|
60397
60487
|
)
|
|
60398
60488
|
);
|
|
60489
|
+
process.exitCode = 1;
|
|
60399
60490
|
return;
|
|
60400
60491
|
}
|
|
60401
|
-
|
|
60402
|
-
|
|
60403
|
-
);
|
|
60404
|
-
});
|
|
60405
|
-
egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
|
|
60406
|
-
const a = normalizeEgressHost(address);
|
|
60407
|
-
if (!normalizeIpLiteral(a)) {
|
|
60492
|
+
const m = classifySsrf(base);
|
|
60493
|
+
if (m && !m.overridable) {
|
|
60408
60494
|
console.error(
|
|
60409
60495
|
chalk34.red(`
|
|
60410
|
-
\u2717
|
|
60411
|
-
|
|
60496
|
+
\u2717 ${a} cannot be exempted.`) + chalk34.gray(
|
|
60497
|
+
`
|
|
60498
|
+
${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
|
|
60499
|
+
`
|
|
60412
60500
|
)
|
|
60413
60501
|
);
|
|
60414
60502
|
process.exitCode = 1;
|
|
60415
60503
|
return;
|
|
60416
60504
|
}
|
|
60417
60505
|
if (!exempt(a)) return;
|
|
60418
|
-
const m = classifySsrf(a);
|
|
60419
60506
|
console.log(chalk34.green(`
|
|
60420
60507
|
\u2713 ${a} is exempt from the floor.`));
|
|
60421
60508
|
if (!m)
|
package/dist/dashboard.mjs
CHANGED
|
@@ -4717,10 +4717,12 @@ import os7 from "os";
|
|
|
4717
4717
|
function sanitizeSsrfAllow(entries, source) {
|
|
4718
4718
|
const kept = [];
|
|
4719
4719
|
for (const entry of entries) {
|
|
4720
|
-
const
|
|
4720
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
4721
|
+
const m = classifySsrf(base);
|
|
4721
4722
|
if (m && !m.overridable) {
|
|
4723
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
4722
4724
|
process.emitWarning(
|
|
4723
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
4725
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
4724
4726
|
);
|
|
4725
4727
|
continue;
|
|
4726
4728
|
}
|
package/dist/index.js
CHANGED
|
@@ -3630,6 +3630,38 @@ function classifySsrf(host) {
|
|
|
3630
3630
|
return null;
|
|
3631
3631
|
}
|
|
3632
3632
|
}
|
|
3633
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3634
|
+
if (!entries?.length || !normalized) return false;
|
|
3635
|
+
const target = bitsOf(normalized);
|
|
3636
|
+
for (const raw of entries) {
|
|
3637
|
+
const entry = raw.trim().toLowerCase();
|
|
3638
|
+
if (!entry) continue;
|
|
3639
|
+
const slash = entry.indexOf("/");
|
|
3640
|
+
if (slash === -1) {
|
|
3641
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3642
|
+
continue;
|
|
3643
|
+
}
|
|
3644
|
+
if (!target) continue;
|
|
3645
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3646
|
+
const prefixText = entry.slice(slash + 1);
|
|
3647
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3648
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3649
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3650
|
+
base.length !== target.length || prefix > base.length) {
|
|
3651
|
+
continue;
|
|
3652
|
+
}
|
|
3653
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3654
|
+
}
|
|
3655
|
+
return false;
|
|
3656
|
+
}
|
|
3657
|
+
function bitsOf(normalized) {
|
|
3658
|
+
const o = v4Octets(normalized);
|
|
3659
|
+
if (o) {
|
|
3660
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3661
|
+
}
|
|
3662
|
+
const g = expandIpv6(normalized);
|
|
3663
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3664
|
+
}
|
|
3633
3665
|
var TIER_REASON = {
|
|
3634
3666
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3635
3667
|
"link-local": "a link-local address",
|
|
@@ -3639,14 +3671,11 @@ var TIER_REASON = {
|
|
|
3639
3671
|
private: "a loopback or private address"
|
|
3640
3672
|
};
|
|
3641
3673
|
function ssrfFloor(tokens, opts = {}) {
|
|
3642
|
-
const exempt = new Set(
|
|
3643
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3644
|
-
);
|
|
3645
3674
|
for (const { token, binary } of tokens) {
|
|
3646
3675
|
const m = classifySsrf(token);
|
|
3647
3676
|
if (!m) continue;
|
|
3648
3677
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3649
|
-
if (m.overridable &&
|
|
3678
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3650
3679
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3651
3680
|
}
|
|
3652
3681
|
return null;
|
|
@@ -4109,7 +4138,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4109
4138
|
try {
|
|
4110
4139
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4111
4140
|
if (!paths) return null;
|
|
4112
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
4113
4141
|
for (const path16 of paths) {
|
|
4114
4142
|
for (const value of valuesAt(args, path16)) {
|
|
4115
4143
|
const host = hostOf(value);
|
|
@@ -4117,7 +4145,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4117
4145
|
const m = classifySsrf(host);
|
|
4118
4146
|
if (!m) continue;
|
|
4119
4147
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4120
|
-
if (m.overridable &&
|
|
4148
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
4121
4149
|
return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
|
|
4122
4150
|
}
|
|
4123
4151
|
}
|
|
@@ -5950,10 +5978,12 @@ function safeApiUrl(raw, onReject) {
|
|
|
5950
5978
|
function sanitizeSsrfAllow(entries, source) {
|
|
5951
5979
|
const kept = [];
|
|
5952
5980
|
for (const entry of entries) {
|
|
5953
|
-
const
|
|
5981
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
5982
|
+
const m = classifySsrf(base);
|
|
5954
5983
|
if (m && !m.overridable) {
|
|
5984
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
5955
5985
|
process.emitWarning(
|
|
5956
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
5986
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
5957
5987
|
);
|
|
5958
5988
|
continue;
|
|
5959
5989
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -3600,6 +3600,38 @@ function classifySsrf(host) {
|
|
|
3600
3600
|
return null;
|
|
3601
3601
|
}
|
|
3602
3602
|
}
|
|
3603
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3604
|
+
if (!entries?.length || !normalized) return false;
|
|
3605
|
+
const target = bitsOf(normalized);
|
|
3606
|
+
for (const raw of entries) {
|
|
3607
|
+
const entry = raw.trim().toLowerCase();
|
|
3608
|
+
if (!entry) continue;
|
|
3609
|
+
const slash = entry.indexOf("/");
|
|
3610
|
+
if (slash === -1) {
|
|
3611
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3612
|
+
continue;
|
|
3613
|
+
}
|
|
3614
|
+
if (!target) continue;
|
|
3615
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3616
|
+
const prefixText = entry.slice(slash + 1);
|
|
3617
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3618
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3619
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3620
|
+
base.length !== target.length || prefix > base.length) {
|
|
3621
|
+
continue;
|
|
3622
|
+
}
|
|
3623
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3624
|
+
}
|
|
3625
|
+
return false;
|
|
3626
|
+
}
|
|
3627
|
+
function bitsOf(normalized) {
|
|
3628
|
+
const o = v4Octets(normalized);
|
|
3629
|
+
if (o) {
|
|
3630
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3631
|
+
}
|
|
3632
|
+
const g = expandIpv6(normalized);
|
|
3633
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3634
|
+
}
|
|
3603
3635
|
var TIER_REASON = {
|
|
3604
3636
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3605
3637
|
"link-local": "a link-local address",
|
|
@@ -3609,14 +3641,11 @@ var TIER_REASON = {
|
|
|
3609
3641
|
private: "a loopback or private address"
|
|
3610
3642
|
};
|
|
3611
3643
|
function ssrfFloor(tokens, opts = {}) {
|
|
3612
|
-
const exempt = new Set(
|
|
3613
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3614
|
-
);
|
|
3615
3644
|
for (const { token, binary } of tokens) {
|
|
3616
3645
|
const m = classifySsrf(token);
|
|
3617
3646
|
if (!m) continue;
|
|
3618
3647
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3619
|
-
if (m.overridable &&
|
|
3648
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3620
3649
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3621
3650
|
}
|
|
3622
3651
|
return null;
|
|
@@ -4079,7 +4108,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4079
4108
|
try {
|
|
4080
4109
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4081
4110
|
if (!paths) return null;
|
|
4082
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
4083
4111
|
for (const path16 of paths) {
|
|
4084
4112
|
for (const value of valuesAt(args, path16)) {
|
|
4085
4113
|
const host = hostOf(value);
|
|
@@ -4087,7 +4115,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4087
4115
|
const m = classifySsrf(host);
|
|
4088
4116
|
if (!m) continue;
|
|
4089
4117
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4090
|
-
if (m.overridable &&
|
|
4118
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
4091
4119
|
return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
|
|
4092
4120
|
}
|
|
4093
4121
|
}
|
|
@@ -5920,10 +5948,12 @@ function safeApiUrl(raw, onReject) {
|
|
|
5920
5948
|
function sanitizeSsrfAllow(entries, source) {
|
|
5921
5949
|
const kept = [];
|
|
5922
5950
|
for (const entry of entries) {
|
|
5923
|
-
const
|
|
5951
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
5952
|
+
const m = classifySsrf(base);
|
|
5924
5953
|
if (m && !m.overridable) {
|
|
5954
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
5925
5955
|
process.emitWarning(
|
|
5926
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
5956
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
5927
5957
|
);
|
|
5928
5958
|
continue;
|
|
5929
5959
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "IAM for your AI agents. Set what Claude Code, Codex, Gemini, Cursor and any MCP server are allowed to do, review risky actions before they run, and keep every action on the record.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|