@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,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection.
|
|
3
|
+
*
|
|
4
|
+
* Finds the agent runtimes, MCP server configurations, and credential files
|
|
5
|
+
* that are actually present on this machine. Everything here is READ-ONLY —
|
|
6
|
+
* `scan` must never write, never phone home, and never require an account.
|
|
7
|
+
* That property is the entire reason a developer will run it, so it is
|
|
8
|
+
* enforced structurally: this module imports no network API and opens nothing
|
|
9
|
+
* for writing.
|
|
10
|
+
*
|
|
11
|
+
* Detection is filesystem inspection, not magic. Each probe declares the exact
|
|
12
|
+
* path it looks at so a reader can verify what was touched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, resolve, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Best-effort read; a missing or unreadable file is simply "not present". */
|
|
20
|
+
async function readJson(path) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function exists(path) {
|
|
29
|
+
try {
|
|
30
|
+
await stat(path);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HOME = homedir();
|
|
38
|
+
|
|
39
|
+
/* -------------------------------------------------------------------------- */
|
|
40
|
+
/* Agent runtimes */
|
|
41
|
+
/* -------------------------------------------------------------------------- */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where each runtime keeps its configuration. `mcpKey` names the property that
|
|
45
|
+
* holds MCP server definitions, which differs between tools.
|
|
46
|
+
*/
|
|
47
|
+
const RUNTIME_PROBES = [
|
|
48
|
+
{
|
|
49
|
+
id: "claude-code",
|
|
50
|
+
label: "Claude Code",
|
|
51
|
+
paths: [join(HOME, ".claude", "settings.json"), join(HOME, ".claude.json")],
|
|
52
|
+
mcpKey: "mcpServers",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "cursor",
|
|
56
|
+
label: "Cursor",
|
|
57
|
+
paths: [join(HOME, ".cursor", "mcp.json")],
|
|
58
|
+
mcpKey: "mcpServers",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "windsurf",
|
|
62
|
+
label: "Windsurf",
|
|
63
|
+
paths: [join(HOME, ".codeium", "windsurf", "mcp_config.json")],
|
|
64
|
+
mcpKey: "mcpServers",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "cline",
|
|
68
|
+
label: "Cline",
|
|
69
|
+
paths: [
|
|
70
|
+
join(HOME, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
|
|
71
|
+
join(HOME, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
|
|
72
|
+
],
|
|
73
|
+
mcpKey: "mcpServers",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "vscode",
|
|
77
|
+
label: "VS Code (MCP)",
|
|
78
|
+
paths: [
|
|
79
|
+
join(HOME, "Library", "Application Support", "Code", "User", "mcp.json"),
|
|
80
|
+
join(HOME, "AppData", "Roaming", "Code", "User", "mcp.json"),
|
|
81
|
+
],
|
|
82
|
+
mcpKey: "servers",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Project-level markers that indicate an agent framework in the tree. */
|
|
87
|
+
const FRAMEWORK_MARKERS = [
|
|
88
|
+
{ id: "langchain", label: "LangChain", deps: ["langchain", "@langchain/core", "langgraph"] },
|
|
89
|
+
{ id: "crewai", label: "CrewAI", deps: ["crewai"] },
|
|
90
|
+
{ id: "openai-agents", label: "OpenAI Agents SDK", deps: ["@openai/agents", "openai-agents"] },
|
|
91
|
+
{ id: "vercel-ai", label: "Vercel AI SDK", deps: ["ai"] },
|
|
92
|
+
{ id: "anthropic", label: "Anthropic SDK", deps: ["@anthropic-ai/sdk"] },
|
|
93
|
+
{ id: "mcp-sdk", label: "MCP SDK", deps: ["@modelcontextprotocol/sdk"] },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export async function detectRuntimes() {
|
|
97
|
+
const found = [];
|
|
98
|
+
|
|
99
|
+
for (const probe of RUNTIME_PROBES) {
|
|
100
|
+
// Merge across every config path a runtime uses rather than stopping at
|
|
101
|
+
// the first that exists. Claude Code, for example, has both
|
|
102
|
+
// ~/.claude/settings.json and ~/.claude.json, and MCP servers may live in
|
|
103
|
+
// either — breaking early reports "0 MCP servers" for a machine that has
|
|
104
|
+
// several, which is exactly the false clean bill this tool must not give.
|
|
105
|
+
const paths = [];
|
|
106
|
+
const servers = {};
|
|
107
|
+
|
|
108
|
+
for (const path of probe.paths) {
|
|
109
|
+
if (!(await exists(path))) continue;
|
|
110
|
+
paths.push(path);
|
|
111
|
+
const config = await readJson(path);
|
|
112
|
+
Object.assign(servers, config?.[probe.mcpKey] ?? {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (paths.length === 0) continue;
|
|
116
|
+
|
|
117
|
+
found.push({
|
|
118
|
+
id: probe.id,
|
|
119
|
+
label: probe.label,
|
|
120
|
+
path: paths[0],
|
|
121
|
+
paths,
|
|
122
|
+
governed: isGoverned(servers),
|
|
123
|
+
serverCount: Object.keys(servers).length,
|
|
124
|
+
servers,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A runtime is governed when its MCP traffic routes through the gateway. */
|
|
132
|
+
function isGoverned(servers) {
|
|
133
|
+
return Object.entries(servers).some(
|
|
134
|
+
([name, def]) =>
|
|
135
|
+
name === "cirvix" ||
|
|
136
|
+
(typeof def?.command === "string" && def.command.includes("cirvix")),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Detects agent frameworks declared in the project at `cwd`. Reads
|
|
142
|
+
* package.json and requirements.txt / pyproject.toml — declaration files only,
|
|
143
|
+
* never source.
|
|
144
|
+
*/
|
|
145
|
+
export async function detectFrameworks(cwd) {
|
|
146
|
+
const found = [];
|
|
147
|
+
|
|
148
|
+
const pkg = await readJson(join(cwd, "package.json"));
|
|
149
|
+
if (pkg) {
|
|
150
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
151
|
+
for (const marker of FRAMEWORK_MARKERS) {
|
|
152
|
+
const hit = marker.deps.find((d) => d in deps);
|
|
153
|
+
if (hit) found.push({ id: marker.id, label: marker.label, via: `package.json → ${hit}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const file of ["requirements.txt", "pyproject.toml"]) {
|
|
158
|
+
const path = join(cwd, file);
|
|
159
|
+
if (!(await exists(path))) continue;
|
|
160
|
+
let text = "";
|
|
161
|
+
try {
|
|
162
|
+
text = await readFile(path, "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const marker of FRAMEWORK_MARKERS) {
|
|
167
|
+
if (found.some((f) => f.id === marker.id)) continue;
|
|
168
|
+
const hit = marker.deps.find((d) => new RegExp(`(^|[\\s"'=])${escapeRe(d)}\\b`, "m").test(text));
|
|
169
|
+
if (hit) found.push({ id: marker.id, label: marker.label, via: `${file} → ${hit}` });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return found;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function escapeRe(s) {
|
|
177
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* -------------------------------------------------------------------------- */
|
|
181
|
+
/* MCP servers */
|
|
182
|
+
/* -------------------------------------------------------------------------- */
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Flattens every MCP server across every detected runtime into one list,
|
|
186
|
+
* deduplicated by name+command. Each entry keeps the runtimes that reference
|
|
187
|
+
* it, because the same server configured in three editors is one trust
|
|
188
|
+
* boundary with three doors.
|
|
189
|
+
*/
|
|
190
|
+
export function collectMcpServers(runtimes) {
|
|
191
|
+
const byKey = new Map();
|
|
192
|
+
|
|
193
|
+
for (const runtime of runtimes) {
|
|
194
|
+
for (const [name, def] of Object.entries(runtime.servers ?? {})) {
|
|
195
|
+
if (name === "cirvix") continue; // the gateway itself
|
|
196
|
+
const transport = def?.url ? "http" : "stdio";
|
|
197
|
+
const command = def?.command ?? def?.url ?? "";
|
|
198
|
+
const key = `${name}::${command}`;
|
|
199
|
+
const existing = byKey.get(key);
|
|
200
|
+
if (existing) {
|
|
201
|
+
existing.runtimes.push(runtime.label);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
byKey.set(key, {
|
|
205
|
+
name,
|
|
206
|
+
transport,
|
|
207
|
+
command,
|
|
208
|
+
args: def?.args ?? [],
|
|
209
|
+
runtimes: [runtime.label],
|
|
210
|
+
// Env blocks in MCP config frequently carry raw API keys.
|
|
211
|
+
envKeys: Object.keys(def?.env ?? {}),
|
|
212
|
+
scope: inferScope(def),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return [...byKey.values()];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Filesystem-style servers are commonly pointed at a whole home directory or
|
|
222
|
+
* `/`, which grants an agent far more reach than the person configuring it
|
|
223
|
+
* usually intends. Flagging that is one of the scanner's most useful outputs.
|
|
224
|
+
*/
|
|
225
|
+
function inferScope(def) {
|
|
226
|
+
const args = def?.args ?? [];
|
|
227
|
+
const paths = args.filter((a) => typeof a === "string" && (a.startsWith("/") || /^[A-Za-z]:[\\/]/.test(a)));
|
|
228
|
+
if (paths.length === 0) return null;
|
|
229
|
+
const widest = paths.find((p) => p === "/" || p === HOME || /^[A-Za-z]:[\\/]?$/.test(p));
|
|
230
|
+
return { paths, broad: Boolean(widest), widest: widest ?? null };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* -------------------------------------------------------------------------- */
|
|
234
|
+
/* Credentials reachable from agent context */
|
|
235
|
+
/* -------------------------------------------------------------------------- */
|
|
236
|
+
|
|
237
|
+
/** Files whose presence means an agent with file read can obtain secrets. */
|
|
238
|
+
const CREDENTIAL_PROBES = [
|
|
239
|
+
{ path: join(HOME, ".aws", "credentials"), label: "AWS credentials", severity: "high" },
|
|
240
|
+
{ path: join(HOME, ".ssh", "id_rsa"), label: "SSH private key", severity: "high" },
|
|
241
|
+
{ path: join(HOME, ".ssh", "id_ed25519"), label: "SSH private key", severity: "high" },
|
|
242
|
+
{ path: join(HOME, ".kube", "config"), label: "Kubernetes config", severity: "high" },
|
|
243
|
+
{ path: join(HOME, ".docker", "config.json"), label: "Docker registry auth", severity: "medium" },
|
|
244
|
+
{ path: join(HOME, ".npmrc"), label: "npm token", severity: "medium" },
|
|
245
|
+
{ path: join(HOME, ".netrc"), label: "netrc credentials", severity: "medium" },
|
|
246
|
+
{ path: join(HOME, ".config", "gcloud", "credentials.db"), label: "gcloud credentials", severity: "high" },
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/** Keys that look like live secret material rather than config. */
|
|
250
|
+
const SECRET_KEY_RE =
|
|
251
|
+
/(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|APIKEY|ACCESS_KEY|CLIENT_SECRET|DSN|CREDENTIAL)/i;
|
|
252
|
+
|
|
253
|
+
/** Values that are obviously placeholders shouldn't be reported as secrets. */
|
|
254
|
+
const PLACEHOLDER_RE =
|
|
255
|
+
/^(|x{3,}|\.{3,}|<.*>|\$\{.*\}|your[-_ ]|changeme|placeholder|todo|example|test|dummy|none|null|undefined)$/i;
|
|
256
|
+
|
|
257
|
+
export async function detectCredentials(cwd) {
|
|
258
|
+
const findings = [];
|
|
259
|
+
|
|
260
|
+
for (const probe of CREDENTIAL_PROBES) {
|
|
261
|
+
if (await exists(probe.path)) {
|
|
262
|
+
findings.push({
|
|
263
|
+
kind: "credential-file",
|
|
264
|
+
path: probe.path,
|
|
265
|
+
label: probe.label,
|
|
266
|
+
severity: probe.severity,
|
|
267
|
+
detail: "On disk and readable by any agent with filesystem access",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// .env files in the working tree — the most common real exposure.
|
|
273
|
+
let entries = [];
|
|
274
|
+
try {
|
|
275
|
+
entries = await readdir(cwd, { withFileTypes: true });
|
|
276
|
+
} catch {
|
|
277
|
+
entries = [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (!entry.isFile()) continue;
|
|
282
|
+
if (!/^\.env($|\.)/.test(entry.name)) continue;
|
|
283
|
+
if (/\.example$|\.sample$|\.template$/.test(entry.name)) continue;
|
|
284
|
+
|
|
285
|
+
const path = join(cwd, entry.name);
|
|
286
|
+
const keys = await readSecretKeys(path);
|
|
287
|
+
if (keys.length === 0) continue;
|
|
288
|
+
|
|
289
|
+
findings.push({
|
|
290
|
+
kind: "dotenv",
|
|
291
|
+
path,
|
|
292
|
+
label: entry.name,
|
|
293
|
+
severity: /production|prod/.test(entry.name) ? "high" : "medium",
|
|
294
|
+
// Key NAMES only. The scanner never records, prints, or transmits a
|
|
295
|
+
// secret VALUE — reporting a leak by leaking it would be absurd.
|
|
296
|
+
detail: `${keys.length} secret-shaped ${keys.length === 1 ? "key" : "keys"}: ${keys.slice(0, 4).join(", ")}${keys.length > 4 ? "…" : ""}`,
|
|
297
|
+
keys,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return findings;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function readSecretKeys(path) {
|
|
305
|
+
let text = "";
|
|
306
|
+
try {
|
|
307
|
+
const info = await stat(path);
|
|
308
|
+
if (info.size > 512 * 1024) return []; // not a real .env
|
|
309
|
+
text = await readFile(path, "utf8");
|
|
310
|
+
} catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const keys = [];
|
|
315
|
+
for (const line of text.split(/\r?\n/)) {
|
|
316
|
+
const trimmed = line.trim();
|
|
317
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
318
|
+
const eq = trimmed.indexOf("=");
|
|
319
|
+
if (eq <= 0) continue;
|
|
320
|
+
const key = trimmed.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
321
|
+
const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
322
|
+
if (!SECRET_KEY_RE.test(key)) continue;
|
|
323
|
+
if (PLACEHOLDER_RE.test(value)) continue;
|
|
324
|
+
if (value.length < 8) continue;
|
|
325
|
+
keys.push(key);
|
|
326
|
+
}
|
|
327
|
+
return keys;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* -------------------------------------------------------------------------- */
|
|
331
|
+
|
|
332
|
+
/** True when `child` resolves inside `parent` — used by the path guard. */
|
|
333
|
+
export function isInside(parent, child) {
|
|
334
|
+
const p = resolve(parent);
|
|
335
|
+
const c = resolve(child);
|
|
336
|
+
return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
|
|
337
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commercial gate, in one place.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
*
|
|
6
|
+
* It used to live inline in `Pipeline.submit()` and nowhere else. `Guard` —
|
|
7
|
+
* the shared decision core behind both `guard.wrap()` and the MCP gateway —
|
|
8
|
+
* had no entitlement gate at all, so a Free-tier user going through either of
|
|
9
|
+
* those paths was never metered. `Pipeline` had the gate but the CLI never
|
|
10
|
+
* passed it a licence or a meter, so it never fired there either.
|
|
11
|
+
*
|
|
12
|
+
* The net effect was that the published Free limits were not enforced on any
|
|
13
|
+
* path, and the upgrade prompt that the pricing depends on could not fire. The
|
|
14
|
+
* cause was the same one this codebase has hit before: two decision cores, one
|
|
15
|
+
* of which quietly grew a rule the other did not have.
|
|
16
|
+
*
|
|
17
|
+
* So the rule lives here, both cores call it, and neither can answer the
|
|
18
|
+
* question differently.
|
|
19
|
+
*
|
|
20
|
+
* IT IS AN OVERRIDE, NOT AN EARLY RETURN
|
|
21
|
+
*
|
|
22
|
+
* The call is still parsed, classified and evaluated against policy first, and
|
|
23
|
+
* the refusal is layered on top of that decision. That costs microseconds and
|
|
24
|
+
* buys a record showing what policy WOULD have said, which is what an operator
|
|
25
|
+
* wants when they discover they ran out mid-session.
|
|
26
|
+
*
|
|
27
|
+
* AN EXHAUSTED QUOTA DENIES
|
|
28
|
+
*
|
|
29
|
+
* It does not pass the call through unchecked. A security control that stops
|
|
30
|
+
* enforcing when a counter runs out is not a degraded product, it is an absent
|
|
31
|
+
* one, and the absence would be invisible exactly when it mattered.
|
|
32
|
+
*
|
|
33
|
+
* A GATED CALL IS NOT COUNTED
|
|
34
|
+
*
|
|
35
|
+
* Counting refusals would mean a user who hit the limit could never get back
|
|
36
|
+
* under it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { GATE, checkAgents, checkQuota } from "./entitlements.mjs";
|
|
40
|
+
import { DECISION } from "./decisions.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Applies the quota and concurrent-agent gates to an already-made decision.
|
|
44
|
+
*
|
|
45
|
+
* All three of `licence`, `meter` and `agents` are optional. Supplying none of
|
|
46
|
+
* them — which is what the conformance fixture, the test suite and any
|
|
47
|
+
* embedding library caller do — returns the decision untouched, so this cannot
|
|
48
|
+
* change policy semantics.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} decision the decision policy produced
|
|
51
|
+
* @param {object} ctx
|
|
52
|
+
* @param {object|null} ctx.licence
|
|
53
|
+
* @param {object|null} ctx.meter Meter — consulted, then incremented on a pass
|
|
54
|
+
* @param {object|null} ctx.agents AgentRegistry — concurrent agent tracking
|
|
55
|
+
* @param {string} ctx.agent the agent making this call
|
|
56
|
+
* @returns {object} the decision, overridden if a commercial limit was hit
|
|
57
|
+
*/
|
|
58
|
+
export function applyEntitlements(decision, { licence, meter, agents, agent }) {
|
|
59
|
+
if (licence && meter) {
|
|
60
|
+
const quota = checkQuota(licence, meter.used());
|
|
61
|
+
if (quota.ok) {
|
|
62
|
+
// Counted only once a real policy decision has been produced: that is
|
|
63
|
+
// the thing being sold.
|
|
64
|
+
meter.count(1);
|
|
65
|
+
} else {
|
|
66
|
+
return {
|
|
67
|
+
...decision,
|
|
68
|
+
verdict: "deny",
|
|
69
|
+
decision: DECISION.DENY,
|
|
70
|
+
rule: "quota-exhausted",
|
|
71
|
+
reason: quota.reason,
|
|
72
|
+
remediation: quota.remediation,
|
|
73
|
+
gate: GATE.QUOTA_EXHAUSTED,
|
|
74
|
+
quota: { used: quota.used, allowance: quota.allowance, tier: quota.tier },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Only an agent this session has not already seen can be a new one, or a
|
|
80
|
+
// permitted agent's second call would read as a second agent and every tier
|
|
81
|
+
// would be a one-call tier.
|
|
82
|
+
if (licence && agents && !agents.has(agent)) {
|
|
83
|
+
const seats = checkAgents(licence, agents.size());
|
|
84
|
+
if (seats.ok) {
|
|
85
|
+
agents.register(agent);
|
|
86
|
+
} else {
|
|
87
|
+
return {
|
|
88
|
+
...decision,
|
|
89
|
+
verdict: "deny",
|
|
90
|
+
decision: DECISION.DENY,
|
|
91
|
+
rule: "agent-limit",
|
|
92
|
+
reason: seats.reason,
|
|
93
|
+
remediation: seats.remediation,
|
|
94
|
+
gate: GATE.AGENT_LIMIT,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return decision;
|
|
100
|
+
}
|