@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 +52 -0
- package/package.json +1 -1
- package/src/commands/console.mjs +58 -0
- package/src/commands/onboard.mjs +52 -0
- package/src/core/events.mjs +234 -0
- package/src/core/format.mjs +22 -21
- package/src/core/theme.mjs +240 -0
- package/src/index.mjs +42 -0
- package/src/tui/activity.mjs +71 -0
- package/src/tui/app.mjs +292 -0
- package/src/tui/cards.mjs +235 -0
- package/src/tui/composer.mjs +88 -0
- package/src/tui/palette.mjs +48 -0
- package/src/tui/status.mjs +42 -0
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.
|
|
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
|
+
}
|
package/src/core/format.mjs
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Terminal formatting.
|
|
2
|
+
* Terminal formatting — thin compatibility layer over the semantic theme.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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(
|
|
36
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Visible character width, ignoring ANSI. */
|