@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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. 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
+ }