@cirvix_ai/agent-control 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 +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cirvix scan` — the free, read-only inventory.
|
|
3
|
+
*
|
|
4
|
+
* This is the command the whole funnel rests on, so it has one job: tell a
|
|
5
|
+
* developer something true about their machine that they did not already
|
|
6
|
+
* know, in under five seconds, without an account and without sending
|
|
7
|
+
* anything anywhere.
|
|
8
|
+
*
|
|
9
|
+
* It writes nothing and opens no sockets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
collectMcpServers,
|
|
14
|
+
detectCredentials,
|
|
15
|
+
detectFrameworks,
|
|
16
|
+
detectRuntimes,
|
|
17
|
+
} from "../core/detect.mjs";
|
|
18
|
+
import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
|
|
19
|
+
|
|
20
|
+
export async function scan({ cwd = process.cwd(), json = false, deep = false } = {}) {
|
|
21
|
+
const runtimes = await detectRuntimes();
|
|
22
|
+
const frameworks = await detectFrameworks(cwd);
|
|
23
|
+
const servers = collectMcpServers(runtimes);
|
|
24
|
+
const credentials = await detectCredentials(cwd);
|
|
25
|
+
|
|
26
|
+
const findings = buildFindings({ runtimes, frameworks, servers, credentials });
|
|
27
|
+
const counts = tally(findings);
|
|
28
|
+
|
|
29
|
+
const result = {
|
|
30
|
+
scannedAt: new Date().toISOString(),
|
|
31
|
+
cwd,
|
|
32
|
+
runtimes: runtimes.map(({ servers: _s, ...r }) => r),
|
|
33
|
+
frameworks,
|
|
34
|
+
mcpServers: servers,
|
|
35
|
+
credentials: credentials.map(({ keys: _k, ...c }) => c),
|
|
36
|
+
findings,
|
|
37
|
+
counts,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (json) return { result, output: JSON.stringify(result, null, 2) };
|
|
41
|
+
return { result, output: render(result, { deep }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* -------------------------------------------------------------------------- */
|
|
45
|
+
|
|
46
|
+
function buildFindings({ runtimes, frameworks, servers, credentials }) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
|
|
49
|
+
for (const r of runtimes) {
|
|
50
|
+
if (!r.governed) {
|
|
51
|
+
findings.push({
|
|
52
|
+
severity: "high",
|
|
53
|
+
code: "runtime-ungoverned",
|
|
54
|
+
subject: r.label,
|
|
55
|
+
detail: `Tool calls from ${r.label} are not routed through a control plane. Anything it can reach, it can reach unchecked.`,
|
|
56
|
+
fix: `cirvix gateway --servers ${r.path}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const f of frameworks) {
|
|
62
|
+
findings.push({
|
|
63
|
+
severity: "medium",
|
|
64
|
+
code: "framework-uninstrumented",
|
|
65
|
+
subject: f.label,
|
|
66
|
+
detail: `${f.label} detected in this project with no Cirvix middleware on its tool boundary.`,
|
|
67
|
+
fix: "Wrap the executor boundary: guard.wrap(tools, { agent, rules })",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const s of servers) {
|
|
72
|
+
if (s.scope?.broad) {
|
|
73
|
+
findings.push({
|
|
74
|
+
severity: "high",
|
|
75
|
+
code: "mcp-broad-scope",
|
|
76
|
+
subject: s.name,
|
|
77
|
+
detail: `Exposes ${s.scope.widest} to any agent that can call it — far wider than a workspace.`,
|
|
78
|
+
fix: "Narrow the server's path arguments, or scope it per-agent through the gateway.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (s.envKeys.length > 0) {
|
|
82
|
+
findings.push({
|
|
83
|
+
severity: "medium",
|
|
84
|
+
code: "mcp-inline-secrets",
|
|
85
|
+
subject: s.name,
|
|
86
|
+
detail: `Configuration carries ${plural(s.envKeys.length, "environment value")} inline (${s.envKeys.slice(0, 3).join(", ")}${s.envKeys.length > 3 ? "…" : ""}). These sit in a plaintext config file.`,
|
|
87
|
+
fix: "Move to secret handles brokered at the boundary.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (s.runtimes.length > 1) {
|
|
91
|
+
findings.push({
|
|
92
|
+
severity: "low",
|
|
93
|
+
code: "mcp-duplicated",
|
|
94
|
+
subject: s.name,
|
|
95
|
+
detail: `Configured separately in ${s.runtimes.join(" and ")} — one trust boundary with ${s.runtimes.length} doors, each updated independently.`,
|
|
96
|
+
fix: "Register once and route both runtimes through the gateway.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const c of credentials) {
|
|
102
|
+
findings.push({
|
|
103
|
+
severity: c.severity,
|
|
104
|
+
code: c.kind === "dotenv" ? "env-readable" : "credential-readable",
|
|
105
|
+
subject: c.label,
|
|
106
|
+
detail: c.detail,
|
|
107
|
+
fix: "Route agents through cirvix gateway — the starter policy already denies reads of this path.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
112
|
+
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tally(findings) {
|
|
116
|
+
return findings.reduce(
|
|
117
|
+
(acc, f) => ({ ...acc, [f.severity]: (acc[f.severity] ?? 0) + 1 }),
|
|
118
|
+
{ high: 0, medium: 0, low: 0 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* -------------------------------------------------------------------------- */
|
|
123
|
+
|
|
124
|
+
function render(r, { deep }) {
|
|
125
|
+
const L = [];
|
|
126
|
+
// padEnd does nothing when the string already exceeds the column, which
|
|
127
|
+
// collides the label with the next field. Always guarantee one space.
|
|
128
|
+
const pad = (s, n) => {
|
|
129
|
+
const v = String(s);
|
|
130
|
+
return v.length >= n ? v + " " : v.padEnd(n);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
L.push("");
|
|
134
|
+
L.push(` ${bold("Cirvix scan")} ${dim("· read-only · nothing was changed or sent")}`);
|
|
135
|
+
L.push("");
|
|
136
|
+
|
|
137
|
+
// Runtimes
|
|
138
|
+
L.push(` ${bold("runtimes")}${dim(pad("", 12))}${r.runtimes.length ? plural(r.runtimes.length, "found") : dim("none detected")}`);
|
|
139
|
+
for (const rt of r.runtimes) {
|
|
140
|
+
const state = rt.governed ? green("governed") : red("ungoverned");
|
|
141
|
+
L.push(` ${pad(rt.label, 18)}${dim(shorten(rt.path))}`);
|
|
142
|
+
L.push(` ${pad("", 18)}${state}${dim(` · ${plural(rt.serverCount, "MCP server")}`)}`);
|
|
143
|
+
}
|
|
144
|
+
if (r.frameworks.length) {
|
|
145
|
+
for (const f of r.frameworks) {
|
|
146
|
+
L.push(` ${pad(f.label, 18)}${dim(f.via)}`);
|
|
147
|
+
L.push(` ${pad("", 18)}${amber("uninstrumented")}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
L.push("");
|
|
151
|
+
|
|
152
|
+
// MCP servers
|
|
153
|
+
L.push(` ${bold("mcp servers")}${dim(pad("", 9))}${r.mcpServers.length ? plural(r.mcpServers.length, "configured") : dim("none")}`);
|
|
154
|
+
for (const s of r.mcpServers) {
|
|
155
|
+
const flags = [];
|
|
156
|
+
if (s.scope?.broad) flags.push(red("broad scope"));
|
|
157
|
+
if (s.envKeys.length) flags.push(amber("inline secrets"));
|
|
158
|
+
if (s.runtimes.length > 1) flags.push(dim(`×${s.runtimes.length} runtimes`));
|
|
159
|
+
L.push(` ${pad(s.name, 18)}${pad(s.transport, 8)}${flags.join(dim(" · "))}`);
|
|
160
|
+
if (deep && s.command) L.push(` ${pad("", 18)}${dim(s.command + " " + s.args.join(" "))}`);
|
|
161
|
+
}
|
|
162
|
+
L.push("");
|
|
163
|
+
|
|
164
|
+
// Credentials
|
|
165
|
+
L.push(` ${bold("credentials")}${dim(pad("", 9))}${r.credentials.length ? `${plural(r.credentials.length, "path")} reachable from agent context` : dim("none reachable")}`);
|
|
166
|
+
for (const c of r.credentials) {
|
|
167
|
+
L.push(` ${pad(c.label, 18)}${dim(c.detail)}`);
|
|
168
|
+
}
|
|
169
|
+
L.push("");
|
|
170
|
+
|
|
171
|
+
// Findings
|
|
172
|
+
const { high, medium, low } = r.counts;
|
|
173
|
+
const summary = [
|
|
174
|
+
high ? red(`${high} high`) : null,
|
|
175
|
+
medium ? amber(`${medium} medium`) : null,
|
|
176
|
+
low ? dim(`${low} low`) : null,
|
|
177
|
+
]
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.join(dim(" · "));
|
|
180
|
+
|
|
181
|
+
L.push(` ${bold("findings")}${dim(pad("", 12))}${summary || green("nothing ungoverned")}`);
|
|
182
|
+
L.push("");
|
|
183
|
+
|
|
184
|
+
for (const f of r.findings.slice(0, 12)) {
|
|
185
|
+
const mark = f.severity === "high" ? red("▲") : f.severity === "medium" ? amber("▲") : dim("▪");
|
|
186
|
+
L.push(` ${mark} ${bold(f.subject)} ${dim(`(${f.code})`)}`);
|
|
187
|
+
L.push(` ${f.detail}`);
|
|
188
|
+
L.push(` ${dim("fix:")} ${blue(f.fix)}`);
|
|
189
|
+
L.push("");
|
|
190
|
+
}
|
|
191
|
+
if (r.findings.length > 12) {
|
|
192
|
+
L.push(` ${dim(`…and ${r.findings.length - 12} more. Run with --json for the full list.`)}`);
|
|
193
|
+
L.push("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (r.findings.length > 0) {
|
|
197
|
+
L.push(` ${dim("Nothing was changed. To see how a call would be decided:")}`);
|
|
198
|
+
L.push(` ${blue("cirvix check --action fs.read --resource .env")}`);
|
|
199
|
+
} else {
|
|
200
|
+
L.push(` ${green("No ungoverned surfaces found on this machine.")}`);
|
|
201
|
+
}
|
|
202
|
+
L.push("");
|
|
203
|
+
|
|
204
|
+
return L.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shorten(p) {
|
|
208
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
209
|
+
return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
210
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cirvix status` — what is protected, right now.
|
|
3
|
+
*
|
|
4
|
+
* CIRVIX AGENTCONTROL
|
|
5
|
+
*
|
|
6
|
+
* Runtime RUNNING
|
|
7
|
+
* Policy 17 rules
|
|
8
|
+
* MCP Servers 6
|
|
9
|
+
* Protected 4
|
|
10
|
+
* Blocked 3
|
|
11
|
+
* Approvals 2
|
|
12
|
+
* P99 overhead 2.1ms
|
|
13
|
+
*
|
|
14
|
+
* EVERY NUMBER HERE IS MEASURED, NOT ASSERTED.
|
|
15
|
+
*
|
|
16
|
+
* `Protected` counts runtimes whose MCP traffic actually routes through the
|
|
17
|
+
* gateway — read out of their config files, not out of ours. `Blocked` and
|
|
18
|
+
* `Approvals` are counted from the audit chain. `P99 overhead` is computed from
|
|
19
|
+
* recorded per-decision latencies and shows `—` when there are none, rather
|
|
20
|
+
* than a plausible-looking default.
|
|
21
|
+
*
|
|
22
|
+
* That last one matters more than it sounds. A status screen that prints a
|
|
23
|
+
* latency figure before anything has been measured is how a design target
|
|
24
|
+
* becomes a benchmark result in a deck three weeks later.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { access, readFile } from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { collectMcpServers, detectRuntimes } from "../core/detect.mjs";
|
|
31
|
+
import { read as readJournal, summarize } from "../core/journal.mjs";
|
|
32
|
+
import { ApprovalStore } from "../core/approvals.mjs";
|
|
33
|
+
import { UdsClient, defaultEndpoint, tokenPath } from "../core/uds.mjs";
|
|
34
|
+
import { MODE } from "../core/decisions.mjs";
|
|
35
|
+
import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
|
|
36
|
+
|
|
37
|
+
async function exists(path) {
|
|
38
|
+
try {
|
|
39
|
+
await access(path);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Is the runtime actually up?
|
|
48
|
+
*
|
|
49
|
+
* Answered by connecting to the control socket, not by checking for a pid file.
|
|
50
|
+
* A stale pid file is the standard way a status command reports RUNNING for a
|
|
51
|
+
* process that died an hour ago, and this is a security control — "is it on"
|
|
52
|
+
* has to be the truth.
|
|
53
|
+
*/
|
|
54
|
+
async function probeRuntime(stateDir) {
|
|
55
|
+
const endpoint = defaultEndpoint(stateDir);
|
|
56
|
+
if (!(await exists(tokenPath(stateDir)))) return { running: false, endpoint, reason: "no session token" };
|
|
57
|
+
let token;
|
|
58
|
+
try {
|
|
59
|
+
token = (await readFile(tokenPath(stateDir), "utf8")).trim();
|
|
60
|
+
} catch {
|
|
61
|
+
return { running: false, endpoint, reason: "unreadable session token" };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const client = new UdsClient({ endpoint, token, timeoutMs: 1500 });
|
|
65
|
+
const status = await client.call("cirvix/status", {});
|
|
66
|
+
return { running: true, endpoint, live: status };
|
|
67
|
+
} catch {
|
|
68
|
+
return { running: false, endpoint, reason: "nothing listening" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {string} opts.cwd
|
|
75
|
+
* @param {Array} [opts.rules] already-loaded rule set
|
|
76
|
+
* @param {boolean} [opts.json]
|
|
77
|
+
*/
|
|
78
|
+
export async function status({ cwd = process.cwd(), rules = [], json = false, stateDir: dir } = {}) {
|
|
79
|
+
const stateDir = dir ?? join(cwd, ".cirvix");
|
|
80
|
+
|
|
81
|
+
const [runtimes, runtime, records] = await Promise.all([
|
|
82
|
+
detectRuntimes(),
|
|
83
|
+
probeRuntime(stateDir),
|
|
84
|
+
readJournal(join(stateDir, "audit.jsonl")),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const servers = collectMcpServers(runtimes);
|
|
88
|
+
const protectedRuntimes = runtimes.filter((r) => r.governed);
|
|
89
|
+
const stats = summarize(records);
|
|
90
|
+
|
|
91
|
+
let approvals = { pending: 0, total: 0 };
|
|
92
|
+
const approvalsPath = join(stateDir, "approvals.jsonl");
|
|
93
|
+
if (await exists(approvalsPath)) {
|
|
94
|
+
const store = await new ApprovalStore(approvalsPath).open();
|
|
95
|
+
approvals = { pending: store.pending().length, total: store.all().length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = {
|
|
99
|
+
runtime: {
|
|
100
|
+
running: runtime.running,
|
|
101
|
+
endpoint: runtime.endpoint,
|
|
102
|
+
reason: runtime.reason ?? null,
|
|
103
|
+
mode: runtime.live?.mode ?? MODE.ENFORCE,
|
|
104
|
+
},
|
|
105
|
+
policy: {
|
|
106
|
+
rules: rules.length,
|
|
107
|
+
// A live runtime is the authority on how many rules are actually loaded;
|
|
108
|
+
// the file on disk may have been edited since it started.
|
|
109
|
+
loaded: runtime.live?.rules ?? null,
|
|
110
|
+
},
|
|
111
|
+
mcpServers: servers.length,
|
|
112
|
+
runtimes: runtimes.map((r) => ({
|
|
113
|
+
id: r.id,
|
|
114
|
+
label: r.label,
|
|
115
|
+
governed: r.governed,
|
|
116
|
+
servers: r.serverCount,
|
|
117
|
+
})),
|
|
118
|
+
protected: protectedRuntimes.length,
|
|
119
|
+
decisions: stats.counts,
|
|
120
|
+
blocked: stats.counts.deny,
|
|
121
|
+
approvals,
|
|
122
|
+
risks: stats.risks,
|
|
123
|
+
latency: stats.latency,
|
|
124
|
+
vault: runtime.live?.vault ?? null,
|
|
125
|
+
records: stats.records,
|
|
126
|
+
topRules: stats.topRules,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (json) return { result, output: JSON.stringify(result, null, 2) };
|
|
130
|
+
return { result, output: render(result) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* -------------------------------------------------------------------------- */
|
|
134
|
+
|
|
135
|
+
function render(r) {
|
|
136
|
+
const rows = [
|
|
137
|
+
["Runtime", r.runtime.running ? green(bold("RUNNING")) : dim("STOPPED") + dim(` ${r.runtime.reason ?? ""}`)],
|
|
138
|
+
["Policy", r.policy.rules ? `${plural(r.policy.rules, "rule")}` : dim("no policy loaded")],
|
|
139
|
+
["MCP Servers", String(r.mcpServers)],
|
|
140
|
+
[
|
|
141
|
+
"Protected",
|
|
142
|
+
r.protected === 0 && r.runtimes.length > 0
|
|
143
|
+
? amber(String(r.protected)) + dim(` of ${r.runtimes.length} — nothing is routed through the gateway yet`)
|
|
144
|
+
: `${r.protected}` + dim(r.runtimes.length ? ` of ${r.runtimes.length}` : ""),
|
|
145
|
+
],
|
|
146
|
+
["Blocked", r.blocked > 0 ? red(String(r.blocked)) : String(r.blocked)],
|
|
147
|
+
["Approvals", r.approvals.pending > 0 ? amber(`${r.approvals.pending} pending`) : String(r.approvals.pending)],
|
|
148
|
+
[
|
|
149
|
+
"P99 overhead",
|
|
150
|
+
r.latency.samples
|
|
151
|
+
? `${r.latency.p99}ms` + dim(` over ${plural(r.latency.samples, "decision")}`)
|
|
152
|
+
: dim("— nothing measured yet"),
|
|
153
|
+
],
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const width = Math.max(...rows.map(([k]) => k.length));
|
|
157
|
+
const lines = ["", ` ${bold("CIRVIX AGENTCONTROL")}`, ""];
|
|
158
|
+
for (const [key, value] of rows) lines.push(` ${key.padEnd(width + 2)}${value}`);
|
|
159
|
+
|
|
160
|
+
if (r.runtime.mode !== MODE.ENFORCE) {
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push(` ${amber(bold("AUDIT MODE"))} ${dim("decisions are recorded and nothing is blocked.")}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (r.records > 0) {
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push(` ${dim("decisions")} ` +
|
|
168
|
+
[
|
|
169
|
+
green(`${r.decisions.allow} allowed`),
|
|
170
|
+
r.decisions.sanitize ? blue(`${r.decisions.sanitize} sanitized`) : null,
|
|
171
|
+
r.decisions.require_approval ? amber(`${r.decisions.require_approval} held`) : null,
|
|
172
|
+
r.decisions.deny ? red(`${r.decisions.deny} denied`) : null,
|
|
173
|
+
r.decisions.audit_only ? dim(`${r.decisions.audit_only} audit-only`) : null,
|
|
174
|
+
]
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.join(dim(" · ")));
|
|
177
|
+
|
|
178
|
+
const risky = r.risks.high + r.risks.critical;
|
|
179
|
+
if (risky > 0) {
|
|
180
|
+
lines.push(` ${dim("risk")} ` +
|
|
181
|
+
[
|
|
182
|
+
r.risks.critical ? red(`${r.risks.critical} critical`) : null,
|
|
183
|
+
r.risks.high ? amber(`${r.risks.high} high`) : null,
|
|
184
|
+
r.risks.medium ? `${r.risks.medium} medium` : null,
|
|
185
|
+
r.risks.low ? dim(`${r.risks.low} low`) : null,
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(dim(" · ")));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (r.vault) {
|
|
193
|
+
lines.push(` ${dim("vault")} ${r.vault.held} held` + (r.vault.unscoped ? amber(` ${r.vault.unscoped} unscoped`) : ""));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
lines.push("");
|
|
197
|
+
|
|
198
|
+
if (!r.runtime.running && r.mcpServers > 0) {
|
|
199
|
+
lines.push(` ${dim("Start it:")} ${blue("cirvix gateway --servers <mcp.json>")}`);
|
|
200
|
+
lines.push("");
|
|
201
|
+
}
|
|
202
|
+
if (r.approvals.pending > 0) {
|
|
203
|
+
lines.push(` ${amber(`${plural(r.approvals.pending, "call")} waiting on a human:`)} ${blue("cirvix approvals")}`);
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cirvix upgrade [tier]` — show where the limits are, and how to lift them.
|
|
3
|
+
*
|
|
4
|
+
* cirvix upgrade what you are on, what you have used, what is next
|
|
5
|
+
* cirvix upgrade pro the checkout link for a specific tier
|
|
6
|
+
* cirvix upgrade --seats 5 Team, priced for a real number of seats
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS DELIBERATELY DOES NOT DO
|
|
9
|
+
*
|
|
10
|
+
* It does not take a payment, and it does not write a paid licence. It prints
|
|
11
|
+
* a URL. The licence file is written by the checkout callback, because a
|
|
12
|
+
* command that could grant itself Pro is not an entitlement system — and the
|
|
13
|
+
* one thing worse than honour-system metering is metering with a documented
|
|
14
|
+
* bypass in its own CLI.
|
|
15
|
+
*
|
|
16
|
+
* The tone is deliberately flat. Every prompt this product prints appears in
|
|
17
|
+
* the middle of somebody's terminal session while they were trying to do
|
|
18
|
+
* something else, and marketing language in that position reads as an
|
|
19
|
+
* interruption rather than an offer. State the limit, state the number, give
|
|
20
|
+
* the command, stop.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { TIERS, TIER_ORDER, dailyAllowance, nextTier, tierFor } from "../core/entitlements.mjs";
|
|
24
|
+
import { Meter, readLicence } from "../core/meter.mjs";
|
|
25
|
+
|
|
26
|
+
/** Published prices. Mirrors the pricing page; pinned by the test suite. */
|
|
27
|
+
export const PRICING = {
|
|
28
|
+
free: { monthly: 0, annual: 0 },
|
|
29
|
+
starter: { monthly: 29, annual: 290 },
|
|
30
|
+
pro: { monthly: 79, annual: 790 },
|
|
31
|
+
team: { monthly: 149, annual: 1490, perSeat: true, minSeats: 3 },
|
|
32
|
+
enterprise: { monthly: null, annual: null, custom: true },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const CHECKOUT_BASE = "https://www.cirvix.com/pricing.html";
|
|
36
|
+
|
|
37
|
+
export function checkoutUrl(tier, { seats } = {}) {
|
|
38
|
+
const t = tierFor(tier);
|
|
39
|
+
if (t.id === "free") return CHECKOUT_BASE;
|
|
40
|
+
const params = new URLSearchParams({ plan: t.id });
|
|
41
|
+
if (seats && PRICING[t.id]?.perSeat) params.set("seats", String(seats));
|
|
42
|
+
return `${CHECKOUT_BASE}?${params.toString()}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const money = (n) => `$${n.toLocaleString("en-US")}`;
|
|
46
|
+
|
|
47
|
+
/** One line describing what a tier costs. */
|
|
48
|
+
export function priceLine(tierId, seats) {
|
|
49
|
+
const p = PRICING[tierId];
|
|
50
|
+
if (!p) return "";
|
|
51
|
+
if (p.custom) return "Custom — scoped to your deployment";
|
|
52
|
+
if (p.monthly === 0) return "Free, forever";
|
|
53
|
+
if (p.perSeat) {
|
|
54
|
+
const n = Math.max(Number(seats) || 0, p.minSeats);
|
|
55
|
+
return `${money(p.monthly)}/seat/mo · ${money(p.monthly * n)}/mo for ${n} seats · ${money(p.annual)}/seat/yr`;
|
|
56
|
+
}
|
|
57
|
+
return `${money(p.monthly)}/mo · ${money(p.annual)}/yr (2 months free)`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a tier lifts, relative to the one below it. Only real differences. */
|
|
61
|
+
function liftsOver(fromId, toId) {
|
|
62
|
+
const from = tierFor(fromId);
|
|
63
|
+
const to = tierFor(toId);
|
|
64
|
+
const out = [];
|
|
65
|
+
|
|
66
|
+
const fromAllow = dailyAllowance({ tier: from.id, seats: from.seatsIncluded });
|
|
67
|
+
const toAllow = dailyAllowance({ tier: to.id, seats: to.seatsIncluded });
|
|
68
|
+
if (toAllow === null) out.push("Uncapped decisions");
|
|
69
|
+
else if (fromAllow !== null && toAllow > fromAllow) {
|
|
70
|
+
out.push(
|
|
71
|
+
`${toAllow.toLocaleString("en-US")} decisions/day` +
|
|
72
|
+
(to.perSeat ? ` (${to.decisionsPerDay.toLocaleString("en-US")} per seat)` : "") +
|
|
73
|
+
` — up from ${fromAllow.toLocaleString("en-US")}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (to.agents === null && from.agents !== null) out.push("Unlimited concurrent agents");
|
|
78
|
+
else if (to.agents !== null && from.agents !== null && to.agents > from.agents) {
|
|
79
|
+
out.push(`${to.agents} concurrent agents — up from ${from.agents}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (to.persistentSecrets && !from.persistentSecrets) {
|
|
83
|
+
out.push("Secret handles survive a restart");
|
|
84
|
+
}
|
|
85
|
+
if (to.approvals && !from.approvals) out.push("Human-in-the-loop approvals");
|
|
86
|
+
if (to.attestation && !from.attestation) out.push("Attestation headers");
|
|
87
|
+
if (to.sharedPolicy !== false && from.sharedPolicy === false) out.push("Shared policy + RBAC");
|
|
88
|
+
|
|
89
|
+
if (to.auditRetentionHours === null && from.auditRetentionHours !== null) {
|
|
90
|
+
// Hosted retention is a control-plane capability; the local chain itself
|
|
91
|
+
// is never pruned on any tier ("life of deployment" everywhere). Say what
|
|
92
|
+
// the tier actually adds rather than implying local history expires.
|
|
93
|
+
out.push("Hosted audit retention + export");
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string[]} argv tokens after `upgrade`
|
|
100
|
+
*/
|
|
101
|
+
export async function upgrade(argv = [], { cwd = process.cwd(), write = (s) => process.stdout.write(s) } = {}) {
|
|
102
|
+
const seatsFlag = argv.indexOf("--seats");
|
|
103
|
+
const seats = seatsFlag > -1 ? Number(argv[seatsFlag + 1]) || undefined : undefined;
|
|
104
|
+
const requested = argv.find((a) => !a.startsWith("--") && TIER_ORDER.includes(a.toLowerCase()));
|
|
105
|
+
|
|
106
|
+
const licence = readLicence(cwd);
|
|
107
|
+
const current = tierFor(licence.tier);
|
|
108
|
+
const meter = new Meter({ cwd });
|
|
109
|
+
const used = meter.used();
|
|
110
|
+
const allowance = dailyAllowance(licence);
|
|
111
|
+
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(` Current plan ${current.name}`);
|
|
115
|
+
lines.push(
|
|
116
|
+
` Today ${used.toLocaleString("en-US")}` +
|
|
117
|
+
(allowance === null ? " decisions (uncapped)" : ` of ${allowance.toLocaleString("en-US")} decisions`),
|
|
118
|
+
);
|
|
119
|
+
if (allowance !== null) {
|
|
120
|
+
const pct = allowance > 0 ? Math.round((used / allowance) * 100) : 0;
|
|
121
|
+
lines.push(` Resets 00:00 UTC${pct >= 80 ? ` · ${pct}% used` : ""}`);
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
|
|
125
|
+
const target = requested ?? nextTier(current.id);
|
|
126
|
+
|
|
127
|
+
if (target === current.id) {
|
|
128
|
+
lines.push(` ${current.name} is the highest tier. Nothing to upgrade to.`);
|
|
129
|
+
lines.push("");
|
|
130
|
+
write(lines.join("\n") + "\n");
|
|
131
|
+
return { tier: current.id, used, allowance, url: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const to = tierFor(target);
|
|
135
|
+
lines.push(` ${current.name} → ${to.name}`);
|
|
136
|
+
lines.push(` ${priceLine(to.id, seats)}`);
|
|
137
|
+
lines.push("");
|
|
138
|
+
for (const lift of liftsOver(current.id, to.id)) lines.push(` · ${lift}`);
|
|
139
|
+
lines.push("");
|
|
140
|
+
|
|
141
|
+
const url = checkoutUrl(to.id, { seats });
|
|
142
|
+
lines.push(` ${url}`);
|
|
143
|
+
lines.push("");
|
|
144
|
+
// Said once, plainly, because it is true and because a free tier that keeps
|
|
145
|
+
// implying it is about to end is not a free tier.
|
|
146
|
+
lines.push(" The local runtime stays free. This lifts the limits on it.");
|
|
147
|
+
lines.push("");
|
|
148
|
+
|
|
149
|
+
write(lines.join("\n") + "\n");
|
|
150
|
+
return { tier: current.id, target: to.id, used, allowance, url };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The one-off notice printed when a limit is actually hit. */
|
|
154
|
+
export function limitNotice(gateResult, { tier, seats } = {}) {
|
|
155
|
+
const to = nextTier(tier ?? "free");
|
|
156
|
+
return [
|
|
157
|
+
`[cirvix] ${gateResult.reason}`,
|
|
158
|
+
` ${priceLine(to, seats)} — cirvix upgrade ${to}`,
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { TIERS };
|