@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cirvix semantic theme system.
|
|
3
|
+
*
|
|
4
|
+
* Every color in the product goes through here. No file outside this one
|
|
5
|
+
* (and its thin re-export in `format.mjs`) may hardcode an ANSI code or call
|
|
6
|
+
* a raw color helper for a product meaning.
|
|
7
|
+
*
|
|
8
|
+
* Why semantic, not literal:
|
|
9
|
+
* - `allow` always means "this proceeded". If it is green-on-dark today and
|
|
10
|
+
* bright-green-on-light tomorrow, every call site updates at once.
|
|
11
|
+
* - Themes become data: `cirvix theme light` swaps one table, not 40 files.
|
|
12
|
+
* - Accessibility (high-contrast, monochrome) is a theme, not a refactor.
|
|
13
|
+
* - Tests can assert meaning ("blocked renders with the block role") without
|
|
14
|
+
* asserting escape bytes.
|
|
15
|
+
*
|
|
16
|
+
* Zero dependencies. Works with NO_COLOR / dumb terminals / pipes by
|
|
17
|
+
* producing plain text (same contract `format.mjs` always had).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ANSI = {
|
|
21
|
+
reset: "\x1b[0m",
|
|
22
|
+
bold: ["\x1b[1m", "\x1b[22m"],
|
|
23
|
+
dim: ["\x1b[2m", "\x1b[22m"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Role names. Adding a role = adding a key here + every theme below. */
|
|
27
|
+
export const ROLES = [
|
|
28
|
+
"text",
|
|
29
|
+
"muted",
|
|
30
|
+
"accent",
|
|
31
|
+
"allow",
|
|
32
|
+
"sanitize",
|
|
33
|
+
"block",
|
|
34
|
+
"hold",
|
|
35
|
+
"info",
|
|
36
|
+
"warning",
|
|
37
|
+
"error",
|
|
38
|
+
"border",
|
|
39
|
+
"surface",
|
|
40
|
+
"selection",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Themes are { role: [open, close] } ANSI pairs, or null for "no styling".
|
|
45
|
+
* Keep the numbers standard (30-37 / 90-97) so they survive SSH, tmux,
|
|
46
|
+
* Windows Terminal, and CI log renderers.
|
|
47
|
+
*/
|
|
48
|
+
const THEMES = {
|
|
49
|
+
dark: {
|
|
50
|
+
text: [37, 39], // white
|
|
51
|
+
muted: [90, 39], // bright black (grey)
|
|
52
|
+
accent: [36, 39], // cyan
|
|
53
|
+
allow: [32, 39], // green
|
|
54
|
+
sanitize: [36, 39], // cyan
|
|
55
|
+
block: [31, 39], // red
|
|
56
|
+
hold: [33, 39], // yellow
|
|
57
|
+
info: [34, 39], // blue
|
|
58
|
+
warning: [33, 39], // yellow
|
|
59
|
+
error: [31, 39], // red
|
|
60
|
+
border: [90, 39],
|
|
61
|
+
surface: null,
|
|
62
|
+
selection: [36, 39],
|
|
63
|
+
},
|
|
64
|
+
light: {
|
|
65
|
+
text: [30, 39], // black
|
|
66
|
+
muted: [90, 39],
|
|
67
|
+
accent: [36, 39],
|
|
68
|
+
allow: [32, 39], // green reads on light bg; darker terminals vary but stay legible
|
|
69
|
+
sanitize: [36, 39],
|
|
70
|
+
block: [31, 39],
|
|
71
|
+
hold: [33, 39],
|
|
72
|
+
info: [34, 39],
|
|
73
|
+
warning: [33, 39],
|
|
74
|
+
error: [31, 39],
|
|
75
|
+
border: [90, 39],
|
|
76
|
+
surface: null,
|
|
77
|
+
selection: [36, 39],
|
|
78
|
+
},
|
|
79
|
+
midnight: {
|
|
80
|
+
text: [97, 39], // bright white
|
|
81
|
+
muted: [34, 39], // dim blue-grey feel
|
|
82
|
+
accent: [95, 39], // bright magenta
|
|
83
|
+
allow: [92, 39], // bright green
|
|
84
|
+
sanitize: [96, 39], // bright cyan
|
|
85
|
+
block: [91, 39], // bright red
|
|
86
|
+
hold: [93, 39], // bright yellow
|
|
87
|
+
info: [94, 39], // bright blue
|
|
88
|
+
warning: [93, 39],
|
|
89
|
+
error: [91, 39],
|
|
90
|
+
border: [35, 39], // magenta borders
|
|
91
|
+
surface: null,
|
|
92
|
+
selection: [95, 39],
|
|
93
|
+
},
|
|
94
|
+
"high-contrast": {
|
|
95
|
+
text: [97, 39],
|
|
96
|
+
muted: [37, 39], // no dim grey — everything legible
|
|
97
|
+
accent: [93, 39],
|
|
98
|
+
allow: [92, 39],
|
|
99
|
+
sanitize: [96, 39],
|
|
100
|
+
block: [91, 39],
|
|
101
|
+
hold: [93, 39],
|
|
102
|
+
info: [94, 39],
|
|
103
|
+
warning: [93, 39],
|
|
104
|
+
error: [91, 39],
|
|
105
|
+
border: [97, 39],
|
|
106
|
+
surface: null,
|
|
107
|
+
selection: [93, 39],
|
|
108
|
+
},
|
|
109
|
+
monochrome: {
|
|
110
|
+
text: null,
|
|
111
|
+
muted: null,
|
|
112
|
+
accent: null,
|
|
113
|
+
allow: null,
|
|
114
|
+
sanitize: null,
|
|
115
|
+
block: null,
|
|
116
|
+
hold: null,
|
|
117
|
+
info: null,
|
|
118
|
+
warning: null,
|
|
119
|
+
error: null,
|
|
120
|
+
border: null,
|
|
121
|
+
surface: null,
|
|
122
|
+
selection: null,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const THEME_NAMES = Object.keys(THEMES);
|
|
127
|
+
|
|
128
|
+
let current = process.env.CIRVIX_THEME && THEMES[process.env.CIRVIX_THEME]
|
|
129
|
+
? process.env.CIRVIX_THEME
|
|
130
|
+
: "dark";
|
|
131
|
+
|
|
132
|
+
function colorEnabled() {
|
|
133
|
+
if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
|
|
134
|
+
if (process.env.NO_COLOR !== undefined) return false;
|
|
135
|
+
if (process.env.TERM === "dumb") return false;
|
|
136
|
+
if (process.env.CIRVIX_THEME === "monochrome") return false;
|
|
137
|
+
// Non-TTY (pipes, CI logs) → plain text. Same rule format.mjs always had.
|
|
138
|
+
if (!process.stdout.isTTY) return false;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wrapAnsi(open, close) {
|
|
143
|
+
return (s) => (colorEnabled() ? `\x1b[${open}m${String(s)}\x1b[${close}m` : String(s));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Set the active theme. Returns the name set. Throws on unknown names. */
|
|
147
|
+
export function setTheme(name) {
|
|
148
|
+
if (!THEMES[name]) throw new Error(`Unknown theme "${name}". Available: ${THEME_NAMES.join(", ")}`);
|
|
149
|
+
current = name;
|
|
150
|
+
return current;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Active theme name. */
|
|
154
|
+
export function themeName() {
|
|
155
|
+
return current;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Raw role → ANSI pair for the active theme (or null). */
|
|
159
|
+
export function roleAnsi(role) {
|
|
160
|
+
return THEMES[current]?.[role] ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Style a string with a semantic role: `style("BLOCKED", "block")`.
|
|
165
|
+
* Unknown roles pass through unstyled rather than throwing — a theme
|
|
166
|
+
* must never break enforcement output.
|
|
167
|
+
*/
|
|
168
|
+
export function style(text, role) {
|
|
169
|
+
const pair = THEMES[current]?.[role];
|
|
170
|
+
if (!pair || !colorEnabled()) return String(text);
|
|
171
|
+
return `\x1b[${pair[0]}m${String(text)}\x1b[${pair[1]}m`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Bold / dim are emphasis, not color — they survive monochrome. */
|
|
175
|
+
export function bold(s) {
|
|
176
|
+
if (!colorEnabled()) return String(s);
|
|
177
|
+
return `${ANSI.bold[0]}${String(s)}${ANSI.bold[1]}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function dim(s) {
|
|
181
|
+
if (process.env.CIRVIX_THEME === "high-contrast") return String(s); // contrast: never dim
|
|
182
|
+
if (!colorEnabled()) return String(s);
|
|
183
|
+
return `${ANSI.dim[0]}${String(s)}${ANSI.dim[1]}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The semantic palette. Prefer `colors.block("…")` over importing raw
|
|
188
|
+
* helpers — call sites name the meaning, this file owns the rendering.
|
|
189
|
+
*/
|
|
190
|
+
export const colors = {
|
|
191
|
+
get text() { return (s) => style(s, "text"); },
|
|
192
|
+
get muted() { return (s) => style(s, "muted"); },
|
|
193
|
+
get accent() { return (s) => style(s, "accent"); },
|
|
194
|
+
get allow() { return (s) => style(s, "allow"); },
|
|
195
|
+
get sanitize() { return (s) => style(s, "sanitize"); },
|
|
196
|
+
get block() { return (s) => style(s, "block"); },
|
|
197
|
+
get hold() { return (s) => style(s, "hold"); },
|
|
198
|
+
get info() { return (s) => style(s, "info"); },
|
|
199
|
+
get warning() { return (s) => style(s, "warning"); },
|
|
200
|
+
get error() { return (s) => style(s, "error"); },
|
|
201
|
+
get border() { return (s) => style(s, "border"); },
|
|
202
|
+
get selection() { return (s) => style(s, "selection"); },
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Decision → theme role. Single mapping, used by every renderer. */
|
|
206
|
+
export function roleForDecision(decision) {
|
|
207
|
+
switch (String(decision ?? "").toLowerCase()) {
|
|
208
|
+
case "allow": return "allow";
|
|
209
|
+
case "sanitize": return "sanitize";
|
|
210
|
+
case "deny": return "block";
|
|
211
|
+
case "require_approval": return "hold";
|
|
212
|
+
case "audit_only": return "muted";
|
|
213
|
+
default: return "muted";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Risk → theme role. */
|
|
218
|
+
export function roleForRisk(risk) {
|
|
219
|
+
switch (String(risk ?? "").toLowerCase()) {
|
|
220
|
+
case "critical": return "error";
|
|
221
|
+
case "high": return "warning";
|
|
222
|
+
case "medium": return "info";
|
|
223
|
+
case "low": return "muted";
|
|
224
|
+
default: return "muted";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Decision → icon + label. Icon is never the only signal (a11y). */
|
|
229
|
+
export function badgeForDecision(decision) {
|
|
230
|
+
switch (String(decision ?? "").toLowerCase()) {
|
|
231
|
+
case "allow": return { icon: "✓", label: "ALLOWED" };
|
|
232
|
+
case "sanitize": return { icon: "◇", label: "SANITIZED" };
|
|
233
|
+
case "deny": return { icon: "✕", label: "BLOCKED" };
|
|
234
|
+
case "require_approval": return { icon: "◷", label: "HELD FOR APPROVAL" };
|
|
235
|
+
case "audit_only": return { icon: "○", label: "AUDIT ONLY" };
|
|
236
|
+
default: return { icon: "?", label: String(decision ?? "UNKNOWN").toUpperCase() };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export { wrapAnsi };
|
package/src/index.mjs
CHANGED
|
@@ -140,6 +140,48 @@ export { scan } from "./commands/scan.mjs";
|
|
|
140
140
|
export { init, STARTER_POLICY } from "./commands/init.mjs";
|
|
141
141
|
export { status } from "./commands/status.mjs";
|
|
142
142
|
export { demo } from "./commands/demo.mjs";
|
|
143
|
+
export { consoleCmd as console } from "./commands/console.mjs";
|
|
144
|
+
export { onboard } from "./commands/onboard.mjs";
|
|
145
|
+
|
|
146
|
+
/* Product UI (engine stays presentation-free; these render events) -------- */
|
|
147
|
+
export {
|
|
148
|
+
THEME_NAMES,
|
|
149
|
+
THEME_NAMES as THEMES,
|
|
150
|
+
ROLES,
|
|
151
|
+
badgeForDecision,
|
|
152
|
+
colors,
|
|
153
|
+
roleForDecision,
|
|
154
|
+
roleForRisk,
|
|
155
|
+
setTheme,
|
|
156
|
+
style,
|
|
157
|
+
themeName,
|
|
158
|
+
} from "./core/theme.mjs";
|
|
159
|
+
export {
|
|
160
|
+
EVENT,
|
|
161
|
+
EventBus,
|
|
162
|
+
attachPipeline,
|
|
163
|
+
createEvent,
|
|
164
|
+
initialState,
|
|
165
|
+
latencyStats,
|
|
166
|
+
reduce,
|
|
167
|
+
} from "./core/events.mjs";
|
|
168
|
+
export {
|
|
169
|
+
blockedCard,
|
|
170
|
+
cirvixRow,
|
|
171
|
+
explainDecision,
|
|
172
|
+
frame,
|
|
173
|
+
header,
|
|
174
|
+
heldCard,
|
|
175
|
+
policyCard,
|
|
176
|
+
rule,
|
|
177
|
+
spinnerFrame,
|
|
178
|
+
toolCard,
|
|
179
|
+
userRow,
|
|
180
|
+
} from "./tui/cards.mjs";
|
|
181
|
+
export { statusBar } from "./tui/status.mjs";
|
|
182
|
+
export { activitySummary, collapsedFeed, activityRow } from "./tui/activity.mjs";
|
|
183
|
+
export { COMMANDS, filterCommands, paletteBox } from "./tui/palette.mjs";
|
|
184
|
+
export { ConsoleApp, parseRequest } from "./tui/app.mjs";
|
|
143
185
|
export { check as policyCheck, explain as policyExplain, list as policyList, loadPolicyFile, test as policyTest } from "./commands/policy.mjs";
|
|
144
186
|
|
|
145
187
|
/* Adapters & Platform ---------------------------------------------------- */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapsible activity feed — the fix for the "wall of logs".
|
|
3
|
+
*
|
|
4
|
+
* 100 tool calls never dump as 100 lines. They collapse to:
|
|
5
|
+
*
|
|
6
|
+
* ✓ Deployment completed
|
|
7
|
+
* 14 actions ├─ 8 allowed ├─ 4 sanitized └─ 2 blocked
|
|
8
|
+
* [Enter] inspect
|
|
9
|
+
*
|
|
10
|
+
* Expanding (Enter / click / `inspect`) shows the per-call rows with the
|
|
11
|
+
* semantic badge (icon + color + WORD — never icon alone).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { style, bold, dim, badgeForDecision, roleForDecision } from "../core/theme.mjs";
|
|
15
|
+
|
|
16
|
+
export function activitySummary(activity) {
|
|
17
|
+
const counts = { allow: 0, sanitize: 0, deny: 0, require_approval: 0, audit_only: 0 };
|
|
18
|
+
for (const e of activity) {
|
|
19
|
+
const d = e.decision ?? e.raw?.decision ?? "allow";
|
|
20
|
+
if (d in counts) counts[d]++;
|
|
21
|
+
}
|
|
22
|
+
return { total: activity.length, ...counts };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collapsedFeed(activity, { expanded = false, maxRows = 8 } = {}) {
|
|
26
|
+
const sum = activitySummary(activity);
|
|
27
|
+
if (sum.total === 0) return dim("No activity yet. Ask Cirvix to evaluate something.");
|
|
28
|
+
|
|
29
|
+
const head = `${style("✓", "allow")} ${bold(summaryTitle(sum))}\n\n` +
|
|
30
|
+
` ${dim(`${sum.total} actions`)}\n` +
|
|
31
|
+
` ${dim("├─")} ${sum.allow} allowed\n` +
|
|
32
|
+
` ${dim("├─")} ${sum.sanitize} sanitized\n` +
|
|
33
|
+
` ${dim("└─")} ${sum.deny + sum.require_approval} blocked/held\n\n` +
|
|
34
|
+
` ${dim("[Enter] inspect")}`;
|
|
35
|
+
|
|
36
|
+
if (!expanded) return head;
|
|
37
|
+
|
|
38
|
+
const rows = activity.slice(-maxRows).map((e) => activityRow(e)).join("\n");
|
|
39
|
+
const more = sum.total > maxRows ? dim(` … ${sum.total - maxRows} earlier (collapsed)`) + "\n" : "";
|
|
40
|
+
return `▼ ${bold("Activity")}\n\n${more}${rows}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function summaryTitle(sum) {
|
|
44
|
+
if (sum.deny > 0) return `Session activity — ${sum.deny} blocked`;
|
|
45
|
+
if (sum.require_approval > 0) return `Session activity — ${sum.require_approval} held`;
|
|
46
|
+
return `Session activity — all clear`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function activityRow(event) {
|
|
50
|
+
const d = event.decision ?? event.raw?.decision ?? "allow";
|
|
51
|
+
const badge = badgeForDecision(d);
|
|
52
|
+
const role = roleForDecision(d);
|
|
53
|
+
const when = clock(event.ts);
|
|
54
|
+
const tool = event.tool ?? event.raw?.tool ?? "—";
|
|
55
|
+
const target = truncate(event.resource ?? event.raw?.resource ?? "", 36);
|
|
56
|
+
const suffix = d === "deny" ? style(" → blocked", "block")
|
|
57
|
+
: d === "sanitize" ? style(" → sanitized", "sanitize")
|
|
58
|
+
: d === "require_approval" ? style(" → held", "hold")
|
|
59
|
+
: "";
|
|
60
|
+
return ` ${dim(when)} ${style(`${badge.icon} ${tool}`, role)}${target ? dim(` ${target}`) : ""}${suffix}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clock(ts) {
|
|
64
|
+
const m = String(ts ?? "").match(/T(\d{2}:\d{2}:\d{2})/);
|
|
65
|
+
return m ? m[1] : "--:--:--";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function truncate(s, n) {
|
|
69
|
+
const v = String(s ?? "");
|
|
70
|
+
return v.length <= n ? v : `…${v.slice(-(n - 1))}`;
|
|
71
|
+
}
|
package/src/tui/app.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cirvix console — the primary interactive UX.
|
|
3
|
+
*
|
|
4
|
+
* Layout (wide terminals):
|
|
5
|
+
* ┌ header ────────────────────────────────┐
|
|
6
|
+
* │ CONVERSATION │ ACTIVITY │
|
|
7
|
+
* │ user asks / cirvix │ ◐ policy.eval│
|
|
8
|
+
* │ responds / policy cards │ ✓ shell.exec │
|
|
9
|
+
* ├ composer ──────────────────────────────┤
|
|
10
|
+
* │ status bar │
|
|
11
|
+
* └────────────────────────────────────────┘
|
|
12
|
+
*
|
|
13
|
+
* Narrow terminals (<90 cols): the activity pane collapses into the
|
|
14
|
+
* transcript automatically. No horizontal scrolling, ever.
|
|
15
|
+
*
|
|
16
|
+
* Engine separation: the app owns a Pipeline + EventBus. It emits events,
|
|
17
|
+
* reduces them to state, and renders cards from state. Policy code never
|
|
18
|
+
* touches the terminal.
|
|
19
|
+
*
|
|
20
|
+
* Non-TTY / piped usage: `runOnce(text)` evaluates one line and returns
|
|
21
|
+
* the rendered output without starting readline — used by tests, CI, and
|
|
22
|
+
* `echo "…" | cirvix console`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Pipeline } from "../core/pipeline.mjs";
|
|
26
|
+
import { decideNow } from "../core/journal.mjs";
|
|
27
|
+
import { EventBus, createEvent, initialState, reduce, attachPipeline } from "../core/events.mjs";
|
|
28
|
+
import { header, policyCard, toolCard, blockedCard, heldCard, explainDecision, userRow, cirvixRow, spinnerFrame } from "./cards.mjs";
|
|
29
|
+
import { statusBar } from "./status.mjs";
|
|
30
|
+
import { collapsedFeed } from "./activity.mjs";
|
|
31
|
+
import { paletteBox } from "./palette.mjs";
|
|
32
|
+
import { setTheme, THEME_NAMES, bold, dim, style } from "../core/theme.mjs";
|
|
33
|
+
import { startComposer } from "./composer.mjs";
|
|
34
|
+
|
|
35
|
+
const VERSION = "0.1.0";
|
|
36
|
+
|
|
37
|
+
export class ConsoleApp {
|
|
38
|
+
constructor({ cwd = process.cwd(), rules = [], mode = "enforce", agent = "local", write = (s) => process.stdout.write(s) } = {}) {
|
|
39
|
+
this.cwd = cwd;
|
|
40
|
+
this.rules = rules;
|
|
41
|
+
this.agent = agent;
|
|
42
|
+
this.write = write;
|
|
43
|
+
this.bus = new EventBus();
|
|
44
|
+
this.state = initialState();
|
|
45
|
+
this.state.status.mode = mode;
|
|
46
|
+
this.expanded = false;
|
|
47
|
+
this.overlay = null; // 'palette' | null
|
|
48
|
+
|
|
49
|
+
this.pipeline = new Pipeline({ rules, cwd, agent, mode, onEvent: () => {} });
|
|
50
|
+
attachPipeline(this.pipeline, this.bus);
|
|
51
|
+
this.bus.onAny((e) => {
|
|
52
|
+
this.state = reduce(this.state, e);
|
|
53
|
+
});
|
|
54
|
+
this.bus.emit(createEvent.sessionStarted({ agent }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------------------------------------------------------- rendering */
|
|
58
|
+
|
|
59
|
+
renderHeader() {
|
|
60
|
+
return header({ mode: this.state.status.mode === "audit" ? "AUDIT MODE" : "PROTECTED", version: VERSION });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
renderStatus() {
|
|
64
|
+
return statusBar(this.state, { version: VERSION });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
renderActivity() {
|
|
68
|
+
return collapsedFeed(this.state.activity, { expanded: this.expanded });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Full re-render of the transcript region (used on clear / toggle). */
|
|
72
|
+
renderTranscript() {
|
|
73
|
+
const out = [this.renderHeader(), ``];
|
|
74
|
+
for (const m of this.state.messages) {
|
|
75
|
+
out.push(m.role === "user" ? userRow(m.text) : cirvixRow(m.text), ``);
|
|
76
|
+
}
|
|
77
|
+
if (this.narrow()) {
|
|
78
|
+
out.push(`▼ Activity`, this.renderActivity(), ``);
|
|
79
|
+
}
|
|
80
|
+
out.push(this.renderStatus());
|
|
81
|
+
return out.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
narrow() {
|
|
85
|
+
return (process.stdout.columns ?? 80) < 90;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------------------- events */
|
|
89
|
+
|
|
90
|
+
/** Animated "evaluating" line. Resolves with a stop() that clears it. */
|
|
91
|
+
animate(label) {
|
|
92
|
+
if (!process.stdout.isTTY) {
|
|
93
|
+
this.write(`${dim("◐")} ${label}...\n`);
|
|
94
|
+
return () => {};
|
|
95
|
+
}
|
|
96
|
+
let i = 0;
|
|
97
|
+
const timer = setInterval(() => {
|
|
98
|
+
process.stdout.write(`\r${dim(spinnerFrame(i++))} ${dim(label)}...`);
|
|
99
|
+
}, 90);
|
|
100
|
+
return () => {
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
process.stdout.write("\r" + " ".repeat(label.length + 6) + "\r");
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Evaluate one free-text line through policy and render the product UI. */
|
|
107
|
+
async runOnce(input) {
|
|
108
|
+
const text = String(input ?? "").trim();
|
|
109
|
+
if (!text) return "";
|
|
110
|
+
if (text.startsWith("/")) return this.runSlash(text);
|
|
111
|
+
|
|
112
|
+
this.bus.emit(createEvent.userMessage(text));
|
|
113
|
+
const lines = [userRow(text), ``, cirvixRow(`${dim("◐ Evaluating authorization...")}`)];
|
|
114
|
+
const stop = this.animate("Evaluating policy");
|
|
115
|
+
const parsed = parseRequest(text, { agent: this.agent });
|
|
116
|
+
let decision;
|
|
117
|
+
try {
|
|
118
|
+
({ decision } = decideNow({ ...parsed, rules: this.rules, cwd: this.cwd }));
|
|
119
|
+
} finally {
|
|
120
|
+
stop();
|
|
121
|
+
}
|
|
122
|
+
const event = {
|
|
123
|
+
decision_id: `dec_local`,
|
|
124
|
+
request_id: `req_local`,
|
|
125
|
+
agent: this.agent,
|
|
126
|
+
tool: parsed.tool,
|
|
127
|
+
resource: parsed.args.path ?? parsed.args.url ?? parsed.args.command ?? "",
|
|
128
|
+
risk: decision.risk ?? "medium",
|
|
129
|
+
decision: decision.decision ?? "deny",
|
|
130
|
+
policy: decision.rule,
|
|
131
|
+
reason: decision.reason,
|
|
132
|
+
latency_ms: 0,
|
|
133
|
+
};
|
|
134
|
+
this.bus.emit(createEvent.policyDecision(event));
|
|
135
|
+
if (event.decision === "require_approval") this.bus.emit(createEvent.approvalRequested({ tool: event.tool, resource: event.resource }));
|
|
136
|
+
this.bus.emit(createEvent.agentMessage(`Decision: ${event.decision}`));
|
|
137
|
+
|
|
138
|
+
return [...lines, ``, this.renderDecision(event, parsed), ``, this.renderStatus()].join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
renderDecision(event, parsed = {}) {
|
|
142
|
+
const d = event.decision;
|
|
143
|
+
if (d === "deny") {
|
|
144
|
+
const target = event.resource || parsed.args?.command || "";
|
|
145
|
+
return [blockedCard({ tool: event.tool, target, policy: event.policy, reason: event.reason }), ``, explainDecision(event)].join("\n");
|
|
146
|
+
}
|
|
147
|
+
if (d === "require_approval") {
|
|
148
|
+
return heldCard({ tool: event.tool, target: event.resource, approvers: event.approvers ?? [], reason: event.reason });
|
|
149
|
+
}
|
|
150
|
+
return [
|
|
151
|
+
policyCard({
|
|
152
|
+
action: event.tool,
|
|
153
|
+
risk: event.risk,
|
|
154
|
+
policy: event.policy ?? "default",
|
|
155
|
+
identity: `agent:${event.agent}`,
|
|
156
|
+
reason: event.reason,
|
|
157
|
+
latencyMs: event.latency_ms,
|
|
158
|
+
}),
|
|
159
|
+
...(d === "sanitize" ? [``, explainDecision(event)] : []),
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ---------------------------------------------------------------- slash */
|
|
164
|
+
|
|
165
|
+
async runSlash(input) {
|
|
166
|
+
const [cmd, ...rest] = input.trim().split(/\s+/);
|
|
167
|
+
const arg = rest.join(" ");
|
|
168
|
+
switch (cmd.toLowerCase()) {
|
|
169
|
+
case "/help":
|
|
170
|
+
return paletteBox("/");
|
|
171
|
+
case "/quit":
|
|
172
|
+
case "/exit":
|
|
173
|
+
return "quit";
|
|
174
|
+
case "/clear":
|
|
175
|
+
return "\x1b[2J\x1b[0;0H" + this.renderTranscript();
|
|
176
|
+
case "/expand":
|
|
177
|
+
this.expanded = true;
|
|
178
|
+
return this.renderActivity();
|
|
179
|
+
case "/collapse":
|
|
180
|
+
this.expanded = false;
|
|
181
|
+
return this.renderActivity();
|
|
182
|
+
case "/theme": {
|
|
183
|
+
if (!arg) return `Usage: /theme <${THEME_NAMES.join("|")}>\nCurrent: ${bold(process.env.CIRVIX_THEME ?? "dark")}`;
|
|
184
|
+
try {
|
|
185
|
+
setTheme(arg);
|
|
186
|
+
process.env.CIRVIX_THEME = arg;
|
|
187
|
+
return `${style("✓", "allow")} Theme → ${bold(arg)}`;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return `${style("✕", "block")} ${err.message}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
case "/audit":
|
|
193
|
+
case "/logs":
|
|
194
|
+
return this.renderActivity();
|
|
195
|
+
case "/policies": {
|
|
196
|
+
const names = this.rules.map((r) => ` • ${r.name ?? "(unnamed)"} ${dim(r.effect ?? "")}`).join("\n");
|
|
197
|
+
return `${bold("Policies")} ${dim(`(${this.rules.length} rules)`)}\n${names || dim(" (no rules loaded)")}`;
|
|
198
|
+
}
|
|
199
|
+
case "/sessions":
|
|
200
|
+
return `${bold("Session")} ${this.state.session?.id ?? "—"} ${dim(`agent ${this.state.session?.agent ?? this.agent}`)}\n${dim(`${this.state.status.requests} requests this session`)}`;
|
|
201
|
+
case "/doctor":
|
|
202
|
+
return doctor(this.rules, this.state);
|
|
203
|
+
case "/demo":
|
|
204
|
+
return dim("Run `cirvix demo` in a fresh terminal for the full live interception demo.");
|
|
205
|
+
case "/approvals": {
|
|
206
|
+
if (!this.state.approvals.length) return dim("nothing waiting on a human");
|
|
207
|
+
return this.state.approvals.map((a) => ` ${bold(a.approval_id ?? a.id ?? "approval")} ${a.tool ?? ""} ${dim(a.resource ?? "")}`).join("\n");
|
|
208
|
+
}
|
|
209
|
+
case "/config":
|
|
210
|
+
return `${bold("Config")}\n ${dim("theme")} ${process.env.CIRVIX_THEME ?? "dark"}\n ${dim("agent")} ${this.agent}\n ${dim("mode")} ${this.state.status.mode}`;
|
|
211
|
+
default: {
|
|
212
|
+
// Prefix completion: "/pol" → show matches instead of erroring.
|
|
213
|
+
const { filterCommands } = await import("./palette.mjs");
|
|
214
|
+
const matches = filterCommands(input);
|
|
215
|
+
if (matches.length) return paletteBox(input);
|
|
216
|
+
return dim(`Unknown command "${cmd}". Type / for the palette.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ---------------------------------------------------------- interactive */
|
|
222
|
+
|
|
223
|
+
async start() {
|
|
224
|
+
this.write(this.renderTranscript() + "\n\n");
|
|
225
|
+
this.write(dim("Type / for commands. Ctrl+K palette · Ctrl+O activity · Esc close.\n"));
|
|
226
|
+
const rl = startComposer({
|
|
227
|
+
prompt: "> ",
|
|
228
|
+
onLine: async (line) => {
|
|
229
|
+
if (!line) return;
|
|
230
|
+
if (line === "/quit" || line === "/exit") return "quit";
|
|
231
|
+
const out = await this.runOnce(line);
|
|
232
|
+
if (out === "quit") return "quit";
|
|
233
|
+
this.write("\n" + out + "\n\n");
|
|
234
|
+
if (!this.narrow()) {
|
|
235
|
+
// Side-pane refresh on wide terminals: reprint compact activity.
|
|
236
|
+
this.write(dim("─ Activity ─") + "\n" + this.renderActivity() + "\n\n" + this.renderStatus() + "\n");
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
onKey: (key) => {
|
|
240
|
+
if (key === "toggle-activity") {
|
|
241
|
+
this.expanded = !this.expanded;
|
|
242
|
+
this.write("\n" + this.renderActivity() + "\n");
|
|
243
|
+
} else if (key === "palette") {
|
|
244
|
+
this.write("\n" + paletteBox("/") + "\n");
|
|
245
|
+
} else if (key === "policies") {
|
|
246
|
+
this.write("\n" + `Policies: ${this.rules.length} loaded` + "\n");
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
await new Promise((resolve) => rl.on("close", resolve));
|
|
251
|
+
this.bus.emit(createEvent.sessionEnded({}));
|
|
252
|
+
this.write("\n" + dim("Session ended. Audit chain intact.") + "\n");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ------------------------------------------------------------------ */
|
|
257
|
+
/* Helpers */
|
|
258
|
+
/* ------------------------------------------------------------------ */
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Heuristic: turn "Can the deploy bot run `rm -rf /`?" into a policy
|
|
262
|
+
* request. Deliberately conservative — unknown shapes become a low-risk
|
|
263
|
+
* workspace read that policy will judge on its merits, never an implicit
|
|
264
|
+
* permit of something dangerous.
|
|
265
|
+
*/
|
|
266
|
+
export function parseRequest(text, { agent = "local" } = {}) {
|
|
267
|
+
const t = String(text);
|
|
268
|
+
const url = t.match(/https?:\/\/[^\s"'`]+/i)?.[0];
|
|
269
|
+
const credPath = t.match(/(~\/\.aws\/credentials|\.env[^\s]*|\.aws[^\s]*)/i)?.[0];
|
|
270
|
+
const cmd = t.match(/`([^`]+)`/)?.[1] ?? t.match(/run\s+"([^"]+)"/i)?.[1];
|
|
271
|
+
|
|
272
|
+
if (credPath) return { tool: "read_file", server: null, args: { path: credPath }, agent };
|
|
273
|
+
if (url && /collect|attacker|exfil|send|post/i.test(t)) {
|
|
274
|
+
return { tool: "http_request", server: null, args: { url }, agent };
|
|
275
|
+
}
|
|
276
|
+
if (url) return { tool: "http_request", server: null, args: { url }, agent };
|
|
277
|
+
if (cmd) return { tool: "shell_exec", server: null, args: { command: cmd }, agent };
|
|
278
|
+
if (/deploy|production/i.test(t)) return { tool: "shell_exec", server: null, args: { command: "deploy production" }, agent };
|
|
279
|
+
if (/database|db|sql/i.test(t)) return { tool: "db_query", server: null, args: { sql: t.slice(0, 200) }, agent };
|
|
280
|
+
return { tool: "read_file", server: null, args: { path: "./" + t.slice(0, 60) }, agent };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function doctor(rules, state) {
|
|
284
|
+
const rows = [
|
|
285
|
+
["Engine", style("✓ reachable", "allow")],
|
|
286
|
+
["Rules", rules.length ? `${rules.length} loaded` : style("none — run cirvix init", "warning")],
|
|
287
|
+
["Session", `${state.status.requests} requests`],
|
|
288
|
+
["Blocked", state.status.blocked > 0 ? style(`${state.status.blocked} denied`, "block") : "0"],
|
|
289
|
+
["Terminal", `${process.stdout.columns ?? 80} cols ${process.stdout.isTTY ? "(TTY)" : "(piped)"}`],
|
|
290
|
+
];
|
|
291
|
+
return `${bold("Doctor")}\n` + rows.map(([k, v]) => ` ${dim(k.padEnd(9))} ${v}`).join("\n");
|
|
292
|
+
}
|