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 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
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-43853d?logo=node.js&logoColor=white)](https://nodejs.org)
14
+ [![npm](https://img.shields.io/npm/v/perimetercli)](https://www.npmjs.com/package/perimetercli)
15
+ [![license](https://img.shields.io/badge/license-MIT-blue)](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)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.js";
4
+
5
+ main(process.argv.slice(2));
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
+ }