@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 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-sl_…` for a scoped key. Every write tool takes `confirm`; without it you get what would happen and nothing changes.
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 subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : `asset:${s.asset}`;
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 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";
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
- watches: (await client.watches(planId)).watches
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
- io.out(`Already logged in as ${existing.email}. Run again with --force to switch accounts.`);
328
- return 0;
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 cell = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
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) => cell(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} ${cell(r.severity)}/${cell(r.class)} alert=${cell(r.alertLevel)}` : 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(["--json", "--yes", "--help", "--force", "--with-token", "--from-repo"]);
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
- cell(p.monitoring, "none"),
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.severity,
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
- ${[f.severity, f.class, f.verdict, f.disposition].map((v) => cell(v)).join(" ")}`);
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("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.`);
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({ watches: ctx.watches }, null, 2));
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 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)
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 rl.question(question);
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: { source: credential?.source ?? null, clientFor, prompt, readStdin }
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());