@cirvix_ai/agent-control 0.1.5 → 0.2.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/bin/cirvix.mjs CHANGED
@@ -66,6 +66,8 @@ const HELP = `
66
66
  cirvix <command> [options]
67
67
 
68
68
  ${bold("GETTING STARTED")}
69
+ console Interactive runtime authorization (the product UI)
70
+ onboard 10-second guided first run
69
71
  init Detect agents and MCP servers, write a policy, start protecting
70
72
  init --apply Safely wire detected agents with pre-integration backup
71
73
  init --dry-run Preview agent configuration changes without modifying files
@@ -80,6 +82,7 @@ const HELP = `
80
82
  prove <decision-id> Sign a decision into a portable proof artifact
81
83
  verify <proof> Check a proof offline: signature, chain, integrity
82
84
  scan Inventory what is ungoverned on this machine
85
+ theme Change appearance (dark|light|midnight|high-contrast|monochrome)
83
86
 
84
87
  ${bold("ENFORCEMENT")}
85
88
  gateway Run the MCP gateway — intercepts and enforces
@@ -1072,6 +1075,55 @@ async function main() {
1072
1075
  return 0;
1073
1076
  }
1074
1077
 
1078
+ /* ------------------------------------------------------------ console */
1079
+ case "console": {
1080
+ if (typeof flags.theme === "string") {
1081
+ const { setTheme } = await import("../src/core/theme.mjs");
1082
+ try {
1083
+ setTheme(flags.theme);
1084
+ process.env.CIRVIX_THEME = flags.theme;
1085
+ } catch (err) {
1086
+ process.stderr.write(red(` ${err.message}\n`));
1087
+ return 2;
1088
+ }
1089
+ }
1090
+ const rules = await loadRules(flags.policy, cwd);
1091
+ const { consoleCmd } = await import("../src/commands/console.mjs");
1092
+ await consoleCmd({
1093
+ cwd,
1094
+ rules,
1095
+ mode: flags.mode === "audit" ? MODE.AUDIT : MODE.ENFORCE,
1096
+ evalText: typeof flags.eval === "string" ? flags.eval : null,
1097
+ once: Boolean(flags.once ?? flags.eval),
1098
+ });
1099
+ return 0;
1100
+ }
1101
+
1102
+ case "theme": {
1103
+ const { setTheme, THEME_NAMES } = await import("../src/core/theme.mjs");
1104
+ const name = sub ?? flags.set;
1105
+ if (!name) {
1106
+ process.stdout.write(
1107
+ `\n Current theme: ${process.env.CIRVIX_THEME ?? "dark"}\n Available: ${THEME_NAMES.join(", ")}\n\n Usage: cirvix theme <name>\n Persist it: CIRVIX_THEME=${THEME_NAMES[0]} cirvix console\n\n`,
1108
+ );
1109
+ return 0;
1110
+ }
1111
+ try {
1112
+ setTheme(name);
1113
+ process.stdout.write(`\n Theme → ${name} (set CIRVIX_THEME=${name} to keep it)\n\n`);
1114
+ return 0;
1115
+ } catch (err) {
1116
+ process.stderr.write(red(` ${err.message}\n`));
1117
+ return 2;
1118
+ }
1119
+ }
1120
+
1121
+ case "onboard": {
1122
+ const { onboard } = await import("../src/commands/onboard.mjs");
1123
+ await onboard({ cwd, pace: flags.fast ? 0 : 400 });
1124
+ return 0;
1125
+ }
1126
+
1075
1127
  /* ---------------------------------------------------------------- demo */
