@secondlayer/sentinel 0.1.0 → 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/README.md +1 -1
- package/dist/cli.js +458 -48
- package/dist/index.js +23 -3
- package/dist/kind.d.ts +42 -0
- package/dist/mcp.js +358 -30
- package/dist/sentinel-client.d.ts +86 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Anything that spends balance or changes monitoring shows what it would do first
|
|
|
31
31
|
claude mcp add sentinel -- npx -y -p @secondlayer/sentinel sentinel-mcp
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Uses your login, or add `--env SENTINEL_API_KEY=sk-
|
|
34
|
+
Uses your login, or add `--env SENTINEL_API_KEY=sk-snt_…` for a scoped key. A secondlayer API key won't work here; Sentinel keys start `sk-snt_`. Every write tool takes `confirm`; without it you get what would happen and nothing changes.
|
|
35
35
|
|
|
36
36
|
## SDK
|
|
37
37
|
|
package/dist/cli.js
CHANGED
|
@@ -85,10 +85,14 @@ class SentinelError extends Error {
|
|
|
85
85
|
this.name = "SentinelError";
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
var
|
|
88
|
+
var rowsOf = (intents) => intents.flatMap((i) => i.rows);
|
|
89
|
+
var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : s.kind === "group" ? `group:${s.group}` : `asset:${s.asset}`;
|
|
90
|
+
var CONTROL_NAME = "Control changes";
|
|
89
91
|
function subjectName(s, units) {
|
|
90
92
|
if (s.kind === "fn")
|
|
91
93
|
return s.fn;
|
|
94
|
+
if (s.kind === "group")
|
|
95
|
+
return CONTROL_NAME;
|
|
92
96
|
if (units?.symbol)
|
|
93
97
|
return units.symbol;
|
|
94
98
|
if (s.asset.toLowerCase() === "stx")
|
|
@@ -120,7 +124,7 @@ function resolveSubject(watches, name) {
|
|
|
120
124
|
const hits = groups.filter((g) => {
|
|
121
125
|
const s = g.watches[0].subject;
|
|
122
126
|
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;
|
|
127
|
+
return g.name.toLowerCase() === q || g.key.toLowerCase() === q || s.kind === "group" && q === s.group || asset === q || asset !== null && asset.split("::")[1] === q;
|
|
124
128
|
});
|
|
125
129
|
if (hits.length === 1)
|
|
126
130
|
return hits[0];
|
|
@@ -172,26 +176,16 @@ function ruleSummary(rule, units) {
|
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
case "callerAllowlist":
|
|
184
|
-
case "actor":
|
|
185
|
-
return "who-acts";
|
|
186
|
-
case "audit":
|
|
187
|
-
return rule.triggerClass === "governance.proposal_submitted" ? "governance" : "upgrade";
|
|
179
|
+
function intentLine(i) {
|
|
180
|
+
const t = i.threshold;
|
|
181
|
+
if (i.kind !== "money_out" || !t)
|
|
182
|
+
return i.summary;
|
|
183
|
+
const units = i.units ?? null;
|
|
184
|
+
if (t.amount === null) {
|
|
185
|
+
const sug = t.suggested ? ` (suggested ${amountText(t.suggested.amount, units)}, ${t.suggested.basis})` : "";
|
|
186
|
+
return `no threshold yet: every outflow alerts${sug}`;
|
|
188
187
|
}
|
|
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";
|
|
188
|
+
return `${amountText(t.amount, units)} or more in one tx · ${t.basis}`;
|
|
195
189
|
}
|
|
196
190
|
var isMutableRule = (rule) => rule.kind !== "signature" && rule.kind !== "audit";
|
|
197
191
|
function mutedUntilOf(w, now = Date.now()) {
|
|
@@ -256,11 +250,13 @@ var HOUR_MS = 3600000;
|
|
|
256
250
|
var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
|
|
257
251
|
async function watchContext(client, planId) {
|
|
258
252
|
const plan = await client.plan(planId);
|
|
253
|
+
const { intents } = await client.watches(planId);
|
|
259
254
|
return {
|
|
260
255
|
planId,
|
|
261
256
|
contractId: plan.contractId,
|
|
262
257
|
access: plan.access,
|
|
263
|
-
|
|
258
|
+
intents,
|
|
259
|
+
watches: rowsOf(intents)
|
|
264
260
|
};
|
|
265
261
|
}
|
|
266
262
|
async function savedMonitor(client, planId) {
|
|
@@ -279,6 +275,16 @@ async function planOfContract(client, contractId) {
|
|
|
279
275
|
}
|
|
280
276
|
|
|
281
277
|
// cli/auth.ts
|
|
278
|
+
var NEEDS_TTY = 'sentinel login needs an interactive terminal. In CI: echo "$KEY" | sentinel login --with-token';
|
|
279
|
+
var API_KEY_LOGIN = "Your secondlayer login is an API key Sentinel can't use. Run: sentinel login --force";
|
|
280
|
+
|
|
281
|
+
class PromptClosedError extends Error {
|
|
282
|
+
constructor() {
|
|
283
|
+
super(NEEDS_TTY);
|
|
284
|
+
this.name = "PromptClosedError";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
var isOtherProductKey = (message) => message.startsWith("This is a secondlayer API key");
|
|
282
288
|
var sessionExpiry = () => new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
283
289
|
async function post(doFetch, path, body) {
|
|
284
290
|
try {
|
|
@@ -324,9 +330,39 @@ async function login(flags, io) {
|
|
|
324
330
|
}
|
|
325
331
|
const existing = await readSession();
|
|
326
332
|
if (existing && !flags.includes("--force")) {
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
const r = await check(auth.clientFor(existing.token));
|
|
334
|
+
if (!("error" in r)) {
|
|
335
|
+
io.out(`Already logged in as ${r.email ?? existing.email}. Run again with --force to switch accounts.`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
if (r.status === 401 && (existing.token.startsWith("sk-sl_") || isOtherProductKey(r.error))) {
|
|
339
|
+
io.err(API_KEY_LOGIN);
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
if (r.status === 403) {
|
|
343
|
+
io.err(r.error);
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
if (r.status !== 401) {
|
|
347
|
+
io.err(r.error);
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!auth.interactive) {
|
|
352
|
+
io.err(NEEDS_TTY);
|
|
353
|
+
return 2;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return await promptLogin(io);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (!(e instanceof PromptClosedError))
|
|
359
|
+
throw e;
|
|
360
|
+
io.err(NEEDS_TTY);
|
|
361
|
+
return 2;
|
|
329
362
|
}
|
|
363
|
+
}
|
|
364
|
+
async function promptLogin(io) {
|
|
365
|
+
const { auth } = io;
|
|
330
366
|
const doFetch = auth.fetch ?? globalThis.fetch;
|
|
331
367
|
const email = (await auth.prompt("Email: ")).trim();
|
|
332
368
|
if (!/^.+@.+\..+$/.test(email)) {
|
|
@@ -383,6 +419,330 @@ async function whoami(client, io) {
|
|
|
383
419
|
return 0;
|
|
384
420
|
}
|
|
385
421
|
|
|
422
|
+
// client/kind.ts
|
|
423
|
+
var isGreen = (f) => f.poc === "green";
|
|
424
|
+
function findingKind(f) {
|
|
425
|
+
if (f.disposition === "refuted" || f.verdict === "refuted")
|
|
426
|
+
return "refuted";
|
|
427
|
+
if (f.class === "centralization" || f.disposition === "waived")
|
|
428
|
+
return "trust";
|
|
429
|
+
if (f.class === "bug" && f.verdict === "confirmed" && f.reverified === true && isGreen(f)) {
|
|
430
|
+
return "exploitable";
|
|
431
|
+
}
|
|
432
|
+
return "worth_a_look";
|
|
433
|
+
}
|
|
434
|
+
var URGENT_CONSEQUENCES = new Set(["authority", "code", "value"]);
|
|
435
|
+
var KIND_LABEL = {
|
|
436
|
+
exploitable: "Exploitable",
|
|
437
|
+
trust: "Trust assumption",
|
|
438
|
+
worth_a_look: "Worth a look",
|
|
439
|
+
refuted: "refuted"
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// client/labels.ts
|
|
443
|
+
var isPrelaunchKey = (id) => id.startsWith("github:") || id.startsWith("gist:");
|
|
444
|
+
function contractName(id) {
|
|
445
|
+
if (isPrelaunchKey(id))
|
|
446
|
+
return id.split(":").pop()?.split("/").pop()?.replace(/\.clar$/, "") ?? id;
|
|
447
|
+
return id.split(".")[1] ?? id;
|
|
448
|
+
}
|
|
449
|
+
var MONITOR_LABELS = {
|
|
450
|
+
draft: "not on yet",
|
|
451
|
+
requested: "setting up",
|
|
452
|
+
live: "watching",
|
|
453
|
+
armed: "armed for launch",
|
|
454
|
+
stale: "deploy didn't match"
|
|
455
|
+
};
|
|
456
|
+
var monitoringLabel = (status) => status ? MONITOR_LABELS[status] ?? status : "not on yet";
|
|
457
|
+
|
|
458
|
+
// client/handoff.ts
|
|
459
|
+
var DEFAULT_FILE = "sentinel-poc.ts";
|
|
460
|
+
var pocFileName = (f) => f.proof?.file ?? DEFAULT_FILE;
|
|
461
|
+
function runCommand(opts) {
|
|
462
|
+
const lines = [`# save the PoC above as ${opts.file}, then in your own checkout:`];
|
|
463
|
+
if (opts.commit)
|
|
464
|
+
lines.push(`git checkout ${opts.commit} # the commit Sentinel reviewed`);
|
|
465
|
+
const where = opts.substrate === "fork" ? "needs a Stacks RPC" : "airgapped, no network";
|
|
466
|
+
lines.push(`bun run ${opts.file} # @stacks/clarinet-sdk — ${where}`);
|
|
467
|
+
return lines.join(`
|
|
468
|
+
`);
|
|
469
|
+
}
|
|
470
|
+
function handoffBundle(f, ctx) {
|
|
471
|
+
const proof = f.proof;
|
|
472
|
+
const file = pocFileName(f);
|
|
473
|
+
const poc = proof?.script ? {
|
|
474
|
+
file,
|
|
475
|
+
substrate: proof.substrate ?? null,
|
|
476
|
+
reproduced: !!proof.reproduced,
|
|
477
|
+
exitCode: proof.exitCode ?? null,
|
|
478
|
+
source: proof.script
|
|
479
|
+
} : null;
|
|
480
|
+
return {
|
|
481
|
+
contract: ctx.contract,
|
|
482
|
+
fn: f.targetFn ?? null,
|
|
483
|
+
commit: ctx.commit ?? null,
|
|
484
|
+
poc,
|
|
485
|
+
runCommand: poc ? runCommand({ commit: ctx.commit, file, substrate: poc.substrate }) : null
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function assertionsOf(output) {
|
|
489
|
+
const ratio = output.match(/(\d+)\s*\/\s*(\d+)\s*assertions?/i);
|
|
490
|
+
if (ratio)
|
|
491
|
+
return `${ratio[1]}/${ratio[2]}`;
|
|
492
|
+
const passed = output.match(/(\d+)\s*assertions?\s*passed/i);
|
|
493
|
+
return passed ? `${passed[1]} passed` : null;
|
|
494
|
+
}
|
|
495
|
+
var FENCE = "```";
|
|
496
|
+
var FOOTER = "Sentinel's findings are a starting point for your own review, not an audit sign-off.";
|
|
497
|
+
var KIND_LINE = {
|
|
498
|
+
exploitable: "Exploitable (verified, re-verified, reproduced in the sandbox)",
|
|
499
|
+
trust: "Trust assumption (watched: Sentinel alerts when it's used)",
|
|
500
|
+
worth_a_look: "Worth a look (not proven exploitable)",
|
|
501
|
+
refuted: "Refuted"
|
|
502
|
+
};
|
|
503
|
+
var tick = (s) => `\`${s}\``;
|
|
504
|
+
var cell = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
505
|
+
var num = (n) => n.toLocaleString("en-US");
|
|
506
|
+
function printed(output) {
|
|
507
|
+
const lines = output.replace(/\[SENTINEL-POC-TRACE\][\s\S]*?\[\/SENTINEL-POC-TRACE\]/g, "").split(`
|
|
508
|
+
`).filter((l) => l.trim());
|
|
509
|
+
return lines.slice(-40).join(`
|
|
510
|
+
`);
|
|
511
|
+
}
|
|
512
|
+
function movedMd(trace) {
|
|
513
|
+
const keys = Object.keys(trace.series ?? {}).filter((k) => trace.steps.some((s) => s.values?.[k] !== undefined));
|
|
514
|
+
const name = (k) => trace.series?.[k] ?? k;
|
|
515
|
+
const unit = trace.unit ? ` ${trace.unit}` : "";
|
|
516
|
+
const changes = (i) => {
|
|
517
|
+
const prev = trace.steps[i - 1]?.values;
|
|
518
|
+
const now = trace.steps[i].values;
|
|
519
|
+
if (!prev || !now)
|
|
520
|
+
return [];
|
|
521
|
+
return keys.filter((k) => now[k] !== undefined && prev[k] !== undefined && now[k] !== prev[k]).map((k) => `${name(k)} ${num(prev[k])} → ${num(now[k])}${unit}`);
|
|
522
|
+
};
|
|
523
|
+
const first = trace.steps.findIndex((_, i) => changes(i).length > 0);
|
|
524
|
+
const rows = trace.steps.map((s, i) => {
|
|
525
|
+
const call = [s.call && tick(s.call), s.result && `→ ${tick(s.result)}`].filter(Boolean).join(" ");
|
|
526
|
+
const start = keys.filter((k) => s.values?.[k] !== undefined).map((k) => `${name(k)} ${num(s.values?.[k])}${unit}`).join(" · ");
|
|
527
|
+
const change = i === 0 ? start || "start" : changes(i).join(" · ") || "no change";
|
|
528
|
+
return `| ${i + 1} ${cell(s.label)} | ${cell(call || "—")} | ${cell(change)} |`;
|
|
529
|
+
});
|
|
530
|
+
return [
|
|
531
|
+
"## What the PoC moved",
|
|
532
|
+
first > 0 ? `**${changes(first).join("; ")} at step ${first + 1} (${trace.steps[first].label}).**` : "No tracked value changed across the steps.",
|
|
533
|
+
"",
|
|
534
|
+
"| Step | Call | Value change |",
|
|
535
|
+
"|---|---|---|",
|
|
536
|
+
...rows,
|
|
537
|
+
""
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
function variantOf(f) {
|
|
541
|
+
if (f.proof?.reproduced)
|
|
542
|
+
return "reproduced";
|
|
543
|
+
if (f.proof && !f.proof.unavailable && !f.proof.errored && f.proof.script)
|
|
544
|
+
return "notrepro";
|
|
545
|
+
if (f.verdict !== "confirmed")
|
|
546
|
+
return "lead";
|
|
547
|
+
return "analysis";
|
|
548
|
+
}
|
|
549
|
+
function where(f, ctx) {
|
|
550
|
+
const at = ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : "";
|
|
551
|
+
return f.targetFn ? `Open ${tick(f.targetFn)} in ${tick(contractName(ctx.contract))}${at} and show the function with ~10 lines of context. Point at the lines this finding is about and explain in your own words what they do.` : `Find the code this finding describes in ${tick(contractName(ctx.contract))}${at} and show it with ~10 lines of context. Explain in your own words what it does.`;
|
|
552
|
+
}
|
|
553
|
+
function contextMd(f, ctx, kind) {
|
|
554
|
+
return [
|
|
555
|
+
"## Context",
|
|
556
|
+
`- Contract: ${tick(ctx.contract)}`,
|
|
557
|
+
...ctx.commit ? [`- Commit reviewed: ${tick(ctx.commit.slice(0, 7))}`] : [],
|
|
558
|
+
...f.targetFn ? [`- Function: ${tick(f.targetFn)}`] : [],
|
|
559
|
+
`- Kind: ${KIND_LINE[kind]}`
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
function reportBackMd(ctx, extra) {
|
|
563
|
+
return [
|
|
564
|
+
"## How to report back",
|
|
565
|
+
`- Cite every claim with ${tick("file:line")}${ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : ""}.`,
|
|
566
|
+
"- Show the code you discuss, quoted from the repo, not paraphrased.",
|
|
567
|
+
extra,
|
|
568
|
+
"- Keep two sections: **Facts** (what the code and the run show) and **Assessment** (your conclusions, each with a confidence level). Don't mix them.",
|
|
569
|
+
"- Don't change contract code until the team has chosen an approach.",
|
|
570
|
+
""
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
function tailMd(ctx, withPoc) {
|
|
574
|
+
return [
|
|
575
|
+
"",
|
|
576
|
+
"## Pull it again",
|
|
577
|
+
`${FENCE}shell`,
|
|
578
|
+
`npx @secondlayer/sentinel finding ${ctx.planId} ${ctx.index}${withPoc ? " --poc --run" : ""}`,
|
|
579
|
+
FENCE,
|
|
580
|
+
`MCP: ${tick(`get_finding { planId: "${ctx.planId}", index: ${ctx.index} }`)} returns this finding as JSON${withPoc ? ", the PoC source and the run command" : ""}.`,
|
|
581
|
+
"",
|
|
582
|
+
"---",
|
|
583
|
+
FOOTER
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
function issueMd(f, withCondition) {
|
|
587
|
+
return [
|
|
588
|
+
"## The issue",
|
|
589
|
+
f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
|
|
590
|
+
...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
|
|
591
|
+
...withCondition && f.precondition ? ["", `**Exploitable when:** ${f.precondition}`] : [],
|
|
592
|
+
""
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
function prelaunchBrief(f, ctx, kind) {
|
|
596
|
+
const kindLine = kind === "exploitable" ? "Exploitable · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
|
|
597
|
+
return [
|
|
598
|
+
"# Task: review a Sentinel finding on pre-launch code",
|
|
599
|
+
"",
|
|
600
|
+
`Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. PoCs and exploit detail open once Sentinel confirms this code isn't already live on mainnet; possible approaches are shown now, so this brief carries the finding and a direction, not the PoC. Look at the code before launch: the watch arms at launch, but it isn't a substitute for the review.`,
|
|
601
|
+
"",
|
|
602
|
+
"## Start here",
|
|
603
|
+
`1. ${where(f, ctx)}`,
|
|
604
|
+
"2. Only then discuss approaches that fit this codebase's design.",
|
|
605
|
+
"",
|
|
606
|
+
"## How to report back",
|
|
607
|
+
`- Cite every claim with ${tick("file:line")}${ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : ""}.`,
|
|
608
|
+
"- Keep **Facts** and **Assessment** apart, with a confidence level on each conclusion.",
|
|
609
|
+
"",
|
|
610
|
+
"## Context",
|
|
611
|
+
`- Contract: ${tick(contractName(ctx.contract))} (pre-launch)${ctx.commit ? `, commit ${tick(ctx.commit.slice(0, 7))}` : ""}`,
|
|
612
|
+
`- ${f.targetFn ? `Function: ${tick(f.targetFn)} · ` : ""}Kind: ${kindLine}`,
|
|
613
|
+
"",
|
|
614
|
+
...issueMd(f, false),
|
|
615
|
+
...f.recommendedAction ? ["## One direction to consider (not a verified patch)", f.recommendedAction, ""] : [],
|
|
616
|
+
"---",
|
|
617
|
+
FOOTER
|
|
618
|
+
].join(`
|
|
619
|
+
`);
|
|
620
|
+
}
|
|
621
|
+
function trustBrief(f, ctx) {
|
|
622
|
+
return [
|
|
623
|
+
"# Task: review a trust assumption and decide whether to reduce it",
|
|
624
|
+
"",
|
|
625
|
+
`Sentinel found a power held by design${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. It's a trust assumption, not a bug, so there is nothing to reproduce. Review it and decide whether the trust it asks of your users fits your design.`,
|
|
626
|
+
"",
|
|
627
|
+
"## Start here",
|
|
628
|
+
`1. ${where(f, ctx)}`,
|
|
629
|
+
`2. Find every place that reads what ${f.targetFn ? tick(f.targetFn) : "this power"} changes, and show each one, so the reach of this power is clear.`,
|
|
630
|
+
"3. Only then discuss options that fit this codebase's design.",
|
|
631
|
+
"",
|
|
632
|
+
...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write an exploit for it."),
|
|
633
|
+
...contextMd(f, ctx, "trust"),
|
|
634
|
+
"- Class: trust assumption (a power held by design)",
|
|
635
|
+
"- Evidence: checked by Sentinel's adversarial verifier. No PoC; nothing to run.",
|
|
636
|
+
"",
|
|
637
|
+
"## What it is",
|
|
638
|
+
f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
|
|
639
|
+
...f.powers?.length ? [
|
|
640
|
+
"",
|
|
641
|
+
"**Powers:**",
|
|
642
|
+
...f.powers.map((p) => `- ${p.holder} can ${p.can}${p.fn ? ` (${tick(p.fn)})` : ""}`)
|
|
643
|
+
] : [],
|
|
644
|
+
...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
|
|
645
|
+
...f.precondition ? ["", `**Usable when:** ${f.precondition}`] : [],
|
|
646
|
+
"",
|
|
647
|
+
...f.recommendedAction ? ["## Options to consider (not a patch)", `- ${f.recommendedAction}`, ""] : [],
|
|
648
|
+
"## If you change it",
|
|
649
|
+
"- A test for the new behavior.",
|
|
650
|
+
"- A test that an account without the role is still refused.",
|
|
651
|
+
...tailMd(ctx, false)
|
|
652
|
+
].join(`
|
|
653
|
+
`);
|
|
654
|
+
}
|
|
655
|
+
function refutedBrief(f, ctx) {
|
|
656
|
+
return [
|
|
657
|
+
"# Note: a Sentinel finding refuted on re-check",
|
|
658
|
+
"",
|
|
659
|
+
`Sentinel's verifier re-examined this${f.targetFn ? ` (${tick(f.targetFn)})` : ""} and showed it can't happen: ${f.title}. It's kept for the record and isn't watched.`,
|
|
660
|
+
"",
|
|
661
|
+
...contextMd(f, ctx, "refuted"),
|
|
662
|
+
...tailMd(ctx, false)
|
|
663
|
+
].join(`
|
|
664
|
+
`);
|
|
665
|
+
}
|
|
666
|
+
function bugBrief(f, ctx, kind) {
|
|
667
|
+
const v = variantOf(f);
|
|
668
|
+
const bundle = handoffBundle(f, { contract: ctx.contract, commit: ctx.commit });
|
|
669
|
+
const poc = bundle.poc;
|
|
670
|
+
const p = f.proof;
|
|
671
|
+
const asserted = p?.output ? assertionsOf(p.output) : null;
|
|
672
|
+
const counted = asserted?.includes("/") ? `${asserted} assertions, ` : asserted ? `${asserted.replace(" passed", "")} assertions passed, ` : "";
|
|
673
|
+
const ran = `${counted}exit ${p?.exitCode ?? "?"}`;
|
|
674
|
+
const sandbox = p?.substrate === "fork" ? "a mainnet-fork sandbox" : `an airgapped sandbox (${tick("--network none")})`;
|
|
675
|
+
const what = `Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}`;
|
|
676
|
+
const open = {
|
|
677
|
+
reproduced: `${what}. It reproduced with the PoC below. Review the affected code, choose an approach that fits your design, then confirm the PoC no longer succeeds.`,
|
|
678
|
+
notrepro: `${what}. Its verifier confirmed it by analysis, but the PoC below ran and did not reproduce it. Review the affected code and the run, and decide whether it applies to your deployment.`,
|
|
679
|
+
analysis: `${what}. Its verifier confirmed it by analysis; no PoC result yet. ${poc ? "Run the PoC below in your own tooling, then review" : "Review"} the affected code and choose an approach that fits your design.`,
|
|
680
|
+
lead: `An auditor flagged an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. The verifier hasn't confirmed it, so treat it as a lead.`
|
|
681
|
+
}[v];
|
|
682
|
+
const evidence = {
|
|
683
|
+
reproduced: `confirmed by Sentinel's adversarial verifier${f.reverified ? ", re-verified by a second independent pass" : ""}, then reproduced in ${sandbox}: ${ran}.${f.reverified ? "" : " Not re-verified yet."}`,
|
|
684
|
+
notrepro: `confirmed by Sentinel's adversarial verifier. The PoC ran in ${sandbox} and did not reproduce it (${ran}). Verified by analysis only.`,
|
|
685
|
+
analysis: "confirmed by Sentinel's adversarial verifier. No PoC result yet.",
|
|
686
|
+
lead: "found by an auditor, not yet confirmed by the verifier. Treat it as a lead."
|
|
687
|
+
}[v];
|
|
688
|
+
const runStep = poc ? v === "notrepro" ? `2. Save the PoC below as ${tick(poc.file)} and run it. Show the output, compare it with Sentinel's run below, and say whether it reproduces in this codebase.` : v === "reproduced" ? `2. Save the PoC below as ${tick(poc.file)} and run it. Show the output and say whether it reproduces.` : `2. Save the PoC below as ${tick(poc.file)} and run it. Show the output and say whether it reproduces. If its setup doesn't match this codebase, adapt it and list what you changed.` : null;
|
|
689
|
+
const out = p?.output ? printed(p.output) : "";
|
|
690
|
+
return [
|
|
691
|
+
"# Task: investigate a Sentinel finding and decide on an approach",
|
|
692
|
+
"",
|
|
693
|
+
`${open}${poc ? " Simnet only; nothing touches mainnet." : ""}`,
|
|
694
|
+
"",
|
|
695
|
+
"## Start here",
|
|
696
|
+
`1. ${where(f, ctx)}`,
|
|
697
|
+
...runStep ? [runStep] : [],
|
|
698
|
+
`${runStep ? 3 : 2}. Only then discuss approaches that fit this codebase's design.`,
|
|
699
|
+
"",
|
|
700
|
+
...reportBackMd(ctx, poc ? "- Show the exact command you ran and its full output." : "- Say what you checked and how."),
|
|
701
|
+
...contextMd(f, ctx, kind),
|
|
702
|
+
f.class === "bug" ? "- Class: bug (not a trust assumption; no privileged role needed)" : `- Class: ${f.class}`,
|
|
703
|
+
`- Evidence: ${evidence}`,
|
|
704
|
+
"",
|
|
705
|
+
...v === "reproduced" && p?.trace ? movedMd(p.trace) : [],
|
|
706
|
+
...issueMd(f, true),
|
|
707
|
+
...poc ? [
|
|
708
|
+
v === "notrepro" ? `## PoC as it ran (save as ${tick(poc.file)})` : `## PoC (save as ${tick(poc.file)})`,
|
|
709
|
+
`${FENCE}typescript`,
|
|
710
|
+
poc.source,
|
|
711
|
+
FENCE,
|
|
712
|
+
"",
|
|
713
|
+
"## Run it",
|
|
714
|
+
`${FENCE}shell`,
|
|
715
|
+
bundle.runCommand ?? "",
|
|
716
|
+
FENCE,
|
|
717
|
+
...out && (v === "reproduced" || v === "notrepro") ? [
|
|
718
|
+
v === "reproduced" ? "Sentinel's output, before any change:" : "Sentinel's run printed:",
|
|
719
|
+
FENCE,
|
|
720
|
+
out,
|
|
721
|
+
FENCE
|
|
722
|
+
] : [],
|
|
723
|
+
""
|
|
724
|
+
] : [],
|
|
725
|
+
...f.recommendedAction ? ["## One direction to consider (not a verified patch)", f.recommendedAction, ""] : [],
|
|
726
|
+
...poc ? [
|
|
727
|
+
"## Confirm a change",
|
|
728
|
+
"- Re-run the PoC. With your change in place it should no longer succeed.",
|
|
729
|
+
"- Consider keeping the PoC as a regression test, with its assertions flipped to expect the failure."
|
|
730
|
+
] : [],
|
|
731
|
+
...tailMd(ctx, !!poc)
|
|
732
|
+
].join(`
|
|
733
|
+
`);
|
|
734
|
+
}
|
|
735
|
+
function agentBrief(f, ctx) {
|
|
736
|
+
const kind = f.kind ?? findingKind(f);
|
|
737
|
+
if (ctx.prelaunch && kind !== "refuted")
|
|
738
|
+
return prelaunchBrief(f, ctx, kind);
|
|
739
|
+
if (kind === "refuted")
|
|
740
|
+
return refutedBrief(f, ctx);
|
|
741
|
+
if (kind === "trust")
|
|
742
|
+
return trustBrief(f, ctx);
|
|
743
|
+
return bugBrief(f, ctx, kind);
|
|
744
|
+
}
|
|
745
|
+
|
|
386
746
|
// cli/sentinel.ts
|
|
387
747
|
var USAGE = `sentinel <command>
|
|
388
748
|
|
|
@@ -396,7 +756,8 @@ var USAGE = `sentinel <command>
|
|
|
396
756
|
|
|
397
757
|
plans your contracts: monitoring, worst evidence, plan id
|
|
398
758
|
plan <id> [--json] verdict and findings (--json: the raw v1 response)
|
|
399
|
-
finding <id> <n> [--json] one finding
|
|
759
|
+
finding <id> <n> [--poc] [--run] [--brief] [--json] one finding; --poc adds the PoC source, --run the command
|
|
760
|
+
to re-run it, --brief the agent brief (the finding page's "Copy agent brief")
|
|
400
761
|
runs <id> follow-up runs on a plan
|
|
401
762
|
watch <id> follow running runs until none are left
|
|
402
763
|
reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
|
|
@@ -430,9 +791,9 @@ credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENT
|
|
|
430
791
|
`;
|
|
431
792
|
var POLL_MS = 5000;
|
|
432
793
|
var VALUE_FLAGS = new Set(["--email", "--webhook", "--slack", "--contract"]);
|
|
433
|
-
var
|
|
794
|
+
var cell2 = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
|
|
434
795
|
var table = (rows) => {
|
|
435
|
-
const text = rows.map((r) => r.map((c) =>
|
|
796
|
+
const text = rows.map((r) => r.map((c) => cell2(c)));
|
|
436
797
|
const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
|
|
437
798
|
return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
|
|
438
799
|
};
|
|
@@ -443,7 +804,7 @@ var phaseOf = (r) => {
|
|
|
443
804
|
var verdictLine = (p) => {
|
|
444
805
|
const r = p.report;
|
|
445
806
|
const head = `${p.contractId} ${p.status}`;
|
|
446
|
-
return r ? `${head} ${
|
|
807
|
+
return r ? `${head} ${cell2(r.severity)}/${cell2(r.class)} alert=${cell2(r.alertLevel)}` : head;
|
|
447
808
|
};
|
|
448
809
|
async function run(argv, io) {
|
|
449
810
|
const flags = [];
|
|
@@ -468,7 +829,17 @@ ${USAGE}` : USAGE);
|
|
|
468
829
|
}
|
|
469
830
|
const [cmd, id, n] = args;
|
|
470
831
|
const json = flags.includes("--json");
|
|
471
|
-
const known = new Set([
|
|
832
|
+
const known = new Set([
|
|
833
|
+
"--json",
|
|
834
|
+
"--yes",
|
|
835
|
+
"--help",
|
|
836
|
+
"--force",
|
|
837
|
+
"--with-token",
|
|
838
|
+
"--from-repo",
|
|
839
|
+
"--poc",
|
|
840
|
+
"--run",
|
|
841
|
+
"--brief"
|
|
842
|
+
]);
|
|
472
843
|
if (flags.some((f) => !known.has(f)))
|
|
473
844
|
return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
|
|
474
845
|
if (flags.includes("--help") || !cmd || cmd === "help") {
|
|
@@ -484,8 +855,8 @@ ${USAGE}` : USAGE);
|
|
|
484
855
|
io.out(JSON.stringify(plans, null, 2));
|
|
485
856
|
else {
|
|
486
857
|
const rows = plans.map((p) => [
|
|
487
|
-
p.contractId,
|
|
488
|
-
|
|
858
|
+
contractName(p.contractId),
|
|
859
|
+
monitoringLabel(p.monitoring),
|
|
489
860
|
p.evidence?.worst ? `${p.evidence.worst.severity} ${p.evidence.worst.evidence}` : null,
|
|
490
861
|
p.planId
|
|
491
862
|
]);
|
|
@@ -508,8 +879,7 @@ ${USAGE}` : USAGE);
|
|
|
508
879
|
io.out("Exploit detail is hidden until you verify control in the web app.");
|
|
509
880
|
const rows = (p.report?.findings ?? []).map((f, i) => [
|
|
510
881
|
i,
|
|
511
|
-
f.
|
|
512
|
-
f.class,
|
|
882
|
+
f.kind ? KIND_LABEL[f.kind] : f.class,
|
|
513
883
|
f.verdict,
|
|
514
884
|
f.disposition,
|
|
515
885
|
f.title
|
|
@@ -525,14 +895,50 @@ ${USAGE}` : USAGE);
|
|
|
525
895
|
if (n === undefined || !/^\d+$/.test(n))
|
|
526
896
|
return usage("sentinel finding needs a plan id and a finding number");
|
|
527
897
|
const f = await io.client.finding(id, Number(n));
|
|
898
|
+
const wantPoc = flags.includes("--poc");
|
|
899
|
+
const wantRun = flags.includes("--run");
|
|
900
|
+
const wantBrief = flags.includes("--brief");
|
|
901
|
+
const p = wantPoc || wantRun || wantBrief ? await io.client.plan(id) : null;
|
|
902
|
+
const commit = p?.report?.target?.commit ?? null;
|
|
903
|
+
const bundle = p && (wantPoc || wantRun) ? handoffBundle(f, { contract: p.contractId, commit }) : null;
|
|
904
|
+
if (p && wantBrief) {
|
|
905
|
+
io.out(agentBrief(f, {
|
|
906
|
+
contract: p.contractId,
|
|
907
|
+
commit,
|
|
908
|
+
planId: id,
|
|
909
|
+
index: Number(n),
|
|
910
|
+
prelaunch: p.access === "prelaunch"
|
|
911
|
+
}));
|
|
912
|
+
return 0;
|
|
913
|
+
}
|
|
528
914
|
if (json) {
|
|
529
|
-
io.out(JSON.stringify(f, null, 2));
|
|
915
|
+
io.out(JSON.stringify(bundle ? { finding: f, poc: bundle.poc, runCommand: bundle.runCommand } : f, null, 2));
|
|
530
916
|
return 0;
|
|
531
917
|
}
|
|
918
|
+
const kind = f.kind ? KIND_LABEL[f.kind] : f.class;
|
|
919
|
+
const alerts = f.alertLevel ? `alerts: ${f.alertLevel}` : null;
|
|
532
920
|
io.out(`${f.title}
|
|
533
|
-
${[
|
|
921
|
+
${[kind, f.verdict, f.disposition, alerts].filter(Boolean).map((v) => cell2(v)).join(" · ")}`);
|
|
534
922
|
if (f.headline)
|
|
535
923
|
io.out(f.headline);
|
|
924
|
+
if (wantPoc) {
|
|
925
|
+
if (bundle?.poc) {
|
|
926
|
+
io.out(`
|
|
927
|
+
# PoC · ${bundle.poc.file}`);
|
|
928
|
+
io.out(bundle.poc.source);
|
|
929
|
+
} else
|
|
930
|
+
io.out(`
|
|
931
|
+
No PoC is stored for this finding.`);
|
|
932
|
+
}
|
|
933
|
+
if (wantRun) {
|
|
934
|
+
if (bundle?.runCommand) {
|
|
935
|
+
io.out(`
|
|
936
|
+
# Run it yourself`);
|
|
937
|
+
io.out(bundle.runCommand);
|
|
938
|
+
} else
|
|
939
|
+
io.out(`
|
|
940
|
+
No run command: no PoC is stored for this finding.`);
|
|
941
|
+
}
|
|
536
942
|
return 0;
|
|
537
943
|
}
|
|
538
944
|
case "runs": {
|
|
@@ -752,7 +1158,7 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
|
|
|
752
1158
|
return 0;
|
|
753
1159
|
}
|
|
754
1160
|
const r = await io.client.watchContract(first);
|
|
755
|
-
io.out("
|
|
1161
|
+
io.out("intents" 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
1162
|
return 0;
|
|
757
1163
|
}
|
|
758
1164
|
if (!first)
|
|
@@ -769,21 +1175,16 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
|
|
|
769
1175
|
const ctx = await watchContext(io.client, planId);
|
|
770
1176
|
if (cmd === "watches") {
|
|
771
1177
|
if (flags.includes("--json")) {
|
|
772
|
-
io.out(JSON.stringify({
|
|
1178
|
+
io.out(JSON.stringify({ intents: ctx.intents }, null, 2));
|
|
773
1179
|
return 0;
|
|
774
1180
|
}
|
|
775
1181
|
if (ctx.access === "claimed") {
|
|
776
1182
|
io.out("Exploit detail is hidden until you verify control in the web app.");
|
|
777
1183
|
}
|
|
778
|
-
for (const
|
|
779
|
-
const muted =
|
|
780
|
-
io.out([
|
|
781
|
-
|
|
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)
|
|
1184
|
+
for (const i of ctx.intents) {
|
|
1185
|
+
const muted = i.rows.map((w) => mutedUntilOf(w)).find(Boolean);
|
|
1186
|
+
io.out([i.title, i.tag, i.on ? "on" : "off", intentLine(i), muted ? `muted until ${muted}` : ""].filter(Boolean).join(" "));
|
|
1187
|
+
for (const w of i.rows)
|
|
787
1188
|
io.out(` ${w.status.padEnd(14)}${ruleSummary(w.rule, w.units)}`);
|
|
788
1189
|
}
|
|
789
1190
|
return 0;
|
|
@@ -893,7 +1294,10 @@ async function readStdin() {
|
|
|
893
1294
|
async function prompt(question) {
|
|
894
1295
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
895
1296
|
try {
|
|
896
|
-
return await
|
|
1297
|
+
return await new Promise((resolve, reject) => {
|
|
1298
|
+
rl.once("close", () => reject(new PromptClosedError));
|
|
1299
|
+
rl.question(question).then(resolve, reject);
|
|
1300
|
+
});
|
|
897
1301
|
} finally {
|
|
898
1302
|
rl.close();
|
|
899
1303
|
}
|
|
@@ -913,7 +1317,13 @@ async function main() {
|
|
|
913
1317
|
client: clientFor(credential?.token ?? ""),
|
|
914
1318
|
out: console.log,
|
|
915
1319
|
err: console.error,
|
|
916
|
-
auth: {
|
|
1320
|
+
auth: {
|
|
1321
|
+
source: credential?.source ?? null,
|
|
1322
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
1323
|
+
clientFor,
|
|
1324
|
+
prompt,
|
|
1325
|
+
readStdin
|
|
1326
|
+
}
|
|
917
1327
|
});
|
|
918
1328
|
}
|
|
919
1329
|
process.exit(await main());
|