@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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each tier is entitled to — the single source of truth in the runtime.
|
|
3
|
+
*
|
|
4
|
+
* THE NUMBERS HERE MUST MATCH THE PRICING PAGE. Two copies of a limit
|
|
5
|
+
* eventually disagree, and the direction they disagree in is always the one
|
|
6
|
+
* that embarrasses you: a customer who paid for 15,000 and gets 2,000. The
|
|
7
|
+
* pricing page renders from `assets/pricing.js`; this table is the runtime's
|
|
8
|
+
* mirror of it, and `entitlements.test.mjs` pins every figure so a change on
|
|
9
|
+
* one side without the other fails a test rather than a customer.
|
|
10
|
+
*
|
|
11
|
+
* WHAT HAPPENS WHEN THE QUOTA RUNS OUT
|
|
12
|
+
*
|
|
13
|
+
* The call is DENIED. It is not allowed through unchecked.
|
|
14
|
+
*
|
|
15
|
+
* That is worth stating loudly because the alternative is genuinely tempting —
|
|
16
|
+
* "don't break the customer's production over billing" — and it is the wrong
|
|
17
|
+
* answer for this product specifically. A security control that stops
|
|
18
|
+
* enforcing when a counter runs out is not a degraded product, it is an
|
|
19
|
+
* absent one: the agent keeps running, the tool calls keep landing, and
|
|
20
|
+
* nothing is checking them. The failure would be invisible precisely when it
|
|
21
|
+
* mattered. Everywhere else in this codebase an indeterminate or exhausted
|
|
22
|
+
* state resolves to a refusal, and a commercial limit is not the place to
|
|
23
|
+
* invent an exception.
|
|
24
|
+
*
|
|
25
|
+
* So over-quota is a deny with its own reason code, which is loud, recoverable
|
|
26
|
+
* (upgrade, or wait for the reset), and never silently permissive.
|
|
27
|
+
*
|
|
28
|
+
* WHAT THIS CANNOT DO, stated plainly
|
|
29
|
+
*
|
|
30
|
+
* The counter lives on the user's machine, in a file, inside a package they
|
|
31
|
+
* have on disk. Anyone who wants to edit it can. This is honour-system
|
|
32
|
+
* metering and calling it anything else would be dishonest.
|
|
33
|
+
*
|
|
34
|
+
* It is still worth having: the overwhelming majority of users never touch it,
|
|
35
|
+
* the prompt at the limit is the conversion moment the pricing depends on, and
|
|
36
|
+
* the features that genuinely cannot be faked locally — shared policy, team
|
|
37
|
+
* approvals, org vault, hosted audit — are gated on the server side where
|
|
38
|
+
* bypassing them is not a matter of editing a JSON file.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Ordered least → most capable. Used for `atLeast` comparisons. */
|
|
42
|
+
export const TIER_ORDER = ["free", "starter", "pro", "team", "enterprise"];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `decisionsPerDay` is per SEAT for tiers where `perSeat` is true, and
|
|
46
|
+
* absolute otherwise. `null` means uncapped.
|
|
47
|
+
*
|
|
48
|
+
* `agents` is the number of concurrent agent processes; `null` is unlimited.
|
|
49
|
+
*
|
|
50
|
+
* WHICH OF THESE FIELDS ACTUALLY ENFORCE SOMETHING
|
|
51
|
+
*
|
|
52
|
+
* Not all of them do, and the ones that do not were sold on the pricing page
|
|
53
|
+
* as though they did. A number in this table is a promise; nothing here makes
|
|
54
|
+
* it true by itself. Keep this list honest when adding a field.
|
|
55
|
+
*
|
|
56
|
+
* ENFORCED locally, by entitlement-gate.mjs:
|
|
57
|
+
* decisionsPerDay, agents
|
|
58
|
+
*
|
|
59
|
+
* ENFORCED, but by architecture rather than by this table:
|
|
60
|
+
* persistentSecrets — the local Vault holds material in process memory for
|
|
61
|
+
* the life of a run, so a free handle cannot survive a restart whatever
|
|
62
|
+
* this field says. Persistence is a control-plane feature: SecretsClient,
|
|
63
|
+
* not Vault.
|
|
64
|
+
*
|
|
65
|
+
* DESCRIPTIVE ONLY — read by upgrade.mjs and prompts.mjs to say what a tier
|
|
66
|
+
* would give you, and by nothing that enforces:
|
|
67
|
+
* auditRetentionHours — nothing prunes the local chain, on any tier. It is
|
|
68
|
+
* hash-linked, and truncating it costs verifiability back to genesis, so
|
|
69
|
+
* this is a deliberate hold rather than an oversight. The pricing page
|
|
70
|
+
* now reads "life of deployment" on every tier, which is what the code
|
|
71
|
+
* does; the paid ladder is hosted retention and export.
|
|
72
|
+
* secretTtlHours — Vault.issue() accepts a ttlSeconds and no caller passes
|
|
73
|
+
* one derived from the tier.
|
|
74
|
+
* policyPacks — not counted anywhere.
|
|
75
|
+
* sharedPolicy, approvals, attestation, shareableReplay — control-plane
|
|
76
|
+
* features, enforced server-side where editing a local JSON file cannot
|
|
77
|
+
* reach them.
|
|
78
|
+
*
|
|
79
|
+
* `persistentSecrets: false` means handles do not survive a restart — the
|
|
80
|
+
* single strongest conversion lever for anyone who actually uses secrets, and
|
|
81
|
+
* the one free-tier limit that is felt within a day rather than a week.
|
|
82
|
+
*/
|
|
83
|
+
export const TIERS = {
|
|
84
|
+
free: {
|
|
85
|
+
id: "free",
|
|
86
|
+
name: "Free",
|
|
87
|
+
decisionsPerDay: 100,
|
|
88
|
+
perSeat: false,
|
|
89
|
+
agents: 1,
|
|
90
|
+
seatsIncluded: 1,
|
|
91
|
+
auditRetentionHours: 12,
|
|
92
|
+
persistentSecrets: false,
|
|
93
|
+
secretTtlHours: 2,
|
|
94
|
+
approvals: false,
|
|
95
|
+
attestation: false,
|
|
96
|
+
shareableReplay: false,
|
|
97
|
+
policyPacks: 2,
|
|
98
|
+
sharedPolicy: false,
|
|
99
|
+
},
|
|
100
|
+
starter: {
|
|
101
|
+
id: "starter",
|
|
102
|
+
name: "Starter",
|
|
103
|
+
decisionsPerDay: 1_500,
|
|
104
|
+
perSeat: false,
|
|
105
|
+
agents: 2,
|
|
106
|
+
seatsIncluded: 1,
|
|
107
|
+
auditRetentionHours: 24 * 7,
|
|
108
|
+
persistentSecrets: true,
|
|
109
|
+
secretTtlHours: null,
|
|
110
|
+
approvals: false,
|
|
111
|
+
attestation: false,
|
|
112
|
+
shareableReplay: "local",
|
|
113
|
+
policyPacks: 6,
|
|
114
|
+
sharedPolicy: false,
|
|
115
|
+
},
|
|
116
|
+
pro: {
|
|
117
|
+
id: "pro",
|
|
118
|
+
name: "Pro",
|
|
119
|
+
decisionsPerDay: 12_000,
|
|
120
|
+
perSeat: false,
|
|
121
|
+
agents: 8,
|
|
122
|
+
seatsIncluded: 1,
|
|
123
|
+
auditRetentionHours: 24 * 90,
|
|
124
|
+
persistentSecrets: true,
|
|
125
|
+
secretTtlHours: null,
|
|
126
|
+
approvals: true,
|
|
127
|
+
attestation: true,
|
|
128
|
+
shareableReplay: "full",
|
|
129
|
+
policyPacks: null,
|
|
130
|
+
sharedPolicy: "basic",
|
|
131
|
+
},
|
|
132
|
+
team: {
|
|
133
|
+
id: "team",
|
|
134
|
+
name: "Team",
|
|
135
|
+
decisionsPerDay: 40_000,
|
|
136
|
+
perSeat: true,
|
|
137
|
+
agents: null,
|
|
138
|
+
seatsIncluded: 3,
|
|
139
|
+
auditRetentionHours: 24 * 365,
|
|
140
|
+
persistentSecrets: true,
|
|
141
|
+
secretTtlHours: null,
|
|
142
|
+
approvals: true,
|
|
143
|
+
attestation: true,
|
|
144
|
+
shareableReplay: "team",
|
|
145
|
+
policyPacks: null,
|
|
146
|
+
sharedPolicy: "full",
|
|
147
|
+
},
|
|
148
|
+
enterprise: {
|
|
149
|
+
id: "enterprise",
|
|
150
|
+
name: "Enterprise",
|
|
151
|
+
// Not sold from a rate card. `null` is "uncapped", never "zero" — the gate
|
|
152
|
+
// below has to treat it as unlimited or an Enterprise contract would be
|
|
153
|
+
// the most restricted tier in the table.
|
|
154
|
+
decisionsPerDay: null,
|
|
155
|
+
perSeat: false,
|
|
156
|
+
agents: null,
|
|
157
|
+
seatsIncluded: null,
|
|
158
|
+
auditRetentionHours: null,
|
|
159
|
+
persistentSecrets: true,
|
|
160
|
+
secretTtlHours: null,
|
|
161
|
+
approvals: true,
|
|
162
|
+
attestation: true,
|
|
163
|
+
shareableReplay: "team",
|
|
164
|
+
policyPacks: null,
|
|
165
|
+
sharedPolicy: "full",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The tier an unlicensed install runs on. Free, always — never a paid default. */
|
|
170
|
+
export const DEFAULT_TIER = "free";
|
|
171
|
+
|
|
172
|
+
export function tierFor(id) {
|
|
173
|
+
return TIERS[String(id ?? "").toLowerCase()] ?? TIERS[DEFAULT_TIER];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True when `id` is at least as capable as `required`. */
|
|
177
|
+
export function tierAtLeast(id, required) {
|
|
178
|
+
const a = TIER_ORDER.indexOf(tierFor(id).id);
|
|
179
|
+
const b = TIER_ORDER.indexOf(tierFor(required).id);
|
|
180
|
+
return a >= 0 && b >= 0 && a >= b;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The daily decision allowance for a licence, seats included.
|
|
185
|
+
*
|
|
186
|
+
* Seats below the tier's minimum are raised to it rather than rejected: a
|
|
187
|
+
* Team licence recording two seats is a data problem, and answering it by
|
|
188
|
+
* cutting the customer's allowance is the wrong way round.
|
|
189
|
+
*/
|
|
190
|
+
export function dailyAllowance(licence = {}) {
|
|
191
|
+
const tier = tierFor(licence.tier);
|
|
192
|
+
if (tier.decisionsPerDay === null) return null; // uncapped
|
|
193
|
+
if (!tier.perSeat) return tier.decisionsPerDay;
|
|
194
|
+
const seats = Math.max(Number(licence.seats) || 0, tier.seatsIncluded ?? 1);
|
|
195
|
+
return tier.decisionsPerDay * seats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Reasons a call can be refused for commercial rather than policy grounds. */
|
|
199
|
+
export const GATE = {
|
|
200
|
+
QUOTA_EXHAUSTED: "quota_exhausted",
|
|
201
|
+
AGENT_LIMIT: "agent_limit",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether one more decision may be recorded.
|
|
206
|
+
*
|
|
207
|
+
* `used` is today's count. Returns the shape the pipeline needs to build a
|
|
208
|
+
* refusal without re-deriving anything.
|
|
209
|
+
*/
|
|
210
|
+
export function checkQuota(licence, used) {
|
|
211
|
+
const tier = tierFor(licence.tier);
|
|
212
|
+
const allowance = dailyAllowance(licence);
|
|
213
|
+
if (allowance === null) {
|
|
214
|
+
return { ok: true, allowance: null, used, remaining: null, tier: tier.id };
|
|
215
|
+
}
|
|
216
|
+
const remaining = Math.max(0, allowance - used);
|
|
217
|
+
if (used >= allowance) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
gate: GATE.QUOTA_EXHAUSTED,
|
|
221
|
+
allowance,
|
|
222
|
+
used,
|
|
223
|
+
remaining: 0,
|
|
224
|
+
tier: tier.id,
|
|
225
|
+
reason:
|
|
226
|
+
`Daily limit reached: ${allowance.toLocaleString("en-US")} decisions on ${tier.name}. ` +
|
|
227
|
+
`The counter resets at 00:00 UTC.`,
|
|
228
|
+
remediation:
|
|
229
|
+
tier.id === "enterprise"
|
|
230
|
+
? "Contact your account owner."
|
|
231
|
+
: `Run \`cirvix upgrade ${nextTier(tier.id)}\` for a higher daily allowance.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, allowance, used, remaining, tier: tier.id };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Whether another concurrent agent may start. */
|
|
238
|
+
export function checkAgents(licence, activeAgents) {
|
|
239
|
+
const tier = tierFor(licence.tier);
|
|
240
|
+
if (tier.agents === null) return { ok: true, limit: null, active: activeAgents };
|
|
241
|
+
if (activeAgents >= tier.agents) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
gate: GATE.AGENT_LIMIT,
|
|
245
|
+
limit: tier.agents,
|
|
246
|
+
active: activeAgents,
|
|
247
|
+
tier: tier.id,
|
|
248
|
+
reason: `${tier.name} allows ${tier.agents} concurrent agent${tier.agents === 1 ? "" : "s"}.`,
|
|
249
|
+
remediation: `Run \`cirvix upgrade ${nextTier(tier.id)}\` for more.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, limit: tier.agents, active: activeAgents };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The tier a user would move to next. Enterprise has nowhere above it. */
|
|
256
|
+
export function nextTier(id) {
|
|
257
|
+
const i = TIER_ORDER.indexOf(tierFor(id).id);
|
|
258
|
+
return i >= 0 && i < TIER_ORDER.length - 1 ? TIER_ORDER[i + 1] : TIER_ORDER[TIER_ORDER.length - 1];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether a named capability is available on this licence.
|
|
263
|
+
*
|
|
264
|
+
* Feature keys mirror the pricing matrix. An unknown key returns false, so a
|
|
265
|
+
* capability added to the product but not to this table is unavailable rather
|
|
266
|
+
* than accidentally universal — the same fail-closed rule the permission
|
|
267
|
+
* layer uses.
|
|
268
|
+
*/
|
|
269
|
+
export function can(licence, feature) {
|
|
270
|
+
const tier = tierFor(licence?.tier);
|
|
271
|
+
switch (feature) {
|
|
272
|
+
case "persistentSecrets": return tier.persistentSecrets === true;
|
|
273
|
+
case "approvals": return tier.approvals === true;
|
|
274
|
+
case "attestation": return tier.attestation === true;
|
|
275
|
+
case "sharedPolicy": return tier.sharedPolicy !== false;
|
|
276
|
+
case "shareableReplay": return tier.shareableReplay !== false;
|
|
277
|
+
default: return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** UTC calendar day key, `YYYY-MM-DD`. The reset boundary the copy promises. */
|
|
282
|
+
export function dayKey(at = new Date()) {
|
|
283
|
+
const d = at instanceof Date ? at : new Date(at);
|
|
284
|
+
return d.toISOString().slice(0, 10);
|
|
285
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal formatting.
|
|
3
|
+
*
|
|
4
|
+
* Colour is suppressed when stdout is not a TTY, when `NO_COLOR` is set, or
|
|
5
|
+
* when `TERM=dumb` — so piping to a file or a CI log produces clean text
|
|
6
|
+
* rather than escape sequences. `FORCE_COLOR` overrides for the cases where a
|
|
7
|
+
* CI runner does support colour but does not present as a TTY.
|
|
8
|
+
*
|
|
9
|
+
* The palette mirrors the product's chroma rule: green means permitted, red
|
|
10
|
+
* means denied, amber means held. Nothing decorative uses them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const forced = process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true";
|
|
14
|
+
const disabled =
|
|
15
|
+
!forced &&
|
|
16
|
+
(process.env.NO_COLOR !== undefined ||
|
|
17
|
+
process.env.TERM === "dumb" ||
|
|
18
|
+
!process.stdout.isTTY);
|
|
19
|
+
|
|
20
|
+
const wrap = (open, close) => (s) =>
|
|
21
|
+
disabled ? String(s) : `[${open}m${s}[${close}m`;
|
|
22
|
+
|
|
23
|
+
export const bold = wrap(1, 22);
|
|
24
|
+
export const dim = wrap(2, 22);
|
|
25
|
+
export const red = wrap(31, 39);
|
|
26
|
+
export const green = wrap(32, 39);
|
|
27
|
+
export const amber = wrap(33, 39);
|
|
28
|
+
export const blue = wrap(34, 39);
|
|
29
|
+
|
|
30
|
+
/** "1 server" / "3 servers" — avoids the "1 servers" that reads as a bug. */
|
|
31
|
+
export function plural(n, noun, pluralForm) {
|
|
32
|
+
return `${n} ${n === 1 ? noun : (pluralForm ?? noun + "s")}`;
|
|
33
|
+
}
|