@secondlayer/sentinel 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/dist/cli.js ADDED
@@ -0,0 +1,919 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli/main.ts
4
+ import { createInterface } from "node:readline/promises";
5
+
6
+ // client/credentials.ts
7
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ var CREDITS_API_URL = "https://api.secondlayer.tools";
11
+ var creditsUrl = () => (process.env.SL_CREDITS_API_URL ?? CREDITS_API_URL).replace(/\/+$/, "");
12
+ var dir = () => join(process.env.HOME || homedir(), ".secondlayer");
13
+ var sessionPath = () => join(dir(), "session.json");
14
+ var tokenFilePath = () => join(dir(), "sentinel.json");
15
+ async function readJson(path) {
16
+ try {
17
+ return JSON.parse(await readFile(path, "utf8"));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ async function writePrivate(path, data) {
23
+ await mkdir(dirname(path), { recursive: true });
24
+ const tmp = join(dirname(path), `.${process.pid}.tmp`);
25
+ await writeFile(tmp, `${JSON.stringify(data, null, 2)}
26
+ `, { mode: 384, encoding: "utf8" });
27
+ await chmod(tmp, 384);
28
+ await rename(tmp, path);
29
+ }
30
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
31
+ var isSession = (v) => isRecord(v) && typeof v.token === "string" && v.token !== "" && typeof v.email === "string";
32
+ async function readSessionFile() {
33
+ const raw = await readJson(sessionPath());
34
+ if (isRecord(raw) && isRecord(raw.sessions))
35
+ return { sessions: raw.sessions };
36
+ if (isSession(raw))
37
+ return { sessions: { [CREDITS_API_URL]: raw } };
38
+ return { sessions: {} };
39
+ }
40
+ async function readSession(url = creditsUrl()) {
41
+ const s = (await readSessionFile()).sessions[url.replace(/\/+$/, "")];
42
+ return isSession(s) ? s : null;
43
+ }
44
+ async function writeSession(session, url = creditsUrl()) {
45
+ const file = await readSessionFile();
46
+ file.sessions[url.replace(/\/+$/, "")] = session;
47
+ await writePrivate(sessionPath(), file);
48
+ }
49
+ async function clearSession(url = creditsUrl()) {
50
+ const file = await readSessionFile();
51
+ delete file.sessions[url.replace(/\/+$/, "")];
52
+ if (Object.keys(file.sessions).length === 0)
53
+ await rm(sessionPath(), { force: true });
54
+ else
55
+ await writePrivate(sessionPath(), file);
56
+ }
57
+ async function readTokenFile() {
58
+ const raw = await readJson(tokenFilePath());
59
+ return isRecord(raw) && typeof raw.token === "string" && raw.token !== "" ? { token: raw.token, email: typeof raw.email === "string" ? raw.email : undefined } : null;
60
+ }
61
+ var writeTokenFile = (file) => writePrivate(tokenFilePath(), file);
62
+ var clearTokenFile = () => rm(tokenFilePath(), { force: true });
63
+ async function resolveToken(flag) {
64
+ const env = process.env.SENTINEL_API_KEY;
65
+ if (env)
66
+ return { token: env, source: "env" };
67
+ if (flag)
68
+ return { token: flag, source: "flag" };
69
+ const file = await readTokenFile();
70
+ if (file)
71
+ return { token: file.token, source: "token file" };
72
+ const session = await readSession();
73
+ return session ? { token: session.token, source: "secondlayer session" } : null;
74
+ }
75
+ var NOT_LOGGED_IN = "Not logged in. Run: sentinel login";
76
+
77
+ // client/sentinel-client.ts
78
+ var DEFAULT_BASE_URL = "https://api.runsentinel.app";
79
+
80
+ class SentinelError extends Error {
81
+ status;
82
+ constructor(status, message) {
83
+ super(message);
84
+ this.status = status;
85
+ this.name = "SentinelError";
86
+ }
87
+ }
88
+ var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : `asset:${s.asset}`;
89
+ function subjectName(s, units) {
90
+ if (s.kind === "fn")
91
+ return s.fn;
92
+ if (units?.symbol)
93
+ return units.symbol;
94
+ if (s.asset.toLowerCase() === "stx")
95
+ return "STX";
96
+ return s.asset.split("::")[1] ?? s.asset;
97
+ }
98
+
99
+ class SubjectError extends Error {
100
+ candidates;
101
+ constructor(message, candidates) {
102
+ super(message);
103
+ this.candidates = candidates;
104
+ this.name = "SubjectError";
105
+ }
106
+ }
107
+ function groupSubjects(watches) {
108
+ const out = new Map;
109
+ for (const w of watches) {
110
+ const key = subjectKey(w.subject);
111
+ const g = out.get(key) ?? { key, name: subjectName(w.subject, w.units), watches: [] };
112
+ g.watches.push(w);
113
+ out.set(key, g);
114
+ }
115
+ return [...out.values()];
116
+ }
117
+ function resolveSubject(watches, name) {
118
+ const groups = groupSubjects(watches);
119
+ const q = name.trim().toLowerCase();
120
+ const hits = groups.filter((g) => {
121
+ const s = g.watches[0].subject;
122
+ const asset = s.kind === "asset" ? s.asset.toLowerCase() : null;
123
+ return g.name.toLowerCase() === q || g.key.toLowerCase() === q || asset === q || asset !== null && asset.split("::")[1] === q;
124
+ });
125
+ if (hits.length === 1)
126
+ return hits[0];
127
+ if (hits.length === 0) {
128
+ const names = groups.map((g) => g.name);
129
+ throw new SubjectError(`No watched subject called "${name}". Watched: ${names.join(", ") || "nothing yet"}`, names);
130
+ }
131
+ const names = hits.map((g) => g.key);
132
+ throw new SubjectError(`"${name}" matches more than one subject: ${names.join(", ")}`, names);
133
+ }
134
+ var group = (digits) => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
135
+ function toUnits(base, decimals) {
136
+ if (!/^\d+$/.test(base))
137
+ return base;
138
+ const digits = base.replace(/^0+(?=\d)/, "");
139
+ if (decimals === 0)
140
+ return group(digits);
141
+ const padded = digits.padStart(decimals + 1, "0");
142
+ const whole = padded.slice(0, padded.length - decimals);
143
+ const frac = padded.slice(padded.length - decimals).replace(/0+$/, "");
144
+ return frac ? `${group(whole)}.${frac}` : group(whole);
145
+ }
146
+ function amountText(base, units) {
147
+ return units ? `${toUnits(base, units.decimals)} ${units.symbol}` : `${group(base)} base units`;
148
+ }
149
+ var WINDOWS = { 720: "1 hour", 4320: "6 hours", 17280: "1 day" };
150
+ var windowText = (blocks) => WINDOWS[blocks] ?? `${blocks.toLocaleString("en-US")} blocks`;
151
+ function ruleSummary(rule, units) {
152
+ switch (rule.kind) {
153
+ case "signature":
154
+ return `verified finding: ${rule.findingTitle}`;
155
+ case "audit":
156
+ return rule.triggerClass === "governance.proposal_submitted" ? "reviews each governance proposal" : "reviews each upgrade";
157
+ case "callerAllowlist":
158
+ return rule.allow.length ? `any call from outside ${rule.allow.length} allowed principal(s)` : "any call";
159
+ case "outflowSingle":
160
+ return rule.amount ? `a single outflow of ${amountText(rule.amount, units)} or more` : "every outflow";
161
+ case "outflowWindow":
162
+ return `${amountText(rule.amount, units)} or more out within ${windowText(rule.blocks)}`;
163
+ case "valueNewActor":
164
+ return `${amountText(rule.amount, units)} or more to or from a new actor`;
165
+ case "actor": {
166
+ const names = {
167
+ newCaller: "a new caller",
168
+ firstCall: "a first call",
169
+ roleChange: "a role change"
170
+ };
171
+ return `${rule.signals.map((x) => names[x]).join(", ")}${rule.allow.length ? ` (outside ${rule.allow.length} allowed)` : ""}`;
172
+ }
173
+ }
174
+ }
175
+ function kindTag(rule) {
176
+ switch (rule.kind) {
177
+ case "signature":
178
+ return "finding";
179
+ case "outflowSingle":
180
+ case "outflowWindow":
181
+ case "valueNewActor":
182
+ return "outflow";
183
+ case "callerAllowlist":
184
+ case "actor":
185
+ return "who-acts";
186
+ case "audit":
187
+ return rule.triggerClass === "governance.proposal_submitted" ? "governance" : "upgrade";
188
+ }
189
+ }
190
+ function subjectStatus(ws) {
191
+ const st = ws.map((w) => w.status);
192
+ if (st.includes("live"))
193
+ return "live";
194
+ return st.length > 0 && st.every((x) => x === "paused") ? "paused" : "suggested";
195
+ }
196
+ var isMutableRule = (rule) => rule.kind !== "signature" && rule.kind !== "audit";
197
+ function mutedUntilOf(w, now = Date.now()) {
198
+ const at = w.delivery.mutedUntil;
199
+ return at && Date.parse(at) > now ? at : null;
200
+ }
201
+ function createClient(opts) {
202
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
203
+ const doFetch = opts.fetch ?? globalThis.fetch;
204
+ const authHeader = () => ({ authorization: `Bearer ${opts.token}` });
205
+ async function call(method, path, body) {
206
+ const res = await doFetch(`${baseUrl}${path}`, {
207
+ method,
208
+ headers: {
209
+ ...authHeader(),
210
+ ...body === undefined ? {} : { "content-type": "application/json" }
211
+ },
212
+ body: body === undefined ? undefined : JSON.stringify(body)
213
+ });
214
+ const data = await res.json().catch(() => null);
215
+ if (!res.ok)
216
+ throw new SentinelError(res.status, data?.error ?? `HTTP ${res.status}`);
217
+ return data;
218
+ }
219
+ const plan = (id) => `/v1/plans/${encodeURIComponent(id)}`;
220
+ const appPlan = (id) => `/plan/${encodeURIComponent(id)}`;
221
+ return {
222
+ me: () => call("GET", "/v1/me"),
223
+ plans: async () => (await call("GET", "/v1/plans")).plans,
224
+ plan: (id) => call("GET", plan(id)),
225
+ finding: (id, n) => call("GET", `${plan(id)}/findings/${n}`),
226
+ runs: async (id) => (await call("GET", `${plan(id)}/runs`)).runs,
227
+ alerts: async (contract) => (await call("GET", contract ? `/v1/alerts?contract=${encodeURIComponent(contract)}` : "/v1/alerts")).alerts,
228
+ reverify: (id) => call("POST", `/plan/${encodeURIComponent(id)}/reverify`),
229
+ reproduce: (id, finding) => call("POST", `/plan/${encodeURIComponent(id)}/reproduce`, { finding }),
230
+ challenge: (id, finding, reason) => call("POST", `${appPlan(id)}/challenge`, { finding, reason }),
231
+ replay: (id, finding) => call("POST", `${appPlan(id)}/replay`, { finding }),
232
+ directed: (id, prompt) => call("POST", `${appPlan(id)}/directed`, { prompt }),
233
+ testsFromRepo: (id) => call("POST", `${appPlan(id)}/tests`, { fromRepo: true }),
234
+ previewPlan: (contractId) => call("POST", "/plan/preview", { contractId }),
235
+ createPlan: (contractId) => call("POST", "/plan", { contractId }),
236
+ billing: () => call("GET", "/billing"),
237
+ watches: (planId) => call("GET", `${appPlan(planId)}/watches`),
238
+ setRule: (planId, watchId, rule) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/rule`, { rule }),
239
+ addRule: (planId, subject, rule) => call("POST", `${appPlan(planId)}/watches`, { subject, rule }),
240
+ setWatches: (planId, watchIds, on) => call("POST", `${appPlan(planId)}/watches/state`, {
241
+ watchIds,
242
+ on
243
+ }),
244
+ mute: (planId, watchId, mutedUntil) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/delivery`, { mutedUntil }),
245
+ muteAlert: (alertId, hours) => call("POST", `/alerts/${encodeURIComponent(alertId)}/mute`, { hours }),
246
+ parseRule: (planId, subject, text) => call("POST", `${appPlan(planId)}/rules/parse`, { subject, text }),
247
+ monitorSettings: (planId, settings) => call("POST", `${appPlan(planId)}/monitor`, settings),
248
+ testAlert: (planId, dest = {}) => call("POST", `${appPlan(planId)}/testalert`, dest),
249
+ watchContract: (contractId) => call("POST", "/contracts/watch", {
250
+ contractId
251
+ }),
252
+ stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`)
253
+ };
254
+ }
255
+ var HOUR_MS = 3600000;
256
+ var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
257
+ async function watchContext(client, planId) {
258
+ const plan = await client.plan(planId);
259
+ return {
260
+ planId,
261
+ contractId: plan.contractId,
262
+ access: plan.access,
263
+ watches: (await client.watches(planId)).watches
264
+ };
265
+ }
266
+ async function savedMonitor(client, planId) {
267
+ const m = (await client.plan(planId)).monitor;
268
+ if (!m?.status)
269
+ throw new SentinelError(409, "Turn monitoring on first.");
270
+ return m;
271
+ }
272
+ var savedChoices = async (client, planId) => (await savedMonitor(client, planId)).watches;
273
+ async function planOfContract(client, contractId) {
274
+ const me = await client.me();
275
+ const hit = [...me.watching ?? [], ...me.claims ?? []].find((c) => c.contractId === contractId);
276
+ if (!hit)
277
+ throw new SentinelError(404, `You aren't monitoring ${contractId}.`);
278
+ return hit.planId;
279
+ }
280
+
281
+ // cli/auth.ts
282
+ var sessionExpiry = () => new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
283
+ async function post(doFetch, path, body) {
284
+ try {
285
+ const res = await doFetch(`${creditsUrl()}${path}`, {
286
+ method: "POST",
287
+ headers: { "content-type": "application/json" },
288
+ body: JSON.stringify(body)
289
+ });
290
+ const data = await res.json().catch(() => null);
291
+ if (!res.ok)
292
+ return { ok: false, error: data?.error ?? data?.message ?? `HTTP ${res.status}` };
293
+ return { ok: true, data };
294
+ } catch (e) {
295
+ return { ok: false, error: `Couldn't reach ${creditsUrl()}: ${e.message}` };
296
+ }
297
+ }
298
+ async function check(client) {
299
+ try {
300
+ const me = await client.me();
301
+ return { email: me.user?.email ?? null };
302
+ } catch (e) {
303
+ if (e instanceof SentinelError)
304
+ return { error: e.message, status: e.status };
305
+ return { error: e.message, status: 0 };
306
+ }
307
+ }
308
+ async function login(flags, io) {
309
+ const { auth } = io;
310
+ if (flags.includes("--with-token")) {
311
+ const token = (await auth.readStdin()).trim();
312
+ if (!token) {
313
+ io.err('No token on stdin. Usage: echo "$KEY" | sentinel login --with-token');
314
+ return 1;
315
+ }
316
+ const r = await check(auth.clientFor(token));
317
+ if ("error" in r) {
318
+ io.err(`Token rejected: ${r.error}`);
319
+ return 1;
320
+ }
321
+ await writeTokenFile({ token, email: r.email ?? undefined });
322
+ io.out(`Logged in${r.email ? ` as ${r.email}` : ""}.`);
323
+ return 0;
324
+ }
325
+ const existing = await readSession();
326
+ if (existing && !flags.includes("--force")) {
327
+ io.out(`Already logged in as ${existing.email}. Run again with --force to switch accounts.`);
328
+ return 0;
329
+ }
330
+ const doFetch = auth.fetch ?? globalThis.fetch;
331
+ const email = (await auth.prompt("Email: ")).trim();
332
+ if (!/^.+@.+\..+$/.test(email)) {
333
+ io.err("Invalid email");
334
+ return 1;
335
+ }
336
+ const sent = await post(doFetch, "/api/auth/magic-link", { email });
337
+ if (!sent.ok) {
338
+ io.err(sent.error);
339
+ return 1;
340
+ }
341
+ io.out("Check your inbox for a 6-digit code.");
342
+ if (sent.data.code)
343
+ io.out(`(DEV_MODE code: ${sent.data.code})`);
344
+ const code = (await auth.prompt("Code: ")).trim();
345
+ if (!/^\d{6}$/.test(code)) {
346
+ io.err("Expected 6 digits");
347
+ return 1;
348
+ }
349
+ const verified = await post(doFetch, "/api/auth/verify", { email, code });
350
+ if (!verified.ok) {
351
+ io.err(verified.error);
352
+ return 1;
353
+ }
354
+ const { sessionToken, account } = verified.data;
355
+ await writeSession({
356
+ token: sessionToken,
357
+ email: account.email,
358
+ accountId: account.id,
359
+ expiresAt: sessionExpiry()
360
+ });
361
+ io.out(`Logged in as ${account.email}`);
362
+ const r = await check(auth.clientFor(sessionToken));
363
+ if ("error" in r) {
364
+ io.err(r.error);
365
+ return r.status === 403 ? 0 : 1;
366
+ }
367
+ return 0;
368
+ }
369
+ async function logout(io) {
370
+ await clearTokenFile();
371
+ await clearSession();
372
+ io.out("Logged out.");
373
+ io.out(`This also signs the secondlayer CLI out of ${creditsUrl()}.`);
374
+ return 0;
375
+ }
376
+ async function whoami(client, io) {
377
+ const r = await check(client);
378
+ if ("error" in r) {
379
+ io.err(r.error);
380
+ return 1;
381
+ }
382
+ io.out(`${r.email ?? "signed in"} (${io.auth.source ?? "unknown credential"})`);
383
+ return 0;
384
+ }
385
+
386
+ // cli/sentinel.ts
387
+ var USAGE = `sentinel <command>
388
+
389
+ login [--force] sign in with your secondlayer account (email, then a 6-digit code)
390
+ login --with-token read an API key from stdin (CI); checked before it is saved
391
+ logout forget the saved login (also signs the secondlayer CLI out)
392
+ whoami who you are and where the credential came from
393
+ new <contract> [--yes] plan a contract: see what it would do and cost; --yes starts it
394
+ balance your balance and what a plan, a deep run and a monitored event cost
395
+ alerts [--contract <id>] your alerts, newest first
396
+
397
+ plans your contracts: monitoring, worst evidence, plan id
398
+ plan <id> [--json] verdict and findings (--json: the raw v1 response)
399
+ finding <id> <n> [--json] one finding
400
+ runs <id> follow-up runs on a plan
401
+ watch <id> follow running runs until none are left
402
+ reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
403
+
404
+ paid runs on a plan you own (each starts only with --yes; without it you see what it would do):
405
+ reproduce <plan> <n> [--yes] write and run a PoC for finding n in the sandbox
406
+ challenge <plan> <n> "<reason>" [--yes] re-verify finding n against your argument
407
+ replay <plan> <n> [--yes] re-run finding n's stored PoC
408
+ directed <plan> "<what to watch>" [--yes] re-plan around what you want watched
409
+ tests <plan> --from-repo [--yes] run the Clarinet suite from a pre-launch plan's repo
410
+
411
+ A key needs Plans & findings (read for the above, write for new and the runs above); monitoring commands need Monitoring
412
+ (read to look, write to change); mute-alert needs Alerts: write.
413
+
414
+ monitoring (a write only happens with --yes; without it you see what would change):
415
+ watches <plan> [--json] subjects, rules, status, mutes
416
+ rule <plan> <subject> "<sentence>" [--yes] read the sentence; --yes adds or edits the rules it found
417
+ watch on|off <plan> <subject> [--yes] on and off apply now
418
+ mute <plan> <subject> [1h|24h|7d] [--yes] default 24h
419
+ unmute <plan> <subject> [--yes]
420
+ mute-alert <alert> [1h|24h|7d] [--yes] mute the watch behind an alert
421
+ destinations <plan> [--email on|off] [--webhook URL] [--slack URL] [--yes]
422
+ with no options: where alerts go now ("" clears a URL)
423
+ test-alert <plan> [--yes] a sample alert to every saved destination
424
+ follow <contract> [--yes] start monitoring a deployed contract
425
+ unfollow <contract> [--yes]
426
+
427
+ <subject> is a function name or a token symbol, as the watches command shows it.
428
+
429
+ credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENTINEL_API_URL
430
+ `;
431
+ var POLL_MS = 5000;
432
+ var VALUE_FLAGS = new Set(["--email", "--webhook", "--slack", "--contract"]);
433
+ var cell = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
434
+ var table = (rows) => {
435
+ const text = rows.map((r) => r.map((c) => cell(c)));
436
+ const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
437
+ return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
438
+ };
439
+ var phaseOf = (r) => {
440
+ const last = r.events.at(-1);
441
+ return last ? [last.name ?? last.kind, last.state].filter(Boolean).join(" ") : "starting";
442
+ };
443
+ var verdictLine = (p) => {
444
+ const r = p.report;
445
+ const head = `${p.contractId} ${p.status}`;
446
+ return r ? `${head} ${cell(r.severity)}/${cell(r.class)} alert=${cell(r.alertLevel)}` : head;
447
+ };
448
+ async function run(argv, io) {
449
+ const flags = [];
450
+ const args = [];
451
+ const values = {};
452
+ const usage = (msg) => {
453
+ io.err(msg ? `${msg}
454
+
455
+ ${USAGE}` : USAGE);
456
+ return 2;
457
+ };
458
+ for (let i = 0;i < argv.length; i++) {
459
+ const a = argv[i];
460
+ if (!a.startsWith("--"))
461
+ args.push(a);
462
+ else if (VALUE_FLAGS.has(a)) {
463
+ if (i + 1 >= argv.length)
464
+ return usage(`${a} needs a value`);
465
+ values[a] = argv[++i];
466
+ } else
467
+ flags.push(a);
468
+ }
469
+ const [cmd, id, n] = args;
470
+ const json = flags.includes("--json");
471
+ const known = new Set(["--json", "--yes", "--help", "--force", "--with-token", "--from-repo"]);
472
+ if (flags.some((f) => !known.has(f)))
473
+ return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
474
+ if (flags.includes("--help") || !cmd || cmd === "help") {
475
+ io.out(USAGE);
476
+ return cmd || flags.includes("--help") ? 0 : 2;
477
+ }
478
+ const needId = () => id ? null : usage(`sentinel ${cmd} needs a plan id`);
479
+ try {
480
+ switch (cmd) {
481
+ case "plans": {
482
+ const plans = await io.client.plans();
483
+ if (json)
484
+ io.out(JSON.stringify(plans, null, 2));
485
+ else {
486
+ const rows = plans.map((p) => [
487
+ p.contractId,
488
+ cell(p.monitoring, "none"),
489
+ p.evidence?.worst ? `${p.evidence.worst.severity} ${p.evidence.worst.evidence}` : null,
490
+ p.planId
491
+ ]);
492
+ for (const l of table([["CONTRACT", "MONITORING", "WORST", "PLAN"], ...rows]))
493
+ io.out(l);
494
+ }
495
+ return 0;
496
+ }
497
+ case "plan": {
498
+ const bad = needId();
499
+ if (bad !== null)
500
+ return bad;
501
+ const p = await io.client.plan(id);
502
+ if (json) {
503
+ io.out(JSON.stringify(p, null, 2));
504
+ return 0;
505
+ }
506
+ io.out(verdictLine(p));
507
+ if (p.access === "claimed")
508
+ io.out("Exploit detail is hidden until you verify control in the web app.");
509
+ const rows = (p.report?.findings ?? []).map((f, i) => [
510
+ i,
511
+ f.severity,
512
+ f.class,
513
+ f.verdict,
514
+ f.disposition,
515
+ f.title
516
+ ]);
517
+ for (const l of table(rows))
518
+ io.out(l);
519
+ return 0;
520
+ }
521
+ case "finding": {
522
+ const bad = needId();
523
+ if (bad !== null)
524
+ return bad;
525
+ if (n === undefined || !/^\d+$/.test(n))
526
+ return usage("sentinel finding needs a plan id and a finding number");
527
+ const f = await io.client.finding(id, Number(n));
528
+ if (json) {
529
+ io.out(JSON.stringify(f, null, 2));
530
+ return 0;
531
+ }
532
+ io.out(`${f.title}
533
+ ${[f.severity, f.class, f.verdict, f.disposition].map((v) => cell(v)).join(" ")}`);
534
+ if (f.headline)
535
+ io.out(f.headline);
536
+ return 0;
537
+ }
538
+ case "runs": {
539
+ const bad = needId();
540
+ if (bad !== null)
541
+ return bad;
542
+ const runs = await io.client.runs(id);
543
+ for (const l of table(runs.map((r) => [
544
+ r.id,
545
+ r.kind,
546
+ r.status,
547
+ r.status === "running" ? phaseOf(r) : r.finishedAt ?? ""
548
+ ])))
549
+ io.out(l);
550
+ return 0;
551
+ }
552
+ case "watch": {
553
+ if (id === "on" || id === "off") {
554
+ return await monitoring("watch", args.slice(2), id === "on", flags, values, io, usage);
555
+ }
556
+ const bad = needId();
557
+ if (bad !== null)
558
+ return bad;
559
+ const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
560
+ const seen = new Map;
561
+ for (;; ) {
562
+ const running = (await io.client.runs(id)).filter((r) => r.status === "running");
563
+ for (const r of running) {
564
+ const phase = phaseOf(r);
565
+ if (seen.get(r.id) !== phase) {
566
+ seen.set(r.id, phase);
567
+ io.out(`${r.kind} ${r.id}: ${phase}`);
568
+ }
569
+ }
570
+ if (running.length === 0) {
571
+ io.out("Nothing running.");
572
+ return 0;
573
+ }
574
+ await sleep(POLL_MS);
575
+ }
576
+ }
577
+ case "reverify": {
578
+ const bad = needId();
579
+ if (bad !== null)
580
+ return bad;
581
+ if (!flags.includes("--yes")) {
582
+ io.out("This checks the plan's leads again: a stronger second pass on Opus, then a PoC for each bug it confirms. About 10 min, $3, charged only if it finishes.");
583
+ io.out(`Run again with --yes to start: sentinel reverify ${id} --yes`);
584
+ return 0;
585
+ }
586
+ const r = await io.client.reverify(id);
587
+ io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}`);
588
+ return 0;
589
+ }
590
+ case "login":
591
+ case "whoami": {
592
+ if (!io.auth)
593
+ return usage(`sentinel ${cmd} isn't available here`);
594
+ const withAuth = { ...io, auth: io.auth };
595
+ return cmd === "login" ? await login(flags, withAuth) : await whoami(io.client, withAuth);
596
+ }
597
+ case "logout":
598
+ return await logout(io);
599
+ case "new": {
600
+ if (!id)
601
+ return usage("sentinel new needs a contract id (SP….name) or a GitHub link");
602
+ const p = await io.client.previewPlan(id);
603
+ io.out(`${p.contractId} ${p.kind === "prelaunch" ? "pre-launch code" : "deployed"}`);
604
+ if (p.planId)
605
+ io.out(`A plan already exists: ${p.planId}`);
606
+ const b = await io.client.billing();
607
+ io.out(b.prices ? `A new plan costs ${usd(b.prices.run)}, charged only if it finishes.` : "Billing isn't linked: the free daily plan limit applies.");
608
+ if (!flags.includes("--yes")) {
609
+ io.out(`Run again with --yes to start: sentinel new ${id} --yes`);
610
+ return 0;
611
+ }
612
+ const r = await io.client.createPlan(id);
613
+ io.out(`${r.cached ? "Already planned" : "Started"}: ${r.planId}`);
614
+ io.out(`Follow it with: sentinel runs ${r.planId}`);
615
+ return 0;
616
+ }
617
+ case "balance": {
618
+ const b = await io.client.billing();
619
+ if (json)
620
+ io.out(JSON.stringify(b, null, 2));
621
+ else if (b.unavailable)
622
+ io.out("Billing is unavailable right now. Try again.");
623
+ else if (!b.linked || b.balanceUsd === null || !b.prices)
624
+ io.out("Billing isn't linked. Open runsentinel.app to set it up.");
625
+ else {
626
+ io.out(`Balance: ${usd(b.balanceUsd)}`);
627
+ io.out(`Prices: plan ${usd(b.prices.run)}, deep run ${usd(b.prices.deepAudit)}, monitored event ${usd(b.prices.monitoredEvent)}`);
628
+ }
629
+ return 0;
630
+ }
631
+ case "alerts": {
632
+ const alerts = await io.client.alerts(values["--contract"]);
633
+ if (json)
634
+ io.out(JSON.stringify(alerts, null, 2));
635
+ else
636
+ for (const l of table([
637
+ ["ID", "WHEN", "LEVEL", "CONTRACT", "SUMMARY"],
638
+ ...alerts.map((a) => [a.id, a.createdAt, a.level, a.contractId, a.summary])
639
+ ]))
640
+ io.out(l);
641
+ return 0;
642
+ }
643
+ case "reproduce":
644
+ case "replay":
645
+ case "challenge":
646
+ case "directed":
647
+ case "tests":
648
+ return await ownerRun(cmd, args.slice(1), flags, io, usage);
649
+ case "watches":
650
+ case "rule":
651
+ case "mute":
652
+ case "unmute":
653
+ case "mute-alert":
654
+ case "destinations":
655
+ case "test-alert":
656
+ case "follow":
657
+ case "unfollow":
658
+ return await monitoring(cmd, args.slice(1), false, flags, values, io, usage);
659
+ default:
660
+ return usage(`Unknown command ${cmd}`);
661
+ }
662
+ } catch (e) {
663
+ io.err(e instanceof SentinelError || e instanceof SubjectError ? e.message : `Unexpected error: ${e.message}`);
664
+ return 1;
665
+ }
666
+ }
667
+ var usd = (n) => `$${n.toFixed(2)}`;
668
+ var RUN_NOTE = "A paid run from your balance, charged only if it finishes.";
669
+ async function ownerRun(cmd, pos, flags, io, usage) {
670
+ const [planId, second, third] = pos;
671
+ if (!planId)
672
+ return usage(`sentinel ${cmd} needs a plan id`);
673
+ let finding = 0;
674
+ if (cmd === "reproduce" || cmd === "replay" || cmd === "challenge") {
675
+ if (second === undefined || !/^\d+$/.test(second))
676
+ return usage(`sentinel ${cmd} needs a plan id and a finding number`);
677
+ finding = Number(second);
678
+ }
679
+ if (cmd === "challenge" && !third)
680
+ return usage("sentinel challenge needs a reason, in quotes");
681
+ if (cmd === "directed" && !second)
682
+ return usage("sentinel directed needs what to watch, in quotes");
683
+ if (cmd === "tests" && !flags.includes("--from-repo"))
684
+ return usage("sentinel tests needs --from-repo (a pre-launch plan's repo tests)");
685
+ const willDo = {
686
+ reproduce: `Write and run a PoC for finding ${finding} in the airgapped sandbox (never touches mainnet).`,
687
+ replay: `Re-run finding ${finding}'s stored PoC in the airgapped sandbox.`,
688
+ challenge: `Re-verify finding ${finding} against your argument.`,
689
+ directed: "Re-plan this contract around what you asked to have watched.",
690
+ tests: "Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox."
691
+ }[cmd];
692
+ const flagTail = cmd === "tests" ? " --from-repo" : "";
693
+ const quoted = (v) => v ? ` ${JSON.stringify(v)}` : "";
694
+ const argTail = {
695
+ reproduce: ` ${finding}`,
696
+ replay: ` ${finding}`,
697
+ challenge: ` ${finding}${quoted(third)}`,
698
+ directed: quoted(second),
699
+ tests: ""
700
+ }[cmd];
701
+ if (!flags.includes("--yes")) {
702
+ io.out(willDo);
703
+ io.out(RUN_NOTE);
704
+ io.out(`Run again with --yes to start: sentinel ${cmd} ${planId}${argTail}${flagTail} --yes`);
705
+ return 0;
706
+ }
707
+ const r = await {
708
+ reproduce: () => io.client.reproduce(planId, finding),
709
+ replay: () => io.client.replay(planId, finding),
710
+ challenge: () => io.client.challenge(planId, finding, third),
711
+ directed: () => io.client.directed(planId, second),
712
+ tests: () => io.client.testsFromRepo(planId)
713
+ }[cmd]();
714
+ io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}`);
715
+ return 0;
716
+ }
717
+ var DURATIONS = { "1h": 1, "24h": 24, "7d": 168 };
718
+ function previewOnly(io, flags, willDo, effect) {
719
+ if (flags.includes("--yes"))
720
+ return false;
721
+ if (willDo)
722
+ io.out(willDo);
723
+ io.out(`${effect} Run again with --yes to do it.`.trimStart());
724
+ return true;
725
+ }
726
+ async function monitoring(cmd, pos, on, flags, values, io, usage) {
727
+ const [first, second, third] = pos;
728
+ const need = (what) => usage(`sentinel ${cmd} needs ${what}`);
729
+ if (cmd === "mute-alert") {
730
+ if (!first)
731
+ return need("an alert id");
732
+ const dur = second ?? "24h";
733
+ if (!(dur in DURATIONS))
734
+ return usage("Mute for 1h, 24h or 7d");
735
+ const hours = DURATIONS[dur];
736
+ if (previewOnly(io, flags, `Mute the watch behind alert ${first} for ${dur}.`, "Takes effect now."))
737
+ return 0;
738
+ const r = await io.client.muteAlert(first, hours);
739
+ io.out(`Muted ${r.watchTitle} until ${r.mutedUntil}`);
740
+ return 0;
741
+ }
742
+ if (cmd === "follow" || cmd === "unfollow") {
743
+ if (!first)
744
+ return need("a contract id");
745
+ const follow = cmd === "follow";
746
+ const willDo = follow ? `Start monitoring ${first}.` : `Stop monitoring ${first}; your watches, alerts and settings for it are removed.`;
747
+ if (previewOnly(io, flags, willDo, follow ? "Nothing is on until you turn watches on." : ""))
748
+ return 0;
749
+ if (!follow) {
750
+ await io.client.stopMonitoring(await planOfContract(io.client, first));
751
+ io.out(`Stopped monitoring ${first}.`);
752
+ return 0;
753
+ }
754
+ const r = await io.client.watchContract(first);
755
+ io.out("watches" in r ? `Monitoring ${r.contractId}. Manage it with plan ${r.planId}.` : `Sentinel is still making the plan (${r.planId}). Run this again in a few minutes.`);
756
+ return 0;
757
+ }
758
+ if (!first)
759
+ return need("a plan id");
760
+ const planId = first;
761
+ if (cmd === "test-alert") {
762
+ if (previewOnly(io, flags, "Send one sample alert to every place this plan's alerts go.", "Delivers now."))
763
+ return 0;
764
+ for (const r of (await io.client.testAlert(planId)).results) {
765
+ io.out(`${r.to}: ${r.ok ? "ok" : "failed"} ${r.detail}`.trimEnd());
766
+ }
767
+ return 0;
768
+ }
769
+ const ctx = await watchContext(io.client, planId);
770
+ if (cmd === "watches") {
771
+ if (flags.includes("--json")) {
772
+ io.out(JSON.stringify({ watches: ctx.watches }, null, 2));
773
+ return 0;
774
+ }
775
+ if (ctx.access === "claimed") {
776
+ io.out("Exploit detail is hidden until you verify control in the web app.");
777
+ }
778
+ for (const g of groupSubjects(ctx.watches)) {
779
+ const muted = g.watches.map((w) => mutedUntilOf(w)).find(Boolean);
780
+ io.out([
781
+ g.name,
782
+ [...new Set(g.watches.map((w) => kindTag(w.rule)))].join("+"),
783
+ subjectStatus(g.watches),
784
+ muted ? `muted until ${muted}` : ""
785
+ ].filter(Boolean).join(" "));
786
+ for (const w of g.watches)
787
+ io.out(` ${w.status.padEnd(14)}${ruleSummary(w.rule, w.units)}`);
788
+ }
789
+ return 0;
790
+ }
791
+ if (cmd === "destinations") {
792
+ const next = await savedMonitor(io.client, planId);
793
+ const email = values["--email"];
794
+ if (email !== undefined && email !== "on" && email !== "off") {
795
+ return usage("--email takes on or off");
796
+ }
797
+ const asked = email !== undefined || "--webhook" in values || "--slack" in values;
798
+ const dest = {
799
+ emailAlerts: email === undefined ? next.emailAlerts : email === "on",
800
+ webhookUrl: values["--webhook"] ?? next.webhookUrl ?? "",
801
+ slackUrl: values["--slack"] ?? next.slackUrl ?? ""
802
+ };
803
+ const places = `${[dest.emailAlerts ? "email" : null, dest.webhookUrl ? "webhook" : null, dest.slackUrl ? "Slack" : null].filter(Boolean).join(", ") || "nowhere"}`;
804
+ if (!asked) {
805
+ io.out(`Alerts go to: ${places}`);
806
+ return 0;
807
+ }
808
+ if (previewOnly(io, flags, `Alerts would go to: ${places}.`, "Takes effect now."))
809
+ return 0;
810
+ const r = await io.client.monitorSettings(planId, {
811
+ watches: await savedChoices(io.client, planId) ?? undefined,
812
+ ...dest
813
+ });
814
+ io.out(`Alerts go to: ${places}`);
815
+ if (r.signingSecret)
816
+ io.out(`Webhook signing secret (shown once): ${r.signingSecret}`);
817
+ return 0;
818
+ }
819
+ if (!second)
820
+ return need("a subject (a function name or a token symbol)");
821
+ const g = resolveSubject(ctx.watches, second);
822
+ if (cmd === "rule") {
823
+ if (!third)
824
+ return need("a sentence describing the rule");
825
+ const parsed = await io.client.parseRule(planId, g.watches[0].subject, third);
826
+ for (const e of parsed.elsewhere)
827
+ io.out(`Belongs on another subject (${e.subjectKey}): ${e.phrase}`);
828
+ if (parsed.rules.length === 0) {
829
+ io.err(`Couldn't read a rule for ${g.name} from that.`);
830
+ return 1;
831
+ }
832
+ const plan = parsed.rules.map((rule) => ({
833
+ rule,
834
+ existing: g.watches.find((w) => w.rule.kind === rule.kind)
835
+ }));
836
+ for (const p of plan) {
837
+ io.out(`${p.existing ? "Set" : "Add"} on ${g.name}: ${ruleSummary(p.rule, g.watches[0].units)}`);
838
+ }
839
+ const effect = "Applied.";
840
+ if (previewOnly(io, flags, "", effect))
841
+ return 0;
842
+ for (const p of plan) {
843
+ if (p.existing)
844
+ await io.client.setRule(planId, p.existing.id, p.rule);
845
+ else
846
+ await io.client.addRule(planId, g.watches[0].subject, p.rule);
847
+ }
848
+ io.out(effect);
849
+ return 0;
850
+ }
851
+ if (cmd === "watch") {
852
+ const willDo = `Turn ${g.name} ${on ? "on" : "off"} (${g.watches.length} rule(s)).`;
853
+ const effect = on ? "Live now." : "Off now.";
854
+ if (previewOnly(io, flags, willDo, effect))
855
+ return 0;
856
+ const ids = g.watches.map((w) => w.id);
857
+ await io.client.setWatches(planId, ids, on);
858
+ io.out(`${willDo} ${effect}`);
859
+ return 0;
860
+ }
861
+ const mute = cmd === "mute";
862
+ const dur = third ?? "24h";
863
+ if (mute && !(dur in DURATIONS))
864
+ return usage("Mute for 1h, 24h or 7d");
865
+ const targets = g.watches.filter((w) => isMutableRule(w.rule));
866
+ if (targets.length === 0) {
867
+ io.err(`${g.name}'s watches come from a verified finding or governance review and can't be muted.`);
868
+ return 1;
869
+ }
870
+ const willDo = mute ? `Mute ${g.name} for ${dur}.` : `Unmute ${g.name}.`;
871
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
872
+ return 0;
873
+ const mutedUntil = mute ? muteEnd(DURATIONS[dur]) : null;
874
+ for (const w of targets)
875
+ await io.client.mute(planId, w.id, mutedUntil);
876
+ io.out(mute ? `Muted ${g.name} until ${mutedUntil}` : `Unmuted ${g.name}.`);
877
+ return 0;
878
+ }
879
+
880
+ // cli/main.ts
881
+ function takeToken(argv) {
882
+ const i = argv.indexOf("--token");
883
+ if (i === -1)
884
+ return { argv };
885
+ return { argv: argv.filter((_, j) => j !== i && j !== i + 1), token: argv[i + 1] };
886
+ }
887
+ async function readStdin() {
888
+ const chunks = [];
889
+ for await (const chunk of process.stdin)
890
+ chunks.push(chunk);
891
+ return Buffer.concat(chunks).toString("utf8");
892
+ }
893
+ async function prompt(question) {
894
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
895
+ try {
896
+ return await rl.question(question);
897
+ } finally {
898
+ rl.close();
899
+ }
900
+ }
901
+ async function main() {
902
+ const { argv, token: flag } = takeToken(process.argv.slice(2));
903
+ const cmd = argv.find((a) => !a.startsWith("--"));
904
+ const help = argv.length === 0 || cmd === "help" || argv.includes("--help");
905
+ const credential = await resolveToken(flag);
906
+ if (!credential && !help && cmd !== "login" && cmd !== "logout") {
907
+ console.error(NOT_LOGGED_IN);
908
+ return 2;
909
+ }
910
+ const baseUrl = process.env.SENTINEL_API_URL;
911
+ const clientFor = (token) => createClient({ token, baseUrl });
912
+ return run(argv, {
913
+ client: clientFor(credential?.token ?? ""),
914
+ out: console.log,
915
+ err: console.error,
916
+ auth: { source: credential?.source ?? null, clientFor, prompt, readStdin }
917
+ });
918
+ }
919
+ process.exit(await main());