@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,225 @@
1
+ /**
2
+ * The daily decision counter, and the licence it is measured against.
3
+ *
4
+ * Both live in the workspace's `.cirvix/` directory, alongside the audit chain
5
+ * — the same directory `init` creates and the same one a user can inspect.
6
+ * Nothing is sent anywhere: the count is local because the enforcement is
7
+ * local, and a security tool that phones home to decide whether it is allowed
8
+ * to protect you is a worse product than one that does not.
9
+ *
10
+ * WRITES ARE DEBOUNCED, READS ARE NOT
11
+ *
12
+ * The counter is consulted on every decision and the hot path is measured in
13
+ * microseconds, so it is held in memory and flushed on an interval and at
14
+ * exit. Writing synchronously per decision would put a filesystem round trip
15
+ * inside the enforcement path, which is the one place this product cannot
16
+ * afford one.
17
+ *
18
+ * The cost of that choice is honest: a hard kill (SIGKILL, power loss) loses
19
+ * at most one flush interval of counted decisions, and the user gets those
20
+ * back. Losing a few in the customer's favour is the correct direction for
21
+ * rounding error in a quota.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import { DEFAULT_TIER, dayKey, tierFor } from "./entitlements.mjs";
28
+
29
+ const STATE_DIR = ".cirvix";
30
+ const METER_FILE = "meter.json";
31
+ const LICENCE_FILE = "licence.json";
32
+
33
+ /** How often the in-memory count is written through. */
34
+ const FLUSH_MS = 5_000;
35
+
36
+ function stateDir(cwd) {
37
+ return join(cwd, STATE_DIR);
38
+ }
39
+
40
+ function readJson(path, fallback) {
41
+ try {
42
+ if (!existsSync(path)) return fallback;
43
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
44
+ return parsed && typeof parsed === "object" ? parsed : fallback;
45
+ } catch {
46
+ // A corrupt state file must not take the runtime down. Losing a count is
47
+ // recoverable; refusing to start because a JSON file is malformed is not.
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reads the licence, defaulting to Free.
54
+ *
55
+ * An unreadable, missing, or malformed licence resolves to Free rather than
56
+ * to the last known paid tier. That is deliberate: the failure mode of a
57
+ * corrupt file must not be a free upgrade, and Free is a working product
58
+ * rather than a lockout, so defaulting down costs the user nothing they
59
+ * cannot immediately fix with `cirvix upgrade`.
60
+ */
61
+ export function readLicence(cwd = process.cwd()) {
62
+ const raw = readJson(join(stateDir(cwd), LICENCE_FILE), null);
63
+ if (!raw) return { tier: DEFAULT_TIER, seats: 1, source: "default" };
64
+ const tier = tierFor(raw.tier);
65
+ return {
66
+ tier: tier.id,
67
+ seats: Number(raw.seats) > 0 ? Number(raw.seats) : (tier.seatsIncluded ?? 1),
68
+ customerId: raw.customerId ?? null,
69
+ source: "file",
70
+ };
71
+ }
72
+
73
+ export function writeLicence(licence, cwd = process.cwd()) {
74
+ const dir = stateDir(cwd);
75
+ mkdirSync(dir, { recursive: true });
76
+ const tier = tierFor(licence.tier);
77
+ const body = {
78
+ tier: tier.id,
79
+ seats: Number(licence.seats) > 0 ? Number(licence.seats) : (tier.seatsIncluded ?? 1),
80
+ customerId: licence.customerId ?? null,
81
+ updated: new Date().toISOString(),
82
+ };
83
+ writeFileSync(join(dir, LICENCE_FILE), JSON.stringify(body, null, 2) + "\n", "utf8");
84
+ return body;
85
+ }
86
+
87
+ /**
88
+ * The counter.
89
+ *
90
+ * One instance per runtime process. `count()` is the hot path and does no I/O.
91
+ */
92
+ export class Meter {
93
+ #cwd;
94
+ #path;
95
+ #day;
96
+ #used;
97
+ #dirty = false;
98
+ #timer = null;
99
+ #nudgedOn = null;
100
+
101
+ constructor({ cwd = process.cwd(), now = () => new Date() } = {}) {
102
+ this.#cwd = cwd;
103
+ this.#path = join(stateDir(cwd), METER_FILE);
104
+ this.now = now;
105
+
106
+ const stored = readJson(this.#path, null);
107
+ const today = dayKey(this.now());
108
+ // A stored count from a previous day is not carried over — that is what
109
+ // "resets at 00:00 UTC" means, and the copy on the pricing page says so.
110
+ this.#day = today;
111
+ this.#used = stored && stored.day === today ? Number(stored.used) || 0 : 0;
112
+ this.#nudgedOn = stored && stored.day === today ? (stored.nudgedOn ?? null) : null;
113
+ }
114
+
115
+ /** Today's count, rolling the day over if the process has outlived it. */
116
+ used() {
117
+ const today = dayKey(this.now());
118
+ if (today !== this.#day) {
119
+ this.#day = today;
120
+ this.#used = 0;
121
+ this.#dirty = true;
122
+ }
123
+ return this.#used;
124
+ }
125
+
126
+ /**
127
+ * Whether the one soft nudge for today has yet to be shown, marking it shown.
128
+ *
129
+ * The flag lives beside the count and rolls over with it, so a new day gets
130
+ * exactly one nudge. Asking and marking are the same call deliberately: two
131
+ * calls invite a caller that asks, decides not to print, and leaves the day
132
+ * marked — or worse, prints without marking and nags on every decision.
133
+ */
134
+ shouldNudge() {
135
+ this.used(); // rolls the day over if the process has outlived it
136
+ if (this.#nudgedOn === this.#day) return false;
137
+ this.#nudgedOn = this.#day;
138
+ this.#dirty = true;
139
+ this.#schedule();
140
+ return true;
141
+ }
142
+
143
+ /** Records one decision and returns the new total. */
144
+ count(n = 1) {
145
+ const used = this.used();
146
+ this.#used = used + n;
147
+ this.#dirty = true;
148
+ this.#schedule();
149
+ return this.#used;
150
+ }
151
+
152
+ #schedule() {
153
+ if (this.#timer) return;
154
+ this.#timer = setTimeout(() => {
155
+ this.#timer = null;
156
+ this.flush();
157
+ }, FLUSH_MS);
158
+ // Never hold the process open for a counter flush.
159
+ this.#timer.unref?.();
160
+ }
161
+
162
+ flush() {
163
+ if (!this.#dirty) return;
164
+ try {
165
+ mkdirSync(stateDir(this.#cwd), { recursive: true });
166
+ writeFileSync(
167
+ this.#path,
168
+ JSON.stringify({ day: this.#day, used: this.#used, nudgedOn: this.#nudgedOn }, null, 2) +
169
+ "\n",
170
+ "utf8",
171
+ );
172
+ this.#dirty = false;
173
+ } catch {
174
+ // A read-only or full disk must not break enforcement. The count stays
175
+ // in memory and the process keeps deciding; the quota is simply measured
176
+ // from this process's start rather than from midnight.
177
+ }
178
+ }
179
+
180
+ /** Flush and stop. Called on shutdown. */
181
+ close() {
182
+ if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
183
+ this.flush();
184
+ }
185
+
186
+ /** Test seam: force a specific state without touching disk semantics. */
187
+ set(day, used) {
188
+ this.#day = day;
189
+ this.#used = used;
190
+ this.#dirty = true;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Concurrent agents.
196
+ *
197
+ * Tracked per process rather than across machines, because that is the only
198
+ * boundary the local runtime can actually see. Two separate installs on two
199
+ * laptops are two Free tiers, which is a licensing question rather than a
200
+ * runtime one, and pretending otherwise would mean building a phone-home this
201
+ * product deliberately does not have.
202
+ */
203
+ export class AgentRegistry {
204
+ #active = new Set();
205
+
206
+ register(agentId) {
207
+ this.#active.add(String(agentId ?? "local"));
208
+ return this.#active.size;
209
+ }
210
+
211
+ release(agentId) {
212
+ this.#active.delete(String(agentId ?? "local"));
213
+ return this.#active.size;
214
+ }
215
+
216
+ /** How many distinct agents have been seen this session. */
217
+ size() {
218
+ return this.#active.size;
219
+ }
220
+
221
+ /** Whether this agent is already counted — an existing agent is never a new one. */
222
+ has(agentId) {
223
+ return this.#active.has(String(agentId ?? "local"));
224
+ }
225
+ }