@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,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
|
+
}
|