@cirvix_ai/agent-control 0.1.2 → 0.1.5
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 -17
- package/bin/cirvix.mjs +488 -40
- package/bin/escape-benchmark.mjs +67 -0
- package/package.json +36 -16
- package/src/adapters/base.mjs +150 -0
- package/src/adapters/claude-code.mjs +161 -0
- package/src/adapters/cline.mjs +107 -0
- package/src/adapters/codex.mjs +104 -0
- package/src/adapters/cursor.mjs +104 -0
- package/src/adapters/frameworks.mjs +110 -0
- package/src/adapters/gemini-cli.mjs +104 -0
- package/src/adapters/generic-mcp.mjs +101 -0
- package/src/adapters/index.mjs +209 -0
- package/src/adapters/roo-code.mjs +106 -0
- package/src/adapters/vscode.mjs +104 -0
- package/src/adapters/windsurf.mjs +107 -0
- package/src/commands/demo.mjs +56 -70
- package/src/commands/doctor.mjs +235 -0
- package/src/commands/init.mjs +292 -30
- package/src/commands/interactive.mjs +690 -0
- package/src/commands/kill.mjs +74 -0
- package/src/commands/login.mjs +227 -0
- package/src/commands/passport.mjs +149 -0
- package/src/commands/policy.mjs +10 -6
- package/src/commands/protect.mjs +293 -0
- package/src/commands/prove.mjs +209 -0
- package/src/commands/redteam.mjs +51 -0
- package/src/commands/scan.mjs +6 -4
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +121 -36
- package/src/commands/upgrade.mjs +17 -9
- package/src/commands/welcome.mjs +105 -0
- package/src/core/authority.mjs +909 -0
- package/src/core/baseline.mjs +97 -0
- package/src/core/config-store.mjs +280 -0
- package/src/core/cost.mjs +0 -0
- package/src/core/detect.mjs +4 -33
- package/src/core/entitlements.mjs +6 -0
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +27 -0
- package/src/core/gateway.mjs +15 -211
- package/src/core/graph.mjs +270 -0
- package/src/core/guard.mjs +118 -4
- package/src/core/intent.mjs +166 -0
- package/src/core/journal.mjs +131 -40
- package/src/core/kill-switch.mjs +122 -0
- package/src/core/notices.mjs +22 -2
- package/src/core/packs.mjs +193 -0
- package/src/core/passport.mjs +555 -0
- package/src/core/pipeline.mjs +148 -6
- package/src/core/prompts.mjs +51 -0
- package/src/core/proof.mjs +440 -0
- package/src/core/redteam/index.mjs +185 -0
- package/src/core/referral.mjs +187 -0
- package/src/core/sandbox.mjs +139 -0
- package/src/core/session.mjs +172 -0
- package/src/core/shadow.mjs +95 -0
- package/src/core/trifecta.mjs +321 -0
- package/src/core/ui/controller.mjs +192 -0
- package/src/core/ui/decisions.mjs +55 -0
- package/src/core/ui/index.mjs +49 -0
- package/src/core/ui/intercept.mjs +103 -0
- package/src/core/ui/live.mjs +51 -0
- package/src/core/ui/primitives.mjs +123 -0
- package/src/core/ui/theme.mjs +92 -0
- package/src/core/verified.mjs +108 -0
- package/src/core/windows.mjs +270 -0
- package/src/index.mjs +25 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecurityIntercept — visually distinctive blocked-action panel.
|
|
3
|
+
*
|
|
4
|
+
* Animation MUST NOT delay enforcement. Decision is already computed.
|
|
5
|
+
* This only visualizes it with a short 300-800ms sequence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { bold, dim, red, stripAnsi } from "../format.mjs";
|
|
9
|
+
import { shouldAnimate, sleep } from "./controller.mjs";
|
|
10
|
+
import { boxChars, truncate, padVisible } from "./theme.mjs";
|
|
11
|
+
|
|
12
|
+
export function interceptBox(event) {
|
|
13
|
+
const W = 58;
|
|
14
|
+
const ch = boxChars();
|
|
15
|
+
const useAscii = ch.tl === "+";
|
|
16
|
+
// Use red border for intercept.
|
|
17
|
+
const h = useAscii ? ch.h : "═";
|
|
18
|
+
const v = useAscii ? ch.v : "║";
|
|
19
|
+
const tl = useAscii ? ch.tl : "╔";
|
|
20
|
+
const tr = useAscii ? ch.tr : "╗";
|
|
21
|
+
const bl = useAscii ? ch.bl : "╚";
|
|
22
|
+
const br = useAscii ? ch.br : "╝";
|
|
23
|
+
const mj = useAscii ? ch.lt : "╠";
|
|
24
|
+
const mid = mj + h.repeat(W + 2) + (useAscii ? ch.rt : "╣");
|
|
25
|
+
|
|
26
|
+
const rows = [
|
|
27
|
+
["Agent", event.agent ?? "—"],
|
|
28
|
+
["Tool", event.tool ?? event.action ?? "—"],
|
|
29
|
+
["Target", event.resource ?? event.destination ?? "—"],
|
|
30
|
+
["Risk", String(event.risk ?? "—").toUpperCase()],
|
|
31
|
+
["Decision", "BLOCKED"],
|
|
32
|
+
["Policy", event.policy ?? event.rule ?? "default-deny"],
|
|
33
|
+
["Latency", `${event.latency_ms ?? "—"}ms`],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const pad = (text) => {
|
|
37
|
+
const s = String(text);
|
|
38
|
+
const vis = stripAnsi(s).length;
|
|
39
|
+
if (vis > W) return s.slice(0, W - 1) + "…";
|
|
40
|
+
return s + " ".repeat(W - vis);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const lines = [];
|
|
44
|
+
lines.push(` ${red(tl + h.repeat(W + 2) + tr)}`);
|
|
45
|
+
lines.push(` ${red(v)} ${bold(pad("CIRVIX SECURITY INTERCEPT"))} ${red(v)}`);
|
|
46
|
+
lines.push(` ${red(mid)}`);
|
|
47
|
+
for (const [k, val] of rows) {
|
|
48
|
+
const body = `${k}:`.padEnd(11) + String(val);
|
|
49
|
+
const painted = k === "Risk" || k === "Decision" ? red(pad(body)) : pad(body);
|
|
50
|
+
lines.push(` ${red(v)} ${painted} ${red(v)}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push(` ${red(bl + h.repeat(W + 2) + br)}`);
|
|
53
|
+
|
|
54
|
+
const reason = event.reason ? `\n ${dim(truncate(event.reason, 72))}\n` : "";
|
|
55
|
+
return lines.join("\n") + reason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Animated intercept: spinner → risk → BLOCKED → policy.
|
|
60
|
+
* Total 300-800ms, but decision already made.
|
|
61
|
+
*/
|
|
62
|
+
export async function animateIntercept(event, { stream = process.stdout, pace = 700 } = {}) {
|
|
63
|
+
const enabled = shouldAnimate({ pace, json: false });
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
stream.write(interceptBox(event) + "\n");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const write = (s) => {
|
|
69
|
+
try {
|
|
70
|
+
stream.write(s);
|
|
71
|
+
} catch {}
|
|
72
|
+
};
|
|
73
|
+
const timers = [];
|
|
74
|
+
const hide = () => {
|
|
75
|
+
try {
|
|
76
|
+
stream.write("\u001b[?25l");
|
|
77
|
+
} catch {}
|
|
78
|
+
};
|
|
79
|
+
const show = () => {
|
|
80
|
+
try {
|
|
81
|
+
stream.write("\u001b[?25h");
|
|
82
|
+
} catch {}
|
|
83
|
+
};
|
|
84
|
+
hide();
|
|
85
|
+
// Phase 1: evaluating
|
|
86
|
+
write(" ◌ evaluating request...\r");
|
|
87
|
+
await sleep(Math.min(260, pace * 0.35), timers);
|
|
88
|
+
write("\x1b[2K");
|
|
89
|
+
// Phase 2: risk
|
|
90
|
+
const riskTone = event.risk === "critical" ? red : event.risk === "high" ? "\u001b[33m" : "";
|
|
91
|
+
write(` ${riskTone}⚠ ${String(event.risk ?? "").toUpperCase()}\u001b[39m\n`);
|
|
92
|
+
await sleep(Math.min(160, pace * 0.2), timers);
|
|
93
|
+
// Phase 3: BLOCKED
|
|
94
|
+
write(` ${red("✕ BLOCKED")}\n`);
|
|
95
|
+
await sleep(Math.min(160, pace * 0.2), timers);
|
|
96
|
+
// Phase 4: policy
|
|
97
|
+
write(` ${dim(String(event.policy ?? event.rule ?? ""))}\n`);
|
|
98
|
+
await sleep(Math.min(120, pace * 0.15), timers);
|
|
99
|
+
// Phase 5: full box
|
|
100
|
+
write(interceptBox(event) + "\n");
|
|
101
|
+
show();
|
|
102
|
+
for (const t of timers) clearTimeout(t);
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveStream — `cirvix logs --watch` live security stream.
|
|
3
|
+
*
|
|
4
|
+
* Line-based updates, no full redraw, readable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { dim, bold } from "../format.mjs";
|
|
8
|
+
import { renderDecision } from "./decisions.mjs";
|
|
9
|
+
|
|
10
|
+
export class LiveStream {
|
|
11
|
+
constructor({ stream = process.stdout, title = "CIRVIX LIVE · protection active" } = {}) {
|
|
12
|
+
this.stream = stream;
|
|
13
|
+
this.title = title;
|
|
14
|
+
this.started = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
header() {
|
|
18
|
+
if (this.started) return;
|
|
19
|
+
this.started = true;
|
|
20
|
+
try {
|
|
21
|
+
this.stream.write(`\n ${bold(this.title)}\n`);
|
|
22
|
+
this.stream.write(` ${dim("─".repeat(60))}\n\n`);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
push(event) {
|
|
27
|
+
this.header();
|
|
28
|
+
const line = renderDecision(event);
|
|
29
|
+
// For live, compact is preferred even for DENY to keep stream readable,
|
|
30
|
+
// but DENY still gets expanded with context on next line.
|
|
31
|
+
try {
|
|
32
|
+
// event already has ts; render with clock if present.
|
|
33
|
+
const clock = event.ts ? String(event.ts).slice(11, 19) : new Date().toISOString().slice(11, 19);
|
|
34
|
+
// If decision renderer already includes multiline, just prefix clock.
|
|
35
|
+
if (line.includes("\n")) {
|
|
36
|
+
const parts = line.split("\n");
|
|
37
|
+
this.stream.write(` ${dim(clock)} ${parts[0].trimStart()}\n`);
|
|
38
|
+
for (let i = 1; i < parts.length; i++) this.stream.write(parts[i] + "\n");
|
|
39
|
+
} else {
|
|
40
|
+
this.stream.write(` ${dim(clock)} ${line.trimStart()}\n`);
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
footer(stats) {
|
|
46
|
+
if (!stats) return;
|
|
47
|
+
try {
|
|
48
|
+
this.stream.write(`\n ${dim(`${stats.records ?? 0} decisions · P99 ${stats.latency?.p99 ?? 0}ms`)}\n\n`);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitives — Panel, Table, Badge, Logo, Status helpers.
|
|
3
|
+
*
|
|
4
|
+
* Zero deps, unicode with ASCII fallback, respects NO_COLOR via format.mjs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { bold, dim, stripAnsi, supportsUnicode } from "../format.mjs";
|
|
8
|
+
import { boxChars, truncate, padVisible } from "./theme.mjs";
|
|
9
|
+
|
|
10
|
+
/** CIRVIX ASCII logo (5 lines). Compact, premium, not gamey. */
|
|
11
|
+
export const LOGO_LINES = [
|
|
12
|
+
" ██████╗██╗██████╗ ██╗ ██╗██╗██╗ ██╗",
|
|
13
|
+
"██╔════╝██║██╔══██╗██║ ██║██║╚██╗██╔╝",
|
|
14
|
+
"██║ ██║██████╔╝██║ ██║██║ ╚███╔╝ ",
|
|
15
|
+
"██║ ██║██╔══██╗╚██╗ ██╔╝██║ ██╔██╗ ",
|
|
16
|
+
"╚██████╗██║██║ ██║ ╚████╔╝ ██║██╔╝ ██╗",
|
|
17
|
+
" ╚═════╝╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const LOGO_SUBTITLE = "AI AGENT RUNTIME GOVERNANCE";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Draw a rounded panel with optional title.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {string} [opts.title]
|
|
27
|
+
* @param {string[]} opts.lines — already formatted (may contain ANSI)
|
|
28
|
+
* @param {number} [opts.width] — inner width, default 58
|
|
29
|
+
* @param {boolean} [opts.heavy] — use heavy border for intercept
|
|
30
|
+
*/
|
|
31
|
+
export function panel({ title, lines = [], width = 58, heavy = false } = {}) {
|
|
32
|
+
const ch = boxChars();
|
|
33
|
+
const h = heavy ? ch.hHeavy : ch.h;
|
|
34
|
+
const v = heavy ? ch.vHeavy : ch.v;
|
|
35
|
+
|
|
36
|
+
// Compute actual inner width from content if not provided.
|
|
37
|
+
let inner = width;
|
|
38
|
+
if (!width) {
|
|
39
|
+
inner = Math.max(...lines.map((l) => stripAnsi(l).length), title ? stripAnsi(title).length : 0) + 2;
|
|
40
|
+
inner = Math.max(40, Math.min(72, inner));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const top = `╭${h.repeat(inner + 2)}╮`;
|
|
44
|
+
const bottom = `╰${h.repeat(inner + 2)}╯`;
|
|
45
|
+
// For ascii fallback, boxChars returns +/-, so above uses unicode literals.
|
|
46
|
+
// Rebuild with actual chars for ascii.
|
|
47
|
+
const useAscii = !supportsUnicode();
|
|
48
|
+
const topLine = useAscii ? `${ch.tl}${ch.h.repeat(inner + 2)}${ch.tr}` : top;
|
|
49
|
+
const bottomLine = useAscii ? `${ch.bl}${ch.h.repeat(inner + 2)}${ch.br}` : bottom;
|
|
50
|
+
const vert = useAscii ? ch.v : v;
|
|
51
|
+
|
|
52
|
+
const out = [];
|
|
53
|
+
out.push(` ${topLine}`);
|
|
54
|
+
if (title) {
|
|
55
|
+
const t = truncate(title, inner);
|
|
56
|
+
out.push(` ${vert} ${padVisible(t, inner)} ${vert}`);
|
|
57
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
58
|
+
} else {
|
|
59
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
60
|
+
}
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const clean = truncate(stripAnsi(line), inner);
|
|
63
|
+
// Preserve ANSI: pad based on visible length.
|
|
64
|
+
const padded = padVisible(line, inner);
|
|
65
|
+
// Ensure we truncate ANSI correctly — if original had ANSI, we need to re-truncate safely.
|
|
66
|
+
// Simpler: use padded which already uses visibleWidth.
|
|
67
|
+
out.push(` ${vert} ${padded} ${vert}`);
|
|
68
|
+
}
|
|
69
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
70
|
+
out.push(` ${bottomLine}`);
|
|
71
|
+
return out.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the full brand header — logo boxed.
|
|
76
|
+
*/
|
|
77
|
+
export function brandHeader({ width = 58 } = {}) {
|
|
78
|
+
const ch = boxChars();
|
|
79
|
+
const useAscii = !supportsUnicode();
|
|
80
|
+
const h = ch.h;
|
|
81
|
+
const top = useAscii ? `${ch.tl}${h.repeat(width + 2)}${ch.tr}` : `╭${h.repeat(width + 2)}╮`;
|
|
82
|
+
const bottom = useAscii ? `${ch.bl}${h.repeat(width + 2)}${ch.br}` : `╰${h.repeat(width + 2)}╯`;
|
|
83
|
+
const vert = useAscii ? ch.v : ch.v;
|
|
84
|
+
|
|
85
|
+
const lines = [];
|
|
86
|
+
lines.push(` ${top}`);
|
|
87
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
88
|
+
for (const l of LOGO_LINES) {
|
|
89
|
+
const padded = l.padStart(Math.floor((width + l.length) / 2)).padEnd(width);
|
|
90
|
+
lines.push(` ${vert} ${padded} ${vert}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
93
|
+
const sub = LOGO_SUBTITLE.padStart(Math.floor((width + LOGO_SUBTITLE.length) / 2)).padEnd(width);
|
|
94
|
+
lines.push(` ${vert} ${dim(sub)} ${vert}`);
|
|
95
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
96
|
+
lines.push(` ${bottom}`);
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Simple key/value rows aligned. */
|
|
101
|
+
export function keyValueRows(rows, { keyWidth } = {}) {
|
|
102
|
+
const w = keyWidth ?? Math.max(...rows.map(([k]) => stripAnsi(String(k)).length));
|
|
103
|
+
return rows.map(([k, v]) => ` ${String(k).padEnd(w + 2)}${v}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Separator line. */
|
|
107
|
+
export function separator(width = 60, char = "─") {
|
|
108
|
+
const ch = supportsUnicode() ? char : "-";
|
|
109
|
+
return dim(" " + ch.repeat(width));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Badge: ● ONLINE / ● ENFORCING etc. with color. */
|
|
113
|
+
export function badge(label, state, tone) {
|
|
114
|
+
const dot = "●";
|
|
115
|
+
return `${tone(`${dot} ${state}`)} ${dim(label)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Truncate path for display, never show secret values. */
|
|
119
|
+
export function safeTarget(value, max = 44) {
|
|
120
|
+
const s = String(value ?? "");
|
|
121
|
+
if (s.length <= max) return s;
|
|
122
|
+
return "…" + s.slice(-(max - 1));
|
|
123
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme — semantic palette + box characters, with NO_COLOR and ASCII fallbacks.
|
|
3
|
+
*
|
|
4
|
+
* Never rely on color alone; every semantic token also has a text label
|
|
5
|
+
* (ALLOW/BLOCKED etc.) rendered elsewhere.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { bold, dim, green, red, amber, blue, cyan, gray, stripAnsi, supportsUnicode } from "../format.mjs";
|
|
9
|
+
|
|
10
|
+
export const palette = {
|
|
11
|
+
success: green,
|
|
12
|
+
critical: red,
|
|
13
|
+
warning: amber,
|
|
14
|
+
info: blue,
|
|
15
|
+
muted: dim,
|
|
16
|
+
subtle: gray,
|
|
17
|
+
accent: cyan,
|
|
18
|
+
strong: bold,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const riskTone = {
|
|
22
|
+
low: dim,
|
|
23
|
+
medium: blue,
|
|
24
|
+
high: amber,
|
|
25
|
+
critical: red,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const decisionTone = {
|
|
29
|
+
allow: green,
|
|
30
|
+
sanitize: blue,
|
|
31
|
+
require_approval: amber,
|
|
32
|
+
deny: red,
|
|
33
|
+
audit_only: dim,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function toneForDecision(decision) {
|
|
37
|
+
return decisionTone[String(decision ?? "").toLowerCase()] ?? dim;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toneForRisk(risk) {
|
|
41
|
+
return riskTone[String(risk ?? "").toLowerCase()] ?? dim;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Box characters, unicode primary, ascii fallback. */
|
|
45
|
+
export function boxChars() {
|
|
46
|
+
if (supportsUnicode()) {
|
|
47
|
+
return {
|
|
48
|
+
tl: "╭",
|
|
49
|
+
tr: "╮",
|
|
50
|
+
bl: "╰",
|
|
51
|
+
br: "╯",
|
|
52
|
+
h: "─",
|
|
53
|
+
v: "│",
|
|
54
|
+
lt: "├",
|
|
55
|
+
rt: "┤",
|
|
56
|
+
mt: "┬",
|
|
57
|
+
mb: "┴",
|
|
58
|
+
cross: "┼",
|
|
59
|
+
// heavy for intercept
|
|
60
|
+
hHeavy: "━",
|
|
61
|
+
vHeavy: "┃",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
tl: "+",
|
|
66
|
+
tr: "+",
|
|
67
|
+
bl: "+",
|
|
68
|
+
br: "+",
|
|
69
|
+
h: "-",
|
|
70
|
+
v: "|",
|
|
71
|
+
lt: "+",
|
|
72
|
+
rt: "+",
|
|
73
|
+
mt: "+",
|
|
74
|
+
mb: "+",
|
|
75
|
+
cross: "+",
|
|
76
|
+
hHeavy: "=",
|
|
77
|
+
vHeavy: "|",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Safe width truncation with ANSI stripped. */
|
|
82
|
+
export function truncate(str, n) {
|
|
83
|
+
const s = String(str ?? "");
|
|
84
|
+
if (s.length <= n) return s;
|
|
85
|
+
return s.slice(0, n - 1) + "…";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padVisible(str, width) {
|
|
89
|
+
const vis = stripAnsi(String(str)).length;
|
|
90
|
+
if (vis >= width) return String(str);
|
|
91
|
+
return String(str) + " ".repeat(width - vis);
|
|
92
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cirvix Verified & MCP Trust Layer.
|
|
3
|
+
*
|
|
4
|
+
* Implements security verification for MCP servers and tools:
|
|
5
|
+
* - Provenance and publisher tracking
|
|
6
|
+
* - Tool poisoning / description tampering detection
|
|
7
|
+
* - Unpinned network egress analysis
|
|
8
|
+
* - Trust score computation (0-100)
|
|
9
|
+
* - Verification status: VERIFIED | REVIEW_REQUIRED | SUSPICIOUS | BLOCKED
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const VERIFICATION_STATUS = {
|
|
13
|
+
VERIFIED: "VERIFIED",
|
|
14
|
+
REVIEW_REQUIRED: "REVIEW_REQUIRED",
|
|
15
|
+
SUSPICIOUS: "SUSPICIOUS",
|
|
16
|
+
BLOCKED: "BLOCKED",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scans an MCP server declaration and its tools for security risks and assigns trust score.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} server
|
|
23
|
+
* @param {string} server.name
|
|
24
|
+
* @param {string} [server.publisher]
|
|
25
|
+
* @param {string} [server.version]
|
|
26
|
+
* @param {Array} [server.tools]
|
|
27
|
+
* @param {Array} [server.destinations]
|
|
28
|
+
* @returns {Object} Inspection report
|
|
29
|
+
*/
|
|
30
|
+
export function inspectMcpServer({
|
|
31
|
+
name,
|
|
32
|
+
publisher = null,
|
|
33
|
+
version = null,
|
|
34
|
+
tools = [],
|
|
35
|
+
destinations = [],
|
|
36
|
+
} = {}) {
|
|
37
|
+
let trustScore = 70; // Starting baseline for unverified server
|
|
38
|
+
const findings = [];
|
|
39
|
+
|
|
40
|
+
// Publisher trust
|
|
41
|
+
const knownTrustedPublishers = ["anthropic", "google", "github", "stripe", "aws", "cirvix"];
|
|
42
|
+
if (publisher && knownTrustedPublishers.includes(publisher.toLowerCase())) {
|
|
43
|
+
trustScore += 25;
|
|
44
|
+
} else if (!publisher) {
|
|
45
|
+
trustScore -= 15;
|
|
46
|
+
findings.push("Missing publisher provenance metadata");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Network destinations check
|
|
50
|
+
for (const dest of destinations) {
|
|
51
|
+
const dLower = String(dest).toLowerCase();
|
|
52
|
+
if (dLower.includes("169.254.") || dLower.includes("metadata.google")) {
|
|
53
|
+
trustScore = 0;
|
|
54
|
+
findings.push(`Dangerous cloud metadata destination declared: ${dest}`);
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
trustScore: 0,
|
|
58
|
+
status: VERIFICATION_STATUS.BLOCKED,
|
|
59
|
+
findings,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tools inspection
|
|
65
|
+
for (const tool of tools) {
|
|
66
|
+
const desc = (tool.description ?? "").toLowerCase();
|
|
67
|
+
const toolName = (tool.name ?? "").toLowerCase();
|
|
68
|
+
|
|
69
|
+
// Check for tool poisoning / prompt injection markers in description
|
|
70
|
+
if (
|
|
71
|
+
desc.includes("ignore previous instructions") ||
|
|
72
|
+
desc.includes("system prompt") ||
|
|
73
|
+
desc.includes("do not tell the user") ||
|
|
74
|
+
desc.includes("send secrets to")
|
|
75
|
+
) {
|
|
76
|
+
trustScore = 0;
|
|
77
|
+
findings.push(`Tool poisoning detected in tool '${tool.name}': suspicious prompt injection phrases in description`);
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
trustScore: 0,
|
|
81
|
+
status: VERIFICATION_STATUS.BLOCKED,
|
|
82
|
+
findings,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for excessive wildcard permissions or raw shell execution
|
|
87
|
+
if (toolName === "bash" || toolName === "exec" || toolName === "eval") {
|
|
88
|
+
trustScore -= 20;
|
|
89
|
+
findings.push(`Tool '${tool.name}' exposes raw shell/process execution capability`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
trustScore = Math.max(0, Math.min(100, trustScore));
|
|
94
|
+
|
|
95
|
+
let status = VERIFICATION_STATUS.REVIEW_REQUIRED;
|
|
96
|
+
if (trustScore >= 85) status = VERIFICATION_STATUS.VERIFIED;
|
|
97
|
+
else if (trustScore < 50) status = VERIFICATION_STATUS.SUSPICIOUS;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
publisher,
|
|
102
|
+
version,
|
|
103
|
+
trustScore,
|
|
104
|
+
status,
|
|
105
|
+
findings,
|
|
106
|
+
scannedAt: new Date().toISOString(),
|
|
107
|
+
};
|
|
108
|
+
}
|