perimetercli 0.1.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/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/perimeter.js +5 -0
- package/package.json +55 -0
- package/src/audit.js +164 -0
- package/src/catalog.js +335 -0
- package/src/cli.js +469 -0
- package/src/discover.js +262 -0
- package/src/guard.js +159 -0
- package/src/index.js +9 -0
- package/src/report.js +333 -0
- package/src/rules.js +357 -0
- package/src/serve.js +64 -0
- package/src/server.js +194 -0
- package/src/sessions.js +161 -0
- package/src/tokens.js +99 -0
- package/src/util.js +137 -0
- package/src/version.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perimeter Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Perimeter
|
|
4
|
+
|
|
5
|
+
**Security and cost audit for your AI agent toolchain — MCP servers, hooks and skills.**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
perimeter audit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Zero runtime dependencies · Works offline · Open-core (MIT)
|
|
12
|
+
|
|
13
|
+
[](https://nodejs.org)
|
|
14
|
+
[](https://www.npmjs.com/package/perimetercli)
|
|
15
|
+
[](LICENSE)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The problem
|
|
22
|
+
|
|
23
|
+
Teams are wiring AI agents to MCP servers, Claude Code hooks, Cursor rules and
|
|
24
|
+
Codex skills faster than they can secure or afford them. By default that means:
|
|
25
|
+
|
|
26
|
+
- **Tool poisoning** — malicious instructions tucked inside a tool description
|
|
27
|
+
that the agent blindly obeys.
|
|
28
|
+
- **Dangerous capability** — a server that can run shell, write files, read
|
|
29
|
+
credentials, or move money.
|
|
30
|
+
- **Context bloat** — several large servers can eat **30k+ tokens** before the
|
|
31
|
+
agent does anything useful.
|
|
32
|
+
- **Silent drift** — a tool changes since you last looked, and nobody notices.
|
|
33
|
+
|
|
34
|
+
## What Perimeter does
|
|
35
|
+
|
|
36
|
+
One command answers three questions at once:
|
|
37
|
+
|
|
38
|
+
1. **Risk** — what can each server, tool, hook and skill actually *do*?
|
|
39
|
+
2. **Cost** — how much context and money do they burn per load?
|
|
40
|
+
3. **Change** — did a tool quietly change since I last audited?
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
perimeter audit # human-readable verdict + findings
|
|
44
|
+
perimeter audit --enforce # gate CI (exit 1 on a finding)
|
|
45
|
+
perimeter audit --json # machine-readable
|
|
46
|
+
perimeter baseline # record a signature hash for drift
|
|
47
|
+
perimeter cost # token/cost footprint only
|
|
48
|
+
perimeter session # observe what agents actually ran
|
|
49
|
+
perimeter guard --server "npx -y @modelcontextprotocol/server-filesystem"
|
|
50
|
+
perimeter serve # preview the generated report
|
|
51
|
+
perimeter server # self-hosted fleet dashboard (Pro)
|
|
52
|
+
perimeter push <url> # send an audit to a Perimeter server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why it's different
|
|
56
|
+
|
|
57
|
+
| Risk | Perimeter | Most MCP scanners | Enterprise gateways |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| MCP servers | ✅ | ✅ | ✅ |
|
|
60
|
+
| Claude Code hooks / settings | ✅ | ❌ | ⚠️ |
|
|
61
|
+
| Cursor / Codex skills + `AGENTS.md` | ✅ | ❌ | ⚠️ |
|
|
62
|
+
| **Token & cost footprint** | ✅ | ❌ | ⚠️ |
|
|
63
|
+
| **Baseline + drift detection** | ✅ | ⚠️ | ⚠️ |
|
|
64
|
+
| **CI gate (`--enforce`)** | ✅ | ⚠️ | ⚠️ |
|
|
65
|
+
| Local · offline · zero-dep | ✅ | some | ❌ |
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install --global perimetercli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Requires Node 18+. Works on macOS, Linux and Windows.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd my-project
|
|
79
|
+
perimeter audit # scan from the current directory
|
|
80
|
+
perimeter baseline # record a baseline for drift detection
|
|
81
|
+
perimeter audit --enforce --fail-on high
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Here's what an audit of a risky setup looks like:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
Verdict: CRITICAL
|
|
88
|
+
1 critical · 2 high · 5 medium · 4 low
|
|
89
|
+
|
|
90
|
+
[CRITICAL] INJECT-001 · Tool poisoning: likely injection in tool description
|
|
91
|
+
[HIGH] RISK-SHELL · Arbitrary command execution
|
|
92
|
+
[HIGH] RISK-PAYMENT · Payment / financial capability
|
|
93
|
+
[MEDIUM] RISK-FSWRITE · Filesystem write access
|
|
94
|
+
[MEDIUM] SUP-CREDS-ENV · Secrets present in server config env
|
|
95
|
+
|
|
96
|
+
Cost: model claude-sonnet · ~8,652 tokens/load · $0.09/load
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## What it detects (OWASP Agentic-aligned)
|
|
100
|
+
|
|
101
|
+
- **INJECT-001** — tool poisoning / prompt injection in tool metadata
|
|
102
|
+
- **INJECT-002** — suspicious instructions in `AGENTS.md`/`CLAUDE.md`/skills
|
|
103
|
+
- **SECRET-LEAK** — a credential-looking value in an agent context file
|
|
104
|
+
- **HOOK** — risky Claude Code hooks (arbitrary code on tool use / session start)
|
|
105
|
+
- **RISK-SHELL / RISK-EXFIL / RISK-PAYMENT / RISK-CREDS / RISK-CLOUD**
|
|
106
|
+
- **RISK-FSWRITE** — filesystem writes
|
|
107
|
+
- **SUP-TYPOSQUAT** — package names one edit from a well-known server
|
|
108
|
+
- **SUP-UNVERIFIED / SUP-UNPINNED / SUP-CREDS-ENV** — supply-chain concerns
|
|
109
|
+
- **TOK-BLOAT** — a server whose tool schemas eat too much context
|
|
110
|
+
- **DRIFT-NEW / DRIFT-CHANGED** — new or mutated servers vs. your baseline
|
|
111
|
+
|
|
112
|
+
`perimeter session` goes further: it reads your actual agent session logs and
|
|
113
|
+
reports what was *really* called (Bash `×3`, Write `×1`…), with exact token
|
|
114
|
+
spend when the log records it.
|
|
115
|
+
|
|
116
|
+
## Runtime guard
|
|
117
|
+
|
|
118
|
+
`perimeter guard` proxies a real MCP server and **blocks risky tool calls at
|
|
119
|
+
runtime** based on a fail-closed policy. Point your agent at Perimeter instead of
|
|
120
|
+
the server directly:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"filesystem": {
|
|
126
|
+
"command": "perimeter",
|
|
127
|
+
"args": [
|
|
128
|
+
"guard",
|
|
129
|
+
"--server",
|
|
130
|
+
"npx -y @modelcontextprotocol/server-filesystem",
|
|
131
|
+
"--allow",
|
|
132
|
+
"Read,Glob,List"
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every call is logged to `.perimeter/guard.log.jsonl` for review.
|
|
140
|
+
|
|
141
|
+
## Hosted dashboard (self-host)
|
|
142
|
+
|
|
143
|
+
For a team, run a local dashboard and push audits to it — a self-hosted version of
|
|
144
|
+
the Pro continuous-audit loop:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
perimeter server --port 4173
|
|
148
|
+
perimeter audit --json | perimeter push http://localhost:4173 --project acme
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Open `http://localhost:4173` to see verdicts, findings and cost for every project
|
|
152
|
+
you push.
|
|
153
|
+
|
|
154
|
+
## CI
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
- run: npm install --global perimetercli
|
|
158
|
+
- run: perimeter baseline
|
|
159
|
+
- run: perimeter audit --enforce --fail-on high --out perimeter-report
|
|
160
|
+
- uses: actions/upload-artifact@v4
|
|
161
|
+
with: { path: perimeter-report/ }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Perimeter emits **SARIF** too, so findings land directly in GitHub code scanning.
|
|
165
|
+
|
|
166
|
+
## Configuration
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
perimeter init
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Creates `.perimeter/config.json`:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"failOn": "critical",
|
|
177
|
+
"model": "claude-sonnet",
|
|
178
|
+
"budget": 1,
|
|
179
|
+
"maxTokensPerServer": 5000,
|
|
180
|
+
"block": ["@modelcontextprotocol/server-everything"],
|
|
181
|
+
"allow": [],
|
|
182
|
+
"ignoredRules": []
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Pro (open-core)
|
|
187
|
+
|
|
188
|
+
The CLI is free and MIT. Perimeter Pro adds team policy, hosted dashboards,
|
|
189
|
+
remote-server analysis and drift alerts — see [`PRICING.md`](PRICING.md) and
|
|
190
|
+
[`docs/pro.md`](docs/pro.md).
|
|
191
|
+
|
|
192
|
+
## Contributing
|
|
193
|
+
|
|
194
|
+
Read [`CONTRIBUTING.md`](CONTRIBUTING.md), then:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
npm install
|
|
198
|
+
npm test
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
[MIT](LICENSE)
|
package/bin/perimeter.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "perimetercli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Security and cost audit for your AI agent toolchain — MCP servers, hooks, skills. One command finds poisoning, dangerous capabilities, context bloat and drift, and gates CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"perimeter": "./bin/perimeter.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./cli": "./src/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"start": "node bin/perimeter.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"security",
|
|
29
|
+
"mcp",
|
|
30
|
+
"model-context-protocol",
|
|
31
|
+
"ai-agents",
|
|
32
|
+
"supply-chain",
|
|
33
|
+
"agent-security",
|
|
34
|
+
"tool-poisoning",
|
|
35
|
+
"prompt-injection",
|
|
36
|
+
"sbom",
|
|
37
|
+
"cost",
|
|
38
|
+
"tokens",
|
|
39
|
+
"devsecops",
|
|
40
|
+
"cli"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"author": {
|
|
44
|
+
"name": "Perimeter Contributors",
|
|
45
|
+
"email": "hello@perimetercli.dev"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://perimetercli.dev",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/perimetercli/perimeter.git"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/perimetercli/perimeter/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { discover } from "./discover.js";
|
|
4
|
+
import { runRules, runContextRules, overallVerdict, verdictOf } from "./rules.js";
|
|
5
|
+
import { estimateCost, tokensForTools, resolveModel } from "./tokens.js";
|
|
6
|
+
import { readJson, writeJson, sha256, isoNow } from "./util.js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_POLICY = {
|
|
9
|
+
failOn: "critical",
|
|
10
|
+
model: "claude-sonnet",
|
|
11
|
+
budget: null,
|
|
12
|
+
maxTokensPerServer: 5000,
|
|
13
|
+
allow: [],
|
|
14
|
+
block: [],
|
|
15
|
+
ignoredRules: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function normalizeConfig(filePath, cliOpts) {
|
|
19
|
+
const onDisk = readJson(filePath) || {};
|
|
20
|
+
const merged = { ...DEFAULT_POLICY, ...onDisk, ...cliOpts };
|
|
21
|
+
return merged;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function baselinePath(cwd) {
|
|
25
|
+
return path.join(cwd, ".perimeter", "baseline.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function policyPath(cwd) {
|
|
29
|
+
return path.join(cwd, ".perimeter", "config.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeBaseline(cwd, servers) {
|
|
33
|
+
const map = {};
|
|
34
|
+
for (const s of servers) {
|
|
35
|
+
map[s.id] = {
|
|
36
|
+
displayName: s.displayName,
|
|
37
|
+
source: s.source,
|
|
38
|
+
signature: s.signature,
|
|
39
|
+
packageName: s.packageName,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
version: 1,
|
|
44
|
+
created: isoNow(),
|
|
45
|
+
servers: map,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeBaseline(cwd, servers) {
|
|
50
|
+
const baseline = makeBaseline(cwd, servers);
|
|
51
|
+
writeJson(baselinePath(cwd), baseline);
|
|
52
|
+
return baseline;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadBaseline(cwd) {
|
|
56
|
+
return readJson(baselinePath(cwd));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run a full audit.
|
|
61
|
+
* opts: { cwd, configPath, model, budget, failOn, useBaseline, home, recursive }
|
|
62
|
+
*/
|
|
63
|
+
export async function runAudit(opts = {}) {
|
|
64
|
+
const cwd = opts.cwd || process.cwd();
|
|
65
|
+
const config = normalizeConfig(
|
|
66
|
+
opts.configPath || policyPath(cwd),
|
|
67
|
+
{
|
|
68
|
+
model: opts.model,
|
|
69
|
+
budget: opts.budget ?? undefined,
|
|
70
|
+
failOn: opts.failOn,
|
|
71
|
+
maxTokensPerServer: opts.maxTokensPerServer,
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
const { servers, hooks, contextFiles, errors, files } = discover(cwd, {
|
|
75
|
+
home: opts.home,
|
|
76
|
+
recursive: opts.recursive,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const baseline = opts.useBaseline !== false ? loadBaseline(cwd) : null;
|
|
80
|
+
const allServers = [...servers, ...hooks];
|
|
81
|
+
const serverResults = allServers.map((server) => {
|
|
82
|
+
const result = runRules(server, {
|
|
83
|
+
config,
|
|
84
|
+
baseline,
|
|
85
|
+
cost: config.model,
|
|
86
|
+
});
|
|
87
|
+
server.verdict = result.verdict;
|
|
88
|
+
return { server, ...result };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Instruction / skill files — the context an agent reads every session.
|
|
92
|
+
const contextFindings = contextFiles
|
|
93
|
+
.filter((c) => c.content)
|
|
94
|
+
.flatMap((c) => runContextRules(c));
|
|
95
|
+
|
|
96
|
+
// Cost summary across all servers.
|
|
97
|
+
let totalTokens = 0;
|
|
98
|
+
for (const { server } of serverResults) {
|
|
99
|
+
totalTokens += server.estimatedTokens || 0;
|
|
100
|
+
}
|
|
101
|
+
const cost = estimateCost(totalTokens, config.model);
|
|
102
|
+
const budget = config.budget ? Number(config.budget) : null;
|
|
103
|
+
const costs = {
|
|
104
|
+
model: cost.model,
|
|
105
|
+
inputRate: cost.inputRate,
|
|
106
|
+
outputRate: cost.outputRate,
|
|
107
|
+
estimated: cost.estimated,
|
|
108
|
+
totalTokens,
|
|
109
|
+
perLoadCost: cost.perLoad,
|
|
110
|
+
budget,
|
|
111
|
+
loadsPerDayWithinBudget:
|
|
112
|
+
budget && cost.perLoad > 0 ? Math.floor(budget / cost.perLoad) : null,
|
|
113
|
+
overBudget: budget != null && cost.perLoad > budget,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const verdict = overallVerdict(serverResults);
|
|
117
|
+
const findings = [...serverResults.flatMap((r) => r.findings), ...contextFindings];
|
|
118
|
+
const blocked = findings.filter((f) =>
|
|
119
|
+
config.block.some((b) => b && (f.packageName || "").toLowerCase().includes(b.toLowerCase()))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Apply allow/block/ignore filtering for the effective verdict.
|
|
123
|
+
const effectiveFindings = findings.filter(
|
|
124
|
+
(f) => !config.ignoredRules.includes(f.id) && !blocked.includes(f)
|
|
125
|
+
);
|
|
126
|
+
const effectiveVerdict = verdictOf(effectiveFindings);
|
|
127
|
+
|
|
128
|
+
const model = resolveModel(config.model);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
generatedAt: isoNow(),
|
|
132
|
+
cwd,
|
|
133
|
+
config: {
|
|
134
|
+
failOn: config.failOn,
|
|
135
|
+
model: config.model,
|
|
136
|
+
budget: config.budget,
|
|
137
|
+
maxTokensPerServer: config.maxTokensPerServer,
|
|
138
|
+
},
|
|
139
|
+
servers: serverResults.map((r) => ({
|
|
140
|
+
id: r.server.id,
|
|
141
|
+
displayName: r.server.displayName,
|
|
142
|
+
source: r.server.source,
|
|
143
|
+
transport: r.server.transport,
|
|
144
|
+
packageName: r.server.packageName,
|
|
145
|
+
command: r.server.command,
|
|
146
|
+
url: r.server.url,
|
|
147
|
+
capabilities: r.capabilities,
|
|
148
|
+
verdict: r.verdict,
|
|
149
|
+
estimatedTokens: r.server.estimatedTokens,
|
|
150
|
+
catalogName: r.server.catalog?.name || null,
|
|
151
|
+
findings: r.findings,
|
|
152
|
+
})),
|
|
153
|
+
findings,
|
|
154
|
+
contextFindings,
|
|
155
|
+
effectiveFindings,
|
|
156
|
+
verdict,
|
|
157
|
+
effectiveVerdict,
|
|
158
|
+
counts: verdict.counts,
|
|
159
|
+
costs,
|
|
160
|
+
contextFiles: contextFiles.map((c) => ({ file: c.file, kind: c.kind, label: c.label })),
|
|
161
|
+
errors,
|
|
162
|
+
files,
|
|
163
|
+
};
|
|
164
|
+
}
|