1076
1128
  case "demo": {
1077
1129
  const rules = flags.policy ? await loadRules(flags.policy, cwd) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cirvix_ai/agent-control",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Cirvix AgentControl \u2014 runtime governance for AI agents. Scan what is ungoverned, evaluate policy, and broker tool calls.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://cirvix.com/",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `cirvix console` — the primary product interface.
3
+ *
4
+ * Interactive when attached to a TTY, one-shot when piped:
5
+ * cirvix console # full REPL
6
+ * echo "read ~/.aws/credentials" | cirvix console --once
7
+ * cirvix console --eval "deploy to production"
8
+ *
9
+ * `--once` / `--eval` exist so the product UI is scriptable and testable:
10
+ * the same cards render in CI as in the terminal.
11
+ */
12
+
13
+ import { ConsoleApp } from "../tui/app.mjs";
14
+
15
+ export async function consoleCmd({ cwd = process.cwd(), rules = [], mode = "enforce", evalText = null, once = false, write = (s) => process.stdout.write(s) } = {}) {
16
+ const app = new ConsoleApp({ cwd, rules, mode, write });
17
+
18
+ if (evalText) {
19
+ const out = await app.runOnce(evalText);
20
+ if (out && out !== "quit") write(out + "\n");
21
+ return { app, output: out };
22
+ }
23
+
24
+ if (once || !process.stdin.isTTY) {
25
+ // Piped: evaluate each line, print cards, exit. Never start readline
26
+ // on a non-TTY — it would hang waiting for a terminal that is not there.
27
+ const chunks = await readStdin();
28
+ const lines = chunks.split("\n").map((l) => l.trim()).filter(Boolean);
29
+ let output = "";
30
+ for (const line of lines) {
31
+ const out = await app.runOnce(line);
32
+ if (out && out !== "quit") {
33
+ write(out + "\n");
34
+ output += out + "\n";
35
+ }
36
+ }
37
+ if (!lines.length) {
38
+ const help = app.renderTranscript();
39
+ write(help + "\n");
40
+ return { app, output: help };
41
+ }
42
+ return { app, output };
43
+ }
44
+
45
+ await app.start();
46
+ return { app, output: "" };
47
+ }
48
+
49
+ function readStdin() {
50
+ return new Promise((resolve) => {
51
+ if (process.stdin.isTTY) return resolve("");
52
+ let data = "";
53
+ process.stdin.setEncoding("utf8");
54
+ process.stdin.on("data", (c) => (data += c));
55
+ process.stdin.on("end", () => resolve(data));
56
+ setTimeout(() => resolve(data), 1000);
57
+ });
58
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `cirvix onboard` — the 10-second first run.
3
+ *
4
+ * A guided demo, not a man page: it shows one allowed call and one real
5
+ * blocked credential read, then hands the user the console.
6
+ */
7
+
8
+ import { Pipeline } from "../core/pipeline.mjs";
9
+ import { compile } from "../core/policy-dsl.mjs";
10
+ import { STARTER_POLICY } from "./init.mjs";
11
+ import { blockedCard, policyCard } from "../tui/cards.mjs";
12
+ import { bold, dim, style } from "../core/theme.mjs";
13
+
14
+ export async function onboard({ cwd = process.cwd(), pace = 500, write = (s) => process.stdout.write(s) } = {}) {
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ const rules = compile(STARTER_POLICY, { cwd, origin: "onboard" }).rules;
17
+ const pipeline = new Pipeline({ rules, cwd, agent: "you" });
18
+
19
+ write("\n");
20
+ write(` ${bold("◆ CIRVIX")}\n`);
21
+ write(` ${dim("Runtime Authorization Engine")}\n\n`);
22
+ write(` ${dim("Cirvix evaluates what your AI agents are allowed to do.")}\n\n`);
23
+ write(` ${style("✓", "allow")} ${dim("Identity")} ${style("✓", "allow")} ${dim("Policy")} ${style("✓", "allow")} ${dim("Context")} ${style("✓", "allow")} ${dim("Risk")}\n\n`);
24
+ await sleep(pace);
25
+
26
+ write(` ${bold("Let's test it.")}\n`);
27
+ write(` ${dim("> Read ~/.aws/credentials")}\n\n`);
28
+ await sleep(pace);
29
+
30
+ const { event } = await pipeline.submit({ tool: "read_file", arguments: { path: "~/.aws/credentials" } });
31
+ write(blockedCard({
32
+ tool: event.tool,
33
+ target: "~/.aws/credentials",
34
+ policy: event.policy ?? "credential-protection",
35
+ reason: event.reason ?? "This path contains credentials.",
36
+ }).split("\n").map((l) => " " + l).join("\n") + "\n\n");
37
+ write(` ${style("Cirvix protected your credentials.", "allow")}\n\n`);
38
+ await sleep(pace);
39
+
40
+ const { event: ok } = await pipeline.submit({ tool: "read_file", arguments: { path: "./src/app.ts" } });
41
+ write(policyCard({
42
+ action: ok.tool,
43
+ risk: ok.risk,
44
+ policy: ok.policy ?? "allow-workspace-read",
45
+ identity: "agent:you",
46
+ reason: "Ordinary workspace reads keep working.",
47
+ }).split("\n").map((l) => " " + l).join("\n") + "\n\n");
48
+
49
+ write(` ${dim("Next:")} ${bold("cirvix console")} ${dim("— the interactive runtime")}\n`);
50
+ write(` ${dim("Or:")} ${bold("cirvix demo")} ${dim("— the full live interception")}\n\n`);
51
+ return { ok: true };
52
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Cirvix event model — the contract between the engine and every UI.
3
+ *
4
+ * The policy engine never prints. It emits typed events on an EventBus.
5
+ * Every UI (CLI cards, the interactive console, a future web dashboard or
6
+ * desktop app, CI reporters) is a renderer over this event stream:
7
+ *
8
+ * Policy engine → Event → State reducer → Component
9
+ *
10
+ * Event types:
11
+ * SESSION_STARTED / SESSION_ENDED
12
+ * USER_MESSAGE the human typed something into the console
13
+ * AGENT_MESSAGE a text answer to show in the conversation pane
14
+ * POLICY_EVALUATION_STARTED
15
+ * POLICY_DECISION one tool call decided (carries the audit event)
16
+ * TOOL_STARTED / TOOL_OUTPUT / TOOL_FINISHED
17
+ * APPROVAL_REQUESTED / APPROVAL_GRANTED / APPROVAL_DENIED
18
+ * AUDIT_COMPLETED
19
+ * RUNTIME_ERROR
20
+ * STATUS_SNAPSHOT rolling counters for the status bar
21
+ *
22
+ * Zero dependencies. Serializable (JSON-safe) by construction so the same
23
+ * events can cross the UDS socket or be recorded in tests.
24
+ */
25
+
26
+ export const EVENT = {
27
+ SESSION_STARTED: "SESSION_STARTED",
28
+ SESSION_ENDED: "SESSION_ENDED",
29
+ USER_MESSAGE: "USER_MESSAGE",
30
+ AGENT_MESSAGE: "AGENT_MESSAGE",
31
+ POLICY_EVALUATION_STARTED: "POLICY_EVALUATION_STARTED",
32
+ POLICY_DECISION: "POLICY_DECISION",
33
+ TOOL_STARTED: "TOOL_STARTED",
34
+ TOOL_OUTPUT: "TOOL_OUTPUT",
35
+ TOOL_FINISHED: "TOOL_FINISHED",
36
+ APPROVAL_REQUESTED: "APPROVAL_REQUESTED",
37
+ APPROVAL_GRANTED: "APPROVAL_GRANTED",
38
+ APPROVAL_DENIED: "APPROVAL_DENIED",
39
+ AUDIT_COMPLETED: "AUDIT_COMPLETED",
40
+ RUNTIME_ERROR: "RUNTIME_ERROR",
41
+ STATUS_SNAPSHOT: "STATUS_SNAPSHOT",
42
+ };
43
+
44
+ let seq = 0;
45
+
46
+ function base(type, payload = {}) {
47
+ return {
48
+ type,
49
+ id: `evt_${Date.now().toString(36)}_${(seq++).toString(36)}`,
50
+ ts: new Date().toISOString(),
51
+ ...payload,
52
+ };
53
+ }
54
+
55
+ export const createEvent = {
56
+ sessionStarted: (p = {}) => base(EVENT.SESSION_STARTED, p),
57
+ sessionEnded: (p = {}) => base(EVENT.SESSION_ENDED, p),
58
+ userMessage: (text, p = {}) => base(EVENT.USER_MESSAGE, { text, ...p }),
59
+ agentMessage: (text, p = {}) => base(EVENT.AGENT_MESSAGE, { text, ...p }),
60
+ evaluationStarted: (p = {}) => base(EVENT.POLICY_EVALUATION_STARTED, p),
61
+ policyDecision: (decision, p = {}) =>
62
+ base(EVENT.POLICY_DECISION, {
63
+ decision: decision?.decision ?? decision?.verdict ?? "deny",
64
+ tool: decision?.tool ?? decision?.action ?? "unknown",
65
+ resource: decision?.resource ?? "",
66
+ risk: decision?.risk ?? "low",
67
+ policy: decision?.policy ?? decision?.rule ?? null,
68
+ reason: decision?.reason ?? "",
69
+ latency_ms: decision?.latency_ms ?? 0,
70
+ raw: decision,
71
+ ...p,
72
+ }),
73
+ toolStarted: (tool, p = {}) => base(EVENT.TOOL_STARTED, { tool, ...p }),
74
+ toolOutput: (text, p = {}) => base(EVENT.TOOL_OUTPUT, { text, ...p }),
75
+ toolFinished: (tool, p = {}) => base(EVENT.TOOL_FINISHED, { tool, ...p }),
76
+ approvalRequested: (p = {}) => base(EVENT.APPROVAL_REQUESTED, p),
77
+ approvalGranted: (p = {}) => base(EVENT.APPROVAL_GRANTED, p),
78
+ approvalDenied: (p = {}) => base(EVENT.APPROVAL_DENIED, p),
79
+ auditCompleted: (p = {}) => base(EVENT.AUDIT_COMPLETED, p),
80
+ runtimeError: (message, p = {}) => base(EVENT.RUNTIME_ERROR, { message, ...p }),
81
+ statusSnapshot: (p = {}) => base(EVENT.STATUS_SNAPSHOT, p),
82
+ };
83
+
84
+ /**
85
+ * Minimal pub/sub bus. Sync dispatch (the engine is sync-per-decision);
86
+ * subscribers never throw into the engine — a broken UI listener is
87
+ * isolated and reported via `onListenerError`.
88
+ */
89
+ export class EventBus {
90
+ constructor({ onListenerError = () => {} } = {}) {
91
+ this.listeners = new Map();
92
+ this.onListenerError = onListenerError;
93
+ this.history = [];
94
+ this.capped = 2000;
95
+ }
96
+
97
+ on(type, fn) {
98
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
99
+ this.listeners.get(type).add(fn);
100
+ return () => this.listeners.get(type)?.delete(fn);
101
+ }
102
+
103
+ onAny(fn) {
104
+ return this.on("*", fn);
105
+ }
106
+
107
+ emit(event) {
108
+ this.history.push(event);
109
+ if (this.history.length > this.capped) this.history.shift();
110
+ const targets = [
111
+ ...(this.listeners.get(event.type) ?? []),
112
+ ...(this.listeners.get("*") ?? []),
113
+ ];
114
+ for (const fn of targets) {
115
+ try {
116
+ fn(event);
117
+ } catch (err) {
118
+ this.onListenerError(err, event);
119
+ }
120
+ }
121
+ return event;
122
+ }
123
+
124
+ replay(types, fn) {
125
+ for (const e of this.history) {
126
+ if (!types || types.includes(e.type)) fn(e);
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Adapt a live Pipeline to the bus: wraps `onEvent` so every decision the
133
+ * engine makes also becomes a POLICY_DECISION (+ approval/tool) event.
134
+ * Returns an unsubscribe function.
135
+ */
136
+ export function attachPipeline(pipeline, bus) {
137
+ const prev = pipeline.onEvent;
138
+ pipeline.onEvent = (e) => {
139
+ try {
140
+ if (e?.kind === "decision") {
141
+ bus.emit(createEvent.policyDecision(e));
142
+ if (e.decision === "require_approval") {
143
+ bus.emit(createEvent.approvalRequested({ approval_id: e.approval_id, tool: e.tool, resource: e.resource }));
144
+ }
145
+ } else if (e?.kind === "scrub") {
146
+ bus.emit(createEvent.toolOutput(`scrubbed ${e.findings?.length ?? 0} finding(s)`, { findings: e.findings }));
147
+ }
148
+ } catch {
149
+ // event translation must never break enforcement
150
+ }
151
+ return prev?.(e);
152
+ };
153
+ return () => {
154
+ pipeline.onEvent = prev;
155
+ };
156
+ }
157
+
158
+ /* ------------------------------------------------------------------ */
159
+ /* Reducer — event stream → renderable UI state */
160
+ /* ------------------------------------------------------------------ */
161
+
162
+ export function initialState() {
163
+ return {
164
+ session: null,
165
+ messages: [], // { role: 'user'|'cirvix', text, ts }
166
+ activity: [], // POLICY_DECISION payloads, newest last
167
+ approvals: [], // pending approval requests
168
+ errors: [],
169
+ status: {
170
+ mode: "enforce",
171
+ requests: 0,
172
+ allowed: 0,
173
+ sanitized: 0,
174
+ blocked: 0,
175
+ held: 0,
176
+ latencies: [],
177
+ },
178
+ evaluating: false,
179
+ };
180
+ }
181
+
182
+ export function reduce(state, event) {
183
+ switch (event.type) {
184
+ case EVENT.SESSION_STARTED:
185
+ return { ...state, session: { id: event.sessionId ?? event.id, startedAt: event.ts, agent: event.agent ?? "local" } };
186
+ case EVENT.SESSION_ENDED:
187
+ return { ...state, session: state.session ? { ...state.session, endedAt: event.ts } : null, evaluating: false };
188
+ case EVENT.USER_MESSAGE:
189
+ return { ...state, messages: [...state.messages, { role: "user", text: event.text, ts: event.ts }] };
190
+ case EVENT.AGENT_MESSAGE:
191
+ return { ...state, messages: [...state.messages, { role: "cirvix", text: event.text, ts: event.ts }] };
192
+ case EVENT.POLICY_EVALUATION_STARTED:
193
+ return { ...state, evaluating: true };
194
+ case EVENT.POLICY_DECISION: {
195
+ const d = event.decision;
196
+ const status = { ...state.status, requests: state.status.requests + 1 };
197
+ if (d === "allow") status.allowed++;
198
+ else if (d === "sanitize") status.sanitized++;
199
+ else if (d === "deny") status.blocked++;
200
+ else if (d === "require_approval") status.held++;
201
+ if (typeof event.latency_ms === "number") {
202
+ status.latencies = [...status.latencies.slice(-999), event.latency_ms];
203
+ }
204
+ return {
205
+ ...state,
206
+ evaluating: false,
207
+ activity: [...state.activity.slice(-499), event],
208
+ status,
209
+ };
210
+ }
211
+ case EVENT.APPROVAL_REQUESTED:
212
+ return { ...state, approvals: [...state.approvals, event] };
213
+ case EVENT.APPROVAL_GRANTED:
214
+ case EVENT.APPROVAL_DENIED:
215
+ return {
216
+ ...state,
217
+ approvals: state.approvals.filter((a) => a.approval_id !== event.approval_id),
218
+ };
219
+ case EVENT.RUNTIME_ERROR:
220
+ return { ...state, evaluating: false, errors: [...state.errors.slice(-49), event] };
221
+ case EVENT.STATUS_SNAPSHOT:
222
+ return { ...state, status: { ...state.status, ...event.snapshot } };
223
+ default:
224
+ return state;
225
+ }
226
+ }
227
+
228
+ /** P50/P95 over the reducer's latency window. */
229
+ export function latencyStats(latencies) {
230
+ if (!latencies?.length) return { p50: 0, p95: 0, samples: 0 };
231
+ const s = [...latencies].sort((a, b) => a - b);
232
+ const at = (q) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
233
+ return { p50: Number(at(0.5).toFixed(2)), p95: Number(at(0.95).toFixed(2)), samples: s.length };
234
+ }
@@ -1,15 +1,29 @@
1
1
  /**
2
- * Terminal formatting.
2
+ * Terminal formatting — thin compatibility layer over the semantic theme.
3
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.
4
+ * New code should import from `theme.mjs` and use `colors.*` / `style(text,
5
+ * role)` so meaning stays in one place. This module keeps the historic names
6
+ * (`green`, `red`, `amber`, `blue`, `cyan`, `gray`, `white`, `bold`, `dim`,
7
+ * `plural`) working for the existing CLI, gateway logs, the `core/ui`
8
+ * primitives, and every test that already asserts on them.
8
9
  *
9
- * The palette mirrors the product's chroma rule: green means permitted, red
10
- * means denied, amber means held. Nothing decorative uses them.
10
+ * Mapping (the product's chroma rule green means permitted, red denied,
11
+ * amber held, blue/cyan sanitized or informational, gray muted):
12
+ * green → allow · red → block · amber → hold/warning · blue/cyan → sanitize
13
+ * gray → muted · white → text
11
14
  */
12
15
 
16
+ export { bold, dim, colors, style, setTheme, themeName, THEME_NAMES } from "./theme.mjs";
17
+ import { style } from "./theme.mjs";
18
+
19
+ export const green = (s) => style(s, "allow");
20
+ export const red = (s) => style(s, "block");
21
+ export const amber = (s) => style(s, "hold");
22
+ export const blue = (s) => style(s, "sanitize");
23
+ export const cyan = (s) => style(s, "sanitize");
24
+ export const gray = (s) => style(s, "muted");
25
+ export const white = (s) => style(s, "text");
26
+
13
27
  const forced = process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true";
14
28
  const disabled =
15
29
  !forced &&
@@ -17,22 +31,9 @@ const disabled =
17
31
  process.env.TERM === "dumb" ||
18
32
  !process.stdout.isTTY);
19
33
 
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
- export const cyan = wrap(36, 39);
30
- export const gray = wrap(90, 39);
31
- export const white = wrap(97, 39);
32
-
33
34
  /** Strip ANSI escape sequences for width calculation and secret checks. */
34
35
  export function stripAnsi(s) {
35
- return String(s).replace(/\u001b\[[0-9;]*m/g, "");
36
+ return String(s).replace(/\[[0-9;]*m/g, "");
36
37
  }
37
38
 
38
39
  /** Visible character width, ignoring ANSI. */