@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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Cirvix TUI cards — every tool call rendered as a visual object.
3
+ *
4
+ * Pure functions: (data) → string. No console writes, no engine imports.
5
+ * The same functions back the interactive console, `cirvix logs --tree`,
6
+ * and the future React/Ink port (one card = one component).
7
+ *
8
+ * Accessibility rule: icon + color + WORD. A ✓/✕ alone is never the only
9
+ * signal — every card prints ALLOWED / SANITIZED / BLOCKED / HELD as text.
10
+ */
11
+
12
+ import { style, bold, dim, roleForDecision, roleForRisk, badgeForDecision } from "../core/theme.mjs";
13
+
14
+ /* ------------------------------------------------------------------ */
15
+ /* Box primitives */
16
+ /* ------------------------------------------------------------------ */
17
+
18
+ function width() {
19
+ return Math.max(40, Math.min(process.stdout.columns ?? 80, 100));
20
+ }
21
+
22
+ function rule(char = "─") {
23
+ return dim(char.repeat(Math.max(0, width() - 2)));
24
+ }
25
+
26
+ function frame(title, lines, { tone = "border" } = {}) {
27
+ const W = Math.max(40, width() - 6);
28
+ const top = style(`┌─ ${title} ${"─".repeat(Math.max(0, W - title.length - 4))}┐`, tone);
29
+ const bottom = style(`└${"─".repeat(W)}┘`, tone);
30
+ const body = lines.map((l) => {
31
+ const plain = stripAnsi(l);
32
+ const pad = " ".repeat(Math.max(0, W - 2 - plain.length));
33
+ return `${style("│", tone)} ${l}${pad} ${style("│", tone)}`;
34
+ });
35
+ return [top, ...body, bottom].join("\n");
36
+ }
37
+
38
+ function stripAnsi(s) {
39
+ return String(s).replace(/\x1b\[[0-9;]*m/g, "");
40
+ }
41
+
42
+ function kv(key, value, { keyWidth = 12 } = {}) {
43
+ return `${dim(String(key).padEnd(keyWidth))} ${value}`;
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Header */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ export function header({ mode = "PROTECTED", version = "" } = {}) {
51
+ const dot = mode === "PROTECTED" ? style("●", "allow") : style("○", "warning");
52
+ const title = `${bold("◆ CIRVIX")} ${dim("Runtime Authorization")}`;
53
+ const right = `${dot} ${bold(mode)}${version ? dim(` v${version}`) : ""}`;
54
+ return `${title}${" ".repeat(Math.max(2, width() - stripAnsi(title).length - stripAnsi(right).length))}${right}\n${rule()}`;
55
+ }
56
+
57
+ /* ------------------------------------------------------------------ */
58
+ /* Policy decision card — the ALLOWED shape */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ export function policyCard({ action, risk, policy, identity, reason, latencyMs } = {}) {
62
+ const badge = badgeForDecision("allow");
63
+ const lines = [
64
+ `${style(`✓ ${badge.label}`, "allow")}`,
65
+ ``,
66
+ kv("Action", bold(action ?? "—")),
67
+ kv("Risk", style(String(risk ?? "—").toUpperCase(), roleForRisk(risk))),
68
+ kv("Policy", policy ?? dim("default-deny")),
69
+ kv("Identity", identity ?? dim("agent:local")),
70
+ ...(reason ? [kv("Reason", dim(truncate(reason, 60)))] : []),
71
+ ...(latencyMs !== undefined ? [kv("Latency", dim(`${latencyMs}ms`))] : []),
72
+ ];
73
+ return frame("POLICY DECISION", lines, { tone: "border" });
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* Tool card — full lifecycle: identity → policy checks → decision */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ export function toolCard({ tool, risk, identity, detail, checks = [], decision, policy, reason } = {}) {
81
+ const badge = badgeForDecision(decision);
82
+ const role = roleForDecision(decision);
83
+ const lines = [
84
+ ``,
85
+ kv("Risk", style(String(risk ?? "—").toUpperCase(), roleForRisk(risk))),
86
+ kv("Identity", identity ?? dim("agent:local")),
87
+ ...(detail ? [kv(detailLabel(tool), truncate(detail, 64))] : []),
88
+ ``,
89
+ dim("Policy evaluation"),
90
+ ...checks.map((c) => ` ${c.ok ? style("✓", "allow") : style("✕", "block")} ${dim(c.label)}`),
91
+ ``,
92
+ kv("Decision", style(`${badge.icon} ${badge.label}`, role)),
93
+ ...(policy ? [kv("Policy", policy)] : []),
94
+ ...(reason ? [kv("Why", dim(truncate(reason, 64)))] : []),
95
+ ];
96
+ const title = String(tool ?? "TOOL").toUpperCase().replace(/__/g, " · ");
97
+ return frame(title, lines, { tone: role === "block" ? "block" : "border" });
98
+ }
99
+
100
+ function detailLabel(tool) {
101
+ const t = String(tool ?? "");
102
+ if (/http|egress|fetch|request/i.test(t)) return "URL";
103
+ if (/read|write|file/i.test(t)) return "Path";
104
+ if (/shell|exec|command/i.test(t)) return "Command";
105
+ if (/sql|db|query/i.test(t)) return "Query";
106
+ return "Target";
107
+ }
108
+
109
+ /* ------------------------------------------------------------------ */
110
+ /* Blocked card — the dangerous shape. Human first, fields second. */
111
+ /* ------------------------------------------------------------------ */
112
+
113
+ export function blockedCard({ tool, target, policy, reason, detail } = {}) {
114
+ const lines = [
115
+ style(`🔴 HIGH-RISK ACTION — BLOCKED`, "block"),
116
+ ``,
117
+ bold(tool ?? "unknown tool"),
118
+ ...(target ? [dim(truncate(target, 72))] : []),
119
+ ``,
120
+ ...(policy ? [kv("Policy", policy)] : []),
121
+ ...(reason ? [kv("Reason", truncate(reason, 72))] : []),
122
+ ...(detail ? [dim(truncate(detail, 72))] : []),
123
+ ``,
124
+ dim("Action was NOT executed. No side effects occurred."),
125
+ ];
126
+ return frame("BLOCKED", lines, { tone: "block" });
127
+ }
128
+
129
+ export function heldCard({ tool, target, approvers = [], reason } = {}) {
130
+ const lines = [
131
+ `${style("◷ HELD FOR APPROVAL", "hold")}`,
132
+ ``,
133
+ bold(tool ?? "unknown tool"),
134
+ ...(target ? [dim(truncate(target, 72))] : []),
135
+ ``,
136
+ kv("Waits on", approvers.length ? approvers.join(", ") : dim("a human approver")),
137
+ ...(reason ? [kv("Reason", dim(truncate(reason, 68)))] : []),
138
+ ``,
139
+ dim("The call is suspended. Approve it with `cirvix approvals`."),
140
+ ];
141
+ return frame("APPROVAL", lines, { tone: "hold" });
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ */
145
+ /* Human-readable explanation — security product, not infra dump */
146
+ /* ------------------------------------------------------------------ */
147
+
148
+ export function explainDecision(event = {}) {
149
+ const decision = event.decision ?? event.verdict ?? "deny";
150
+ const badge = badgeForDecision(decision);
151
+ const role = roleForDecision(decision);
152
+ const title = style(`${badge.icon} ${badge.label}`, role);
153
+
154
+ if (decision === "deny") {
155
+ return [
156
+ title,
157
+ ``,
158
+ `Cirvix stopped this request${event.policy ? ` because policy ${bold(`"${event.policy}"`)} matched` : ""}.`,
159
+ ...(event.resource || event.destination
160
+ ? [``, `Target:`, ` ${truncate(event.resource || event.destination, 76)}`]
161
+ : []),
162
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
163
+ ``,
164
+ dim("No network request was sent. No file was read. No command ran."),
165
+ ``,
166
+ `${dim("[Why?]")} ${dim("cirvix logs --tree " + (event.request_id ?? event.decision_id ?? "<id>"))} ${dim("[Policy]")} cirvix policy list`,
167
+ ].join("\n");
168
+ }
169
+
170
+ if (decision === "sanitize") {
171
+ return [
172
+ title,
173
+ ``,
174
+ `Cirvix forwarded this call after cleaning it.`,
175
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
176
+ ``,
177
+ dim("The agent received a safe version — the original never left the runtime."),
178
+ ].join("\n");
179
+ }
180
+
181
+ if (decision === "require_approval") {
182
+ return [
183
+ title,
184
+ ``,
185
+ `This call needs a human before it runs.`,
186
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
187
+ ].join("\n");
188
+ }
189
+
190
+ return [
191
+ title,
192
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
193
+ ].join("\n");
194
+ }
195
+
196
+ /* ------------------------------------------------------------------ */
197
+ /* Transcript rows */
198
+ /* ------------------------------------------------------------------ */
199
+
200
+ export function userRow(text) {
201
+ return `${bold("You")}\n${dim("─".repeat(27))}\n${text}`;
202
+ }
203
+
204
+ export function cirvixRow(text) {
205
+ return `${bold("CIRVIX")}\n${dim("─".repeat(27))}\n${text}`;
206
+ }
207
+
208
+ /* ------------------------------------------------------------------ */
209
+ /* Small helpers */
210
+ /* ------------------------------------------------------------------ */
211
+
212
+ export function spinnerFrame(i) {
213
+ return ["◐", "◓", "◑", "◒"][i % 4];
214
+ }
215
+
216
+ function truncate(s, n) {
217
+ const v = String(s ?? "");
218
+ return v.length <= n ? v : `…${v.slice(-(n - 1))}`;
219
+ }
220
+
221
+ function wrap(text, w) {
222
+ const words = String(text).split(/\s+/);
223
+ const lines = [];
224
+ let line = "";
225
+ for (const word of words) {
226
+ if ((line + " " + word).trim().length > w) {
227
+ lines.push(line.trim());
228
+ line = word;
229
+ } else line += " " + word;
230
+ }
231
+ if (line.trim()) lines.push(line.trim());
232
+ return lines.join("\n");
233
+ }
234
+
235
+ export { frame, rule, width };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Composer — the input box. Multiline, history, paste, autocomplete.
3
+ *
4
+ * Built on node:readline (zero deps) rather than a bespoke ANSI editor:
5
+ * - multiline via Shift+Enter semantics: plain Enter sends, and a trailing
6
+ * `\` continues the line; pasted multi-line blocks are joined safely.
7
+ * - ↑↓ history, Ctrl+R reverse search, Ctrl+C cancel, Ctrl+L clear, Tab
8
+ * completion come from readline itself.
9
+ * - `/` prefix triggers the command palette; Tab completes it.
10
+ * - Ctrl+K opens the palette, Ctrl+O toggles activity, Ctrl+P policies,
11
+ * Ctrl+A audit, Ctrl+S session, Esc closes overlays.
12
+ *
13
+ * The composer never evaluates policy itself — it emits lines to the app.
14
+ */
15
+
16
+ import readline from "node:readline";
17
+ import { filterCommands } from "./palette.mjs";
18
+ import { dim, bold } from "../core/theme.mjs";
19
+
20
+ export const KEY_HINT = dim("Enter ↵ send Shift+Enter newline ↑↓ history Tab commands / palette Ctrl+K palette Esc close");
21
+
22
+ export function composerBox() {
23
+ return `${dim("╭─ CIRVIX ─" + "─".repeat(50) + "╮")}\n${dim("│")} ${dim("Ask Cirvix anything... type / for commands")}\n${dim("╰" + "─".repeat(60) + "╯")}\n ${KEY_HINT}`;
24
+ }
25
+
26
+ function completer(line) {
27
+ if (!line.startsWith("/")) return [[], line];
28
+ const matches = filterCommands(line).map((c) => c.name);
29
+ return [matches, line];
30
+ }
31
+
32
+ /**
33
+ * Start the interactive prompt. `onLine` may be async; return "quit" from
34
+ * it (or type /quit) to exit. Returns a handle with `close()`.
35
+ */
36
+ export function startComposer({ prompt = "> ", history = [], onLine, onKey }) {
37
+ const rl = readline.createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout,
40
+ prompt,
41
+ completer,
42
+ history,
43
+ terminal: Boolean(process.stdin.isTTY),
44
+ });
45
+
46
+ // Extra shortcuts beyond readline's builtins.
47
+ if (process.stdin.isTTY) {
48
+ readline.emitKeypressEvents(process.stdin);
49
+ const onKeypress = (str, key = {}) => {
50
+ if (key.name === "escape") onKey?.("escape");
51
+ else if (key.ctrl && key.name === "k") { rl.write("/"); onKey?.("palette"); }
52
+ else if (key.ctrl && key.name === "o") onKey?.("toggle-activity");
53
+ else if (key.ctrl && key.name === "p") onKey?.("policies");
54
+ else if (key.ctrl && key.name === "a") onKey?.("audit");
55
+ else if (key.ctrl && key.name === "s") onKey?.("session");
56
+ else if (key.ctrl && key.name === "l") { console.clear(); rl.prompt(); }
57
+ };
58
+ process.stdin.on("keypress", onKeypress);
59
+ rl.on("close", () => process.stdin.removeListener("keypress", onKeypress));
60
+ }
61
+
62
+ // Trailing-backslash continuation = Shift+Enter equivalent for terminals
63
+ // that cannot distinguish the two.
64
+ let pending = "";
65
+ rl.on("line", async (line) => {
66
+ if (line.endsWith("\\")) {
67
+ pending += line.slice(0, -1) + "\n";
68
+ rl.setPrompt("… ");
69
+ rl.prompt();
70
+ return;
71
+ }
72
+ const full = (pending + line).trim();
73
+ pending = "";
74
+ rl.setPrompt(prompt);
75
+ try {
76
+ const verdict = await onLine?.(full);
77
+ if (verdict === "quit") rl.close();
78
+ else rl.prompt();
79
+ } catch {
80
+ rl.prompt();
81
+ }
82
+ });
83
+
84
+ rl.prompt();
85
+ return rl;
86
+ }
87
+
88
+ export { completer };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Command palette — `/` shows everything. No memorized single letters.
3
+ *
4
+ * Each entry: { name, hint, run }. Filtering is prefix + substring so
5
+ * `/pol` matches `/policies`, `/policy test`, `/policy explain`.
6
+ */
7
+
8
+ export const COMMANDS = [
9
+ { name: "/audit", hint: "View security audit", run: "audit" },
10
+ { name: "/policies", hint: "Inspect policies", run: "policies" },
11
+ { name: "/policy test", hint: "Run policy test cases", run: "policy-test" },
12
+ { name: "/policy explain", hint: "Why would this call be decided that way", run: "policy-explain" },
13
+ { name: "/logs", hint: "View runtime logs", run: "logs" },
14
+ { name: "/sessions", hint: "Manage sessions", run: "sessions" },
15
+ { name: "/approvals", hint: "Calls waiting on a human", run: "approvals" },
16
+ { name: "/theme", hint: "Change appearance (dark/light/midnight/high-contrast)", run: "theme" },
17
+ { name: "/config", hint: "Configure Cirvix", run: "config" },
18
+ { name: "/doctor", hint: "Diagnose runtime", run: "doctor" },
19
+ { name: "/demo", hint: "Run the live interception demo", run: "demo" },
20
+ { name: "/expand", hint: "Expand activity feed", run: "expand" },
21
+ { name: "/collapse", hint: "Collapse activity feed", run: "collapse" },
22
+ { name: "/clear", hint: "Clear the screen", run: "clear" },
23
+ { name: "/help", hint: "Show commands", run: "help" },
24
+ { name: "/quit", hint: "Leave the console", run: "quit" },
25
+ ];
26
+
27
+ export function filterCommands(input) {
28
+ const q = String(input ?? "").trim().toLowerCase();
29
+ if (!q || q === "/") return COMMANDS;
30
+ const needle = q.startsWith("/") ? q : `/${q}`;
31
+ const bare = needle.slice(1);
32
+ return COMMANDS.filter(
33
+ (c) => c.name.toLowerCase().startsWith(needle) || c.name.toLowerCase().includes(bare),
34
+ );
35
+ }
36
+
37
+ export function paletteBox(input) {
38
+ const matches = filterCommands(input);
39
+ const lines = [
40
+ `╭─ Commands ─${"─".repeat(24)}╮`,
41
+ `│ > ${(input ?? "").padEnd(32)} │`,
42
+ `│${" ".repeat(36)}│`,
43
+ ...matches.slice(0, 8).map((c) => `│ ${(c.name.padEnd(14))} ${c.hint.slice(0, 18).padEnd(18)} │`),
44
+ ...(matches.length === 0 ? [`│ (no match)${" ".repeat(24)} │`] : []),
45
+ `╰${"─".repeat(36)}╯`,
46
+ ];
47
+ return lines.join("\n");
48
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Persistent status bar — always on screen, always honest.
3
+ *
4
+ * Wide terminal: full breakdown (mode, policy, counts, latency, audit).
5
+ * Narrow terminal (<72 cols): compact one-liner. The bar adapts; it never
6
+ * wraps into two unreadable lines.
7
+ */
8
+
9
+ import { style, bold, dim } from "../core/theme.mjs";
10
+ import { latencyStats } from "../core/events.mjs";
11
+
12
+ export function statusBar(state, { version = "", width = process.stdout.columns ?? 80 } = {}) {
13
+ const s = state.status;
14
+ const lat = latencyStats(s.latencies);
15
+ const mode = s.mode === "audit"
16
+ ? `${style("○", "warning")} ${style("AUDIT", "warning")}`
17
+ : `${style("●", "allow")} ${bold("PROTECTED")}`;
18
+
19
+ if (width < 72) {
20
+ // Compact: [● PROTECTED] 148 req 21 blocked P95 18ms
21
+ return dim("─".repeat(Math.max(0, width - 1))) + "\n" +
22
+ `${mode} ${s.requests} req ${blockedPart(s)} ${dim(`P95 ${lat.p95}ms`)}`;
23
+ }
24
+
25
+ const line1 = dim("─".repeat(Math.max(0, width - 1)));
26
+ const cells = [
27
+ `${mode} ${dim("│")} ${dim("Policy:")} ${s.policyName ?? "strict"} ${dim("│")} ${dim("Requests:")} ${s.requests}`,
28
+ `${dim("Allow:")} ${style(String(s.allowed), "allow")} ${dim("│")} ${dim("Sanitized:")} ${s.sanitized} ${dim("│")} ${dim("Blocked:")} ${blockedCount(s)}`,
29
+ `${dim(`P50 ${lat.p50}ms`)} ${dim("│")} ${dim(`P95 ${lat.p95}ms`)} ${dim("│")} ${dim("Audit")} ${style("✓", "allow")}${version ? dim(` │ v${version}`) : ""}`,
30
+ ];
31
+ return line1 + "\n" + cells.join("\n");
32
+ }
33
+
34
+ function blockedCount(s) {
35
+ const n = `${s.blocked + s.held}`;
36
+ return s.blocked + s.held > 0 ? style(n, "block") : n;
37
+ }
38
+
39
+ function blockedPart(s) {
40
+ const n = s.blocked + s.held;
41
+ return n > 0 ? style(`${n} blocked`, "block") : dim("0 blocked");
42
+ }