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/src/discover.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { readIfExists, readJson, sha256, walkFiles } from "./util.js";
|
|
5
|
+
import { catalogFor } from "./catalog.js";
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
|
|
9
|
+
const MCP_FILE_NAMES = [
|
|
10
|
+
".mcp.json",
|
|
11
|
+
".cursor/mcp.json",
|
|
12
|
+
".claude/settings.json",
|
|
13
|
+
"claude_desktop_config.json",
|
|
14
|
+
".mcp/servers.json",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const CONTEXT_FILE_NAMES = [
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"CLAUDE.md",
|
|
20
|
+
".cursorrules",
|
|
21
|
+
"GEMINI.md",
|
|
22
|
+
"CONTEXT.md",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const SKILL_DIRS = [".claude/skills", ".cursor/skills", ".codex/skills"];
|
|
26
|
+
|
|
27
|
+
function extractMcpServers(json) {
|
|
28
|
+
if (!json || typeof json !== "object") return {};
|
|
29
|
+
return json.mcpServers || json.servers || json.mcp || {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readServersFrom(json, label) {
|
|
33
|
+
const map = extractMcpServers(json);
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
36
|
+
if (!entry || typeof entry !== "object") continue;
|
|
37
|
+
out.push(makeServer(name, entry, label));
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeServer(name, entry, source) {
|
|
43
|
+
const command = entry.command || null;
|
|
44
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
45
|
+
const env = entry.env && typeof entry.env === "object" ? entry.env : {};
|
|
46
|
+
const transport =
|
|
47
|
+
entry.type || (entry.url ? "http" : entry.command ? "stdio" : "unknown");
|
|
48
|
+
const url = entry.url || null;
|
|
49
|
+
const packageName = extractPackage(command, args);
|
|
50
|
+
const tools = normalizeTools(entry.tools);
|
|
51
|
+
const catalog = catalogFor(packageName) || catalogFor(name);
|
|
52
|
+
const contentForSig = [
|
|
53
|
+
name,
|
|
54
|
+
command,
|
|
55
|
+
args.join(" "),
|
|
56
|
+
JSON.stringify(env),
|
|
57
|
+
tools.map((t) => `${t.name}:${t.description}`).join("|"),
|
|
58
|
+
].join("\u0000");
|
|
59
|
+
return {
|
|
60
|
+
id: sha256(`${source}\u0000${name}`).slice(0, 16),
|
|
61
|
+
kind: "mcp-server",
|
|
62
|
+
displayName: name,
|
|
63
|
+
source,
|
|
64
|
+
transport,
|
|
65
|
+
command,
|
|
66
|
+
args,
|
|
67
|
+
env,
|
|
68
|
+
url,
|
|
69
|
+
packageName,
|
|
70
|
+
tools,
|
|
71
|
+
catalog,
|
|
72
|
+
signature: sha256(contentForSig),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeTools(tools) {
|
|
77
|
+
if (!Array.isArray(tools)) return [];
|
|
78
|
+
return tools.map((t) => ({
|
|
79
|
+
name: t.name || t.title || "tool",
|
|
80
|
+
description: t.description || t.title || "",
|
|
81
|
+
inputSchema: t.inputSchema || t.input_schema || t.schema || {},
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractPackage(command, args) {
|
|
86
|
+
if (!command) return null;
|
|
87
|
+
const commands = [
|
|
88
|
+
"npx", "npm", "pnpm", "yarn", "bunx", "uvx", "pipx", "deno",
|
|
89
|
+
"node", "python", "python3", "poetry", "go", "cargo", "pip",
|
|
90
|
+
];
|
|
91
|
+
const normalized = command.toLowerCase();
|
|
92
|
+
if (commands.includes(normalized)) {
|
|
93
|
+
for (const a of args) {
|
|
94
|
+
if (a.startsWith("-")) continue;
|
|
95
|
+
if (/^[@A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/.test(a) || /^[A-Za-z0-9_.-]+$/.test(a)) {
|
|
96
|
+
return a.replace(/^--/, "");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return normalized === "python" || normalized === "node" ? null : command;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse Claude Code hooks (and similar) into server-like action objects. */
|
|
105
|
+
function parseHooks(json, label) {
|
|
106
|
+
const hooks = json?.hooks;
|
|
107
|
+
if (!hooks || typeof hooks !== "object") return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const [event, defs] of Object.entries(hooks)) {
|
|
110
|
+
if (!Array.isArray(defs)) continue;
|
|
111
|
+
for (const def of defs) {
|
|
112
|
+
const matcher = def.matcher || "any";
|
|
113
|
+
const entries = Array.isArray(def.hooks) ? def.hooks : [];
|
|
114
|
+
for (const h of entries) {
|
|
115
|
+
const command = h?.command || "";
|
|
116
|
+
if (!command) continue;
|
|
117
|
+
const name = `hook:${event}/${matcher}`;
|
|
118
|
+
const buildId = [label, event, matcher, command].join("\u0000");
|
|
119
|
+
out.push({
|
|
120
|
+
id: sha256(buildId).slice(0, 16),
|
|
121
|
+
kind: "hook",
|
|
122
|
+
displayName: name,
|
|
123
|
+
source: `${label} · hook`,
|
|
124
|
+
transport: "hook",
|
|
125
|
+
command,
|
|
126
|
+
args: [],
|
|
127
|
+
env: {},
|
|
128
|
+
url: null,
|
|
129
|
+
packageName: extractPackage(command, []),
|
|
130
|
+
tools: [{ name: "hook", description: command }],
|
|
131
|
+
catalog: catalogFor(extractPackage(command, [])),
|
|
132
|
+
signature: sha256(buildId),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectSkills(cwd, { home = true } = {}) {
|
|
141
|
+
const roots = SKILL_DIRS.map((d) => path.join(cwd, d));
|
|
142
|
+
if (home && HOME !== cwd) {
|
|
143
|
+
roots.push(path.join(HOME, ".codex", "skills"));
|
|
144
|
+
roots.push(path.join(HOME, ".claude", "skills"));
|
|
145
|
+
}
|
|
146
|
+
const files = [];
|
|
147
|
+
for (const root of roots) {
|
|
148
|
+
if (!fs.existsSync(root)) continue;
|
|
149
|
+
for (const f of walkFiles(root, [])) {
|
|
150
|
+
if (f.endsWith(".md")) files.push(f);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return files.slice(0, 200);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Discover agent/MCP configs, hooks, instruction files and skills.
|
|
158
|
+
*/
|
|
159
|
+
export function discover(cwd = process.cwd(), opts = {}) {
|
|
160
|
+
const servers = [];
|
|
161
|
+
const hooks = [];
|
|
162
|
+
const contextFiles = [];
|
|
163
|
+
const errors = [];
|
|
164
|
+
const files = [];
|
|
165
|
+
const includeHome = opts.home !== false;
|
|
166
|
+
|
|
167
|
+
for (const rel of MCP_FILE_NAMES) {
|
|
168
|
+
const file = path.join(cwd, rel);
|
|
169
|
+
if (!fs.existsSync(file)) continue;
|
|
170
|
+
files.push(file);
|
|
171
|
+
const json = readJson(file);
|
|
172
|
+
if (!json) {
|
|
173
|
+
errors.push({ file, message: "Could not parse JSON" });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const label = relLabel(rel);
|
|
177
|
+
servers.push(...readServersFrom(json, label));
|
|
178
|
+
hooks.push(...parseHooks(json, label));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (opts.recursive) {
|
|
182
|
+
for (const file of walkFiles(cwd, [".git", "node_modules"])) {
|
|
183
|
+
if (!file.endsWith("mcp.json") && !file.endsWith("claude_desktop_config.json")) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (file.includes(path.join("node_modules"))) continue;
|
|
187
|
+
if (!files.includes(file)) {
|
|
188
|
+
files.push(file);
|
|
189
|
+
const json = readJson(file);
|
|
190
|
+
if (json) servers.push(...readServersFrom(json, path.relative(cwd, file)));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (includeHome && HOME !== cwd) {
|
|
196
|
+
const homeCandidates = [
|
|
197
|
+
[path.join(HOME, ".claude.json"), ".claude.json"],
|
|
198
|
+
[path.join(HOME, ".claude", "settings.json"), ".claude/settings.json"],
|
|
199
|
+
[path.join(HOME, ".cursor", "mcp.json"), ".cursor/mcp.json"],
|
|
200
|
+
];
|
|
201
|
+
for (const [file, label] of homeCandidates) {
|
|
202
|
+
if (!fs.existsSync(file)) continue;
|
|
203
|
+
const json = readJson(file);
|
|
204
|
+
if (!json) continue;
|
|
205
|
+
files.push(file);
|
|
206
|
+
servers.push(...readServersFrom(json, label));
|
|
207
|
+
hooks.push(...parseHooks(json, label));
|
|
208
|
+
}
|
|
209
|
+
const codex = path.join(HOME, ".codex");
|
|
210
|
+
if (fs.existsSync(codex)) files.push(codex);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const rel of CONTEXT_FILE_NAMES) {
|
|
214
|
+
const file = path.join(cwd, rel);
|
|
215
|
+
if (!fs.existsSync(file)) continue;
|
|
216
|
+
files.push(file);
|
|
217
|
+
contextFiles.push({
|
|
218
|
+
file,
|
|
219
|
+
kind: "instructions",
|
|
220
|
+
label: rel,
|
|
221
|
+
content: readIfExists(file) || "",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const file of collectSkills(cwd, { home: includeHome })) {
|
|
226
|
+
files.push(file);
|
|
227
|
+
contextFiles.push({
|
|
228
|
+
file,
|
|
229
|
+
kind: "skill",
|
|
230
|
+
label: path.relative(cwd, file),
|
|
231
|
+
content: readIfExists(file) || "",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
const dedupe = (arr) =>
|
|
237
|
+
arr.filter((s) => {
|
|
238
|
+
const key = `${s.source}:${s.displayName}`;
|
|
239
|
+
if (seen.has(key)) return false;
|
|
240
|
+
seen.add(key);
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
servers: dedupe(servers),
|
|
246
|
+
hooks: dedupe(hooks),
|
|
247
|
+
contextFiles,
|
|
248
|
+
errors,
|
|
249
|
+
files,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function relLabel(rel) {
|
|
254
|
+
const base = path.basename(rel);
|
|
255
|
+
const dir = path.dirname(rel);
|
|
256
|
+
return dir && dir !== "." ? `${dir}/${base}` : base;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Read the configured policy, if any. */
|
|
260
|
+
export function loadPolicy(cwd) {
|
|
261
|
+
return readJson(path.join(cwd, ".perimeter", "config.json"));
|
|
262
|
+
}
|
package/src/guard.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// Claude Code / common agent tool name → capability.
|
|
7
|
+
const CAPABILITY_BY_TOOL = {
|
|
8
|
+
Bash: "shell",
|
|
9
|
+
Write: "filesystem-write",
|
|
10
|
+
Edit: "filesystem-write",
|
|
11
|
+
MultiEdit: "filesystem-write",
|
|
12
|
+
NotebookEdit: "code-exec",
|
|
13
|
+
Task: "subagent",
|
|
14
|
+
WebFetch: "network-in",
|
|
15
|
+
WebSearch: "network-in",
|
|
16
|
+
Grep: "search",
|
|
17
|
+
Read: "filesystem-read",
|
|
18
|
+
Glob: "filesystem-read",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function describeTool(name) {
|
|
22
|
+
return { capability: CAPABILITY_BY_TOOL[name] || "unrecognised" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultPolicy() {
|
|
26
|
+
return {
|
|
27
|
+
allowTools: [],
|
|
28
|
+
denyTools: [],
|
|
29
|
+
allowCapabilities: [],
|
|
30
|
+
denyCapabilities: ["shell", "code-exec", "filesystem-write"],
|
|
31
|
+
log: ".perimeter/guard.log.jsonl",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Decide whether a tool call should be blocked. Fail-closed by default. */
|
|
36
|
+
export function shouldBlock(tool, policy) {
|
|
37
|
+
const p = { ...defaultPolicy(), ...policy };
|
|
38
|
+
if (!tool) return true;
|
|
39
|
+
if (p.allowTools.includes(tool)) return false;
|
|
40
|
+
if (p.denyTools.includes(tool)) return true;
|
|
41
|
+
const cap = CAPABILITY_BY_TOOL[tool];
|
|
42
|
+
if (!cap) return p.denyTools.includes(tool); // unknown tools are allowed only if explicitly denied
|
|
43
|
+
if (p.allowCapabilities.includes(cap)) return false;
|
|
44
|
+
return p.denyCapabilities.includes(cap);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openLog(logPath) {
|
|
48
|
+
if (!logPath) return { write: () => {} };
|
|
49
|
+
const dir = path.dirname(logPath);
|
|
50
|
+
if (dir) fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
return fs.createWriteStream(logPath, { flags: "a" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseCommand(command) {
|
|
55
|
+
const matches = (command || "").match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
56
|
+
return matches.map((s) => s.replace(/^"|"$/g, ""));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A controllable MCP guard. It spawns the real MCP server as a child and proxies
|
|
61
|
+
* JSON-RPC over stdio, intercepting blocked `tools/call` requests.
|
|
62
|
+
*/
|
|
63
|
+
export function createGuard({ command, policy = {} }) {
|
|
64
|
+
const parts = parseCommand(command);
|
|
65
|
+
if (!parts.length) throw new Error("guard requires --server <command>");
|
|
66
|
+
const child = spawn(parts[0], parts.slice(1), {
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
const mergedPolicy = { ...defaultPolicy(), ...policy };
|
|
70
|
+
const log = openLog(mergedPolicy.log);
|
|
71
|
+
const watchers = [];
|
|
72
|
+
|
|
73
|
+
const rl = createInterface({ input: child.stdout });
|
|
74
|
+
rl.on("line", (line) => {
|
|
75
|
+
for (const watcher of watchers) watcher(line);
|
|
76
|
+
});
|
|
77
|
+
child.stderr.on("data", (chunk) => {
|
|
78
|
+
process.stderr.write(chunk);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
child,
|
|
83
|
+
policy: mergedPolicy,
|
|
84
|
+
send(line) {
|
|
85
|
+
let msg;
|
|
86
|
+
try {
|
|
87
|
+
msg = JSON.parse(line);
|
|
88
|
+
} catch {
|
|
89
|
+
child.stdin.write(line + "\n");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (msg && msg.method === "tools/call" && msg.id !== undefined) {
|
|
93
|
+
const tool = msg.params && msg.params.name;
|
|
94
|
+
const blocked = shouldBlock(tool, mergedPolicy);
|
|
95
|
+
log.write(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
ts: new Date().toISOString(),
|
|
98
|
+
tool,
|
|
99
|
+
method: msg.method,
|
|
100
|
+
allowed: !blocked,
|
|
101
|
+
}) + "\n"
|
|
102
|
+
);
|
|
103
|
+
if (blocked) {
|
|
104
|
+
const response = JSON.stringify({
|
|
105
|
+
jsonrpc: "2.0",
|
|
106
|
+
id: msg.id,
|
|
107
|
+
error: {
|
|
108
|
+
code: -32602,
|
|
109
|
+
message: `Blocked by Perimeter policy: ${tool}`,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
for (const watcher of watchers) watcher(response);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
child.stdin.write(line + "\n");
|
|
117
|
+
},
|
|
118
|
+
onResponse(cb) {
|
|
119
|
+
watchers.push(cb);
|
|
120
|
+
},
|
|
121
|
+
close() {
|
|
122
|
+
try {
|
|
123
|
+
child.stdin.end();
|
|
124
|
+
child.kill();
|
|
125
|
+
} catch {
|
|
126
|
+
/* ignore */
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Command-line entry: wire the guard to this process's stdio and run until the
|
|
134
|
+
* child exits or stdin ends.
|
|
135
|
+
*/
|
|
136
|
+
export function runGuardCli({ command, policy }) {
|
|
137
|
+
const guard = createGuard({ command, policy });
|
|
138
|
+
const rl = createInterface({ input: process.stdin });
|
|
139
|
+
rl.on("line", (line) => {
|
|
140
|
+
if (line.trim()) guard.send(line.trim());
|
|
141
|
+
});
|
|
142
|
+
rl.on("close", () => {
|
|
143
|
+
try {
|
|
144
|
+
process.exit(0);
|
|
145
|
+
} catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
guard.onResponse((line) => process.stdout.write(line + "\n"));
|
|
150
|
+
guard.child.on("exit", (code) => {
|
|
151
|
+
try {
|
|
152
|
+
rl.close();
|
|
153
|
+
process.exit(code ?? 0);
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return guard;
|
|
159
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { runAudit } from "./audit.js";
|
|
2
|
+
export { discover } from "./discover.js";
|
|
3
|
+
export { catalogFor, KNOWN_SERVERS } from "./catalog.js";
|
|
4
|
+
export { tokensForText, tokensForTools, estimateCost, MODEL_PRICES } from "./tokens.js";
|
|
5
|
+
export { runRules, runContextRules, verdictOf, SEVERITIES } from "./rules.js";
|
|
6
|
+
export { buildSessionReport, parseSessionFile, findSessions } from "./sessions.js";
|
|
7
|
+
export { createGuard, shouldBlock, describeTool, defaultPolicy } from "./guard.js";
|
|
8
|
+
export { createServer as createPerimeterServer, listen as listenPerimeter } from "./server.js";
|
|
9
|
+
export { VERSION } from "./version.js";
|