@secondlayer/sentinel 0.1.1 → 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/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) {
@@ -423,6 +419,26 @@ async function whoami(client, io) {
423
419
  return 0;
424
420
  }
425
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
+
426
442
  // client/labels.ts
427
443
  var isPrelaunchKey = (id) => id.startsWith("github:") || id.startsWith("gist:");
428
444
  function contractName(id) {
@@ -439,6 +455,294 @@ var MONITOR_LABELS = {
439
455
  };
440
456
  var monitoringLabel = (status) => status ? MONITOR_LABELS[status] ?? status : "not on yet";
441
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
+
442
746
  // cli/sentinel.ts
443
747
  var USAGE = `sentinel <command>
444
748
 
@@ -452,7 +756,8 @@ var USAGE = `sentinel <command>
452
756
 
453
757
  plans your contracts: monitoring, worst evidence, plan id
454
758
  plan <id> [--json] verdict and findings (--json: the raw v1 response)
455
- 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")
456
761
  runs <id> follow-up runs on a plan
457
762
  watch <id> follow running runs until none are left
458
763
  reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
@@ -486,9 +791,9 @@ credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENT
486
791
  `;
487
792
  var POLL_MS = 5000;
488
793
  var VALUE_FLAGS = new Set(["--email", "--webhook", "--slack", "--contract"]);
489
- 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);
490
795
  var table = (rows) => {
491
- const text = rows.map((r) => r.map((c) => cell(c)));
796
+ const text = rows.map((r) => r.map((c) => cell2(c)));
492
797
  const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
493
798
  return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
494
799
  };
@@ -499,7 +804,7 @@ var phaseOf = (r) => {
499
804
  var verdictLine = (p) => {
500
805
  const r = p.report;
501
806
  const head = `${p.contractId} ${p.status}`;
502
- 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;
503
808
  };
504
809
  async function run(argv, io) {
505
810
  const flags = [];
@@ -524,7 +829,17 @@ ${USAGE}` : USAGE);
524
829
  }
525
830
  const [cmd, id, n] = args;
526
831
  const json = flags.includes("--json");
527
- 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
+ ]);
528
843
  if (flags.some((f) => !known.has(f)))
529
844
  return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
530
845
  if (flags.includes("--help") || !cmd || cmd === "help") {
@@ -564,8 +879,7 @@ ${USAGE}` : USAGE);
564
879
  io.out("Exploit detail is hidden until you verify control in the web app.");
565
880
  const rows = (p.report?.findings ?? []).map((f, i) => [
566
881
  i,
567
- f.severity,
568
- f.class,
882
+ f.kind ? KIND_LABEL[f.kind] : f.class,
569
883
  f.verdict,
570
884
  f.disposition,
571
885
  f.title
@@ -581,14 +895,50 @@ ${USAGE}` : USAGE);
581
895
  if (n === undefined || !/^\d+$/.test(n))
582
896
  return usage("sentinel finding needs a plan id and a finding number");
583
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
+ }
584
914
  if (json) {
585
- io.out(JSON.stringify(f, null, 2));
915
+ io.out(JSON.stringify(bundle ? { finding: f, poc: bundle.poc, runCommand: bundle.runCommand } : f, null, 2));
586
916
  return 0;
587
917
  }
918
+ const kind = f.kind ? KIND_LABEL[f.kind] : f.class;
919
+ const alerts = f.alertLevel ? `alerts: ${f.alertLevel}` : null;
588
920
  io.out(`${f.title}
589
- ${[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(" · ")}`);
590
922
  if (f.headline)
591
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
+ }
592
942
  return 0;
593
943
  }
594
944
  case "runs": {
@@ -808,7 +1158,7 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
808
1158
  return 0;
809
1159
  }
810
1160
  const r = await io.client.watchContract(first);
811
- 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.`);
812
1162
  return 0;
813
1163
  }
814
1164
  if (!first)
@@ -825,21 +1175,16 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
825
1175
  const ctx = await watchContext(io.client, planId);
826
1176
  if (cmd === "watches") {
827
1177
  if (flags.includes("--json")) {
828
- io.out(JSON.stringify({ watches: ctx.watches }, null, 2));
1178
+ io.out(JSON.stringify({ intents: ctx.intents }, null, 2));
829
1179
  return 0;
830
1180
  }
831
1181
  if (ctx.access === "claimed") {
832
1182
  io.out("Exploit detail is hidden until you verify control in the web app.");
833
1183
  }
834
- for (const g of groupSubjects(ctx.watches)) {
835
- const muted = g.watches.map((w) => mutedUntilOf(w)).find(Boolean);
836
- io.out([
837
- g.name,
838
- [...new Set(g.watches.map((w) => kindTag(w.rule)))].join("+"),
839
- subjectStatus(g.watches),
840
- muted ? `muted until ${muted}` : ""
841
- ].filter(Boolean).join(" "));
842
- 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)
843
1188
  io.out(` ${w.status.padEnd(14)}${ruleSummary(w.rule, w.units)}`);
844
1189
  }
845
1190
  return 0;
package/dist/index.js CHANGED
@@ -79,10 +79,14 @@ class SentinelError extends Error {
79
79
  this.name = "SentinelError";
80
80
  }
81
81
  }
82
- var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : `asset:${s.asset}`;
82
+ var rowsOf = (intents) => intents.flatMap((i) => i.rows);
83
+ var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : s.kind === "group" ? `group:${s.group}` : `asset:${s.asset}`;
84
+ var CONTROL_NAME = "Control changes";
83
85
  function subjectName(s, units) {
84
86
  if (s.kind === "fn")
85
87
  return s.fn;
88
+ if (s.kind === "group")
89
+ return CONTROL_NAME;
86
90
  if (units?.symbol)
87
91
  return units.symbol;
88
92
  if (s.asset.toLowerCase() === "stx")
@@ -114,7 +118,7 @@ function resolveSubject(watches, name) {
114
118
  const hits = groups.filter((g) => {
115
119
  const s = g.watches[0].subject;
116
120
  const asset = s.kind === "asset" ? s.asset.toLowerCase() : null;
117
- return g.name.toLowerCase() === q || g.key.toLowerCase() === q || asset === q || asset !== null && asset.split("::")[1] === q;
121
+ return g.name.toLowerCase() === q || g.key.toLowerCase() === q || s.kind === "group" && q === s.group || asset === q || asset !== null && asset.split("::")[1] === q;
118
122
  });
119
123
  if (hits.length === 1)
120
124
  return hits[0];
@@ -181,6 +185,17 @@ function kindTag(rule) {
181
185
  return rule.triggerClass === "governance.proposal_submitted" ? "governance" : "upgrade";
182
186
  }
183
187
  }
188
+ function intentLine(i) {
189
+ const t = i.threshold;
190
+ if (i.kind !== "money_out" || !t)
191
+ return i.summary;
192
+ const units = i.units ?? null;
193
+ if (t.amount === null) {
194
+ const sug = t.suggested ? ` (suggested ${amountText(t.suggested.amount, units)}, ${t.suggested.basis})` : "";
195
+ return `no threshold yet: every outflow alerts${sug}`;
196
+ }
197
+ return `${amountText(t.amount, units)} or more in one tx · ${t.basis}`;
198
+ }
184
199
  function subjectStatus(ws) {
185
200
  const st = ws.map((w) => w.status);
186
201
  if (st.includes("live"))
@@ -250,11 +265,13 @@ var HOUR_MS = 3600000;
250
265
  var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
251
266
  async function watchContext(client, planId) {
252
267
  const plan = await client.plan(planId);
268
+ const { intents } = await client.watches(planId);
253
269
  return {
254
270
  planId,
255
271
  contractId: plan.contractId,
256
272
  access: plan.access,
257
- watches: (await client.watches(planId)).watches
273
+ intents,
274
+ watches: rowsOf(intents)
258
275
  };
259
276
  }
260
277
  async function savedMonitor(client, planId) {
@@ -272,6 +289,7 @@ async function planOfContract(client, contractId) {
272
289
  return hit.planId;
273
290
  }
274
291
  export {
292
+ CONTROL_NAME,
275
293
  CREDITS_API_URL,
276
294
  DEFAULT_BASE_URL,
277
295
  NOT_LOGGED_IN,
@@ -282,6 +300,7 @@ export {
282
300
  createClient,
283
301
  creditsUrl,
284
302
  groupSubjects,
303
+ intentLine,
285
304
  isMutableRule,
286
305
  kindTag,
287
306
  muteEnd,
@@ -291,6 +310,7 @@ export {
291
310
  readTokenFile,
292
311
  resolveSubject,
293
312
  resolveToken,
313
+ rowsOf,
294
314
  ruleSummary,
295
315
  savedChoices,
296
316
  savedMonitor,
package/dist/kind.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What a finding is, for the people watching the contract (docs/product/system.md § "How findings are
3
+ * shown"). The user sees a kind, never an audit severity; severity lives on as the alert level of the
4
+ * finding's watch. Dependency-free and the ONE derivation: the API stamps `kind` and `alertLevel` on every
5
+ * finding it returns, so the page, the CLI and the MCP tools never derive it differently.
6
+ */
7
+ /**
8
+ * exploitable a bug, verified, independently RE-verified, and reproduced green in the sandbox. Rare.
9
+ * trust a power held by design (centralization / trust assumption, accepted or not)
10
+ * worth_a_look everything else not refuted: analysis only, not reproduced yet, a lead, or reproduced
11
+ * but not re-verified yet
12
+ * refuted refuted on re-check
13
+ */
14
+ export type FindingKind = "exploitable" | "trust" | "worth_a_look" | "refuted";
15
+ /** How a finding's watch pages: urgent, or normal. */
16
+ export type AlertLevel = "urgent" | "normal";
17
+ /** A watched function's dominant consequence (engine/watch-select/facts.ts `Consequence`, copied: no deps). */
18
+ export type Consequence = "authority" | "code" | "value" | "param" | "none";
19
+ /** The finding fields the kind reads. The app's ReportFinding and the client's Finding both satisfy it. */
20
+ export type KindInput = {
21
+ class: string;
22
+ verdict: string;
23
+ disposition: string;
24
+ /** The PoC status after the credibility gates (a later reproduction's, once one lands). */
25
+ poc?: string;
26
+ /** A second, independent verifier pass confirmed it. Absent: not re-verified (yet). */
27
+ reverified?: boolean;
28
+ };
29
+ /** Reproduced green in the sandbox AND past the credibility gates. A PoC that printed "reproduced" but
30
+ * that Gate 3 downgraded (an incomplete freeze proof) is `pending`, so it is not green here. */
31
+ export declare const isGreen: (f: Pick<KindInput, "poc">) => boolean;
32
+ export declare function findingKind(f: KindInput): FindingKind;
33
+ /**
34
+ * The alert level of a finding's watch. A trust assumption pages urgently only when its function changes
35
+ * who controls the contract, swaps code it calls, or moves value; an unknown consequence is `normal`
36
+ * (the caller logs it). A refuted finding has no watch.
37
+ */
38
+ export declare function alertLevelFor(kind: Exclude<FindingKind, "refuted">, consequence?: Consequence | null): AlertLevel;
39
+ /** A kind as every surface names it (the page's chip, the plan list, the CLI, the agent brief). */
40
+ export declare const KIND_LABEL: Record<FindingKind, string>;
41
+ /** Plan-list order: Exploitable first, then trust assumptions, then worth a look; refuted last. */
42
+ export declare const KIND_ORDER: FindingKind[];
package/dist/mcp.js CHANGED
@@ -12659,10 +12659,14 @@ class SentinelError extends Error {
12659
12659
  this.name = "SentinelError";
12660
12660
  }
12661
12661
  }
12662
- var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : `asset:${s.asset}`;
12662
+ var rowsOf = (intents) => intents.flatMap((i) => i.rows);
12663
+ var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : s.kind === "group" ? `group:${s.group}` : `asset:${s.asset}`;
12664
+ var CONTROL_NAME = "Control changes";
12663
12665
  function subjectName(s, units) {
12664
12666
  if (s.kind === "fn")
12665
12667
  return s.fn;
12668
+ if (s.kind === "group")
12669
+ return CONTROL_NAME;
12666
12670
  if (units?.symbol)
12667
12671
  return units.symbol;
12668
12672
  if (s.asset.toLowerCase() === "stx")
@@ -12694,7 +12698,7 @@ function resolveSubject(watches, name) {
12694
12698
  const hits = groups.filter((g) => {
12695
12699
  const s = g.watches[0].subject;
12696
12700
  const asset = s.kind === "asset" ? s.asset.toLowerCase() : null;
12697
- return g.name.toLowerCase() === q || g.key.toLowerCase() === q || asset === q || asset !== null && asset.split("::")[1] === q;
12701
+ return g.name.toLowerCase() === q || g.key.toLowerCase() === q || s.kind === "group" && q === s.group || asset === q || asset !== null && asset.split("::")[1] === q;
12698
12702
  });
12699
12703
  if (hits.length === 1)
12700
12704
  return hits[0];
@@ -12746,20 +12750,16 @@ function ruleSummary(rule, units) {
12746
12750
  }
12747
12751
  }
12748
12752
  }
12749
- function kindTag(rule) {
12750
- switch (rule.kind) {
12751
- case "signature":
12752
- return "finding";
12753
- case "outflowSingle":
12754
- case "outflowWindow":
12755
- case "valueNewActor":
12756
- return "outflow";
12757
- case "callerAllowlist":
12758
- case "actor":
12759
- return "who-acts";
12760
- case "audit":
12761
- return rule.triggerClass === "governance.proposal_submitted" ? "governance" : "upgrade";
12753
+ function intentLine(i) {
12754
+ const t = i.threshold;
12755
+ if (i.kind !== "money_out" || !t)
12756
+ return i.summary;
12757
+ const units = i.units ?? null;
12758
+ if (t.amount === null) {
12759
+ const sug = t.suggested ? ` (suggested ${amountText(t.suggested.amount, units)}, ${t.suggested.basis})` : "";
12760
+ return `no threshold yet: every outflow alerts${sug}`;
12762
12761
  }
12762
+ return `${amountText(t.amount, units)} or more in one tx · ${t.basis}`;
12763
12763
  }
12764
12764
  function subjectStatus(ws) {
12765
12765
  const st = ws.map((w) => w.status);
@@ -12830,11 +12830,13 @@ var HOUR_MS = 3600000;
12830
12830
  var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
12831
12831
  async function watchContext(client, planId) {
12832
12832
  const plan = await client.plan(planId);
12833
+ const { intents } = await client.watches(planId);
12833
12834
  return {
12834
12835
  planId,
12835
12836
  contractId: plan.contractId,
12836
12837
  access: plan.access,
12837
- watches: (await client.watches(planId)).watches
12838
+ intents,
12839
+ watches: rowsOf(intents)
12838
12840
  };
12839
12841
  }
12840
12842
  async function savedMonitor(client, planId) {
@@ -20279,6 +20281,316 @@ var EMPTY_COMPLETION_RESULT = {
20279
20281
  }
20280
20282
  };
20281
20283
 
20284
+ // client/kind.ts
20285
+ var isGreen = (f) => f.poc === "green";
20286
+ function findingKind(f) {
20287
+ if (f.disposition === "refuted" || f.verdict === "refuted")
20288
+ return "refuted";
20289
+ if (f.class === "centralization" || f.disposition === "waived")
20290
+ return "trust";
20291
+ if (f.class === "bug" && f.verdict === "confirmed" && f.reverified === true && isGreen(f)) {
20292
+ return "exploitable";
20293
+ }
20294
+ return "worth_a_look";
20295
+ }
20296
+ var URGENT_CONSEQUENCES = new Set(["authority", "code", "value"]);
20297
+
20298
+ // client/labels.ts
20299
+ var isPrelaunchKey = (id) => id.startsWith("github:") || id.startsWith("gist:");
20300
+ function contractName(id) {
20301
+ if (isPrelaunchKey(id))
20302
+ return id.split(":").pop()?.split("/").pop()?.replace(/\.clar$/, "") ?? id;
20303
+ return id.split(".")[1] ?? id;
20304
+ }
20305
+
20306
+ // client/handoff.ts
20307
+ var DEFAULT_FILE = "sentinel-poc.ts";
20308
+ var pocFileName = (f) => f.proof?.file ?? DEFAULT_FILE;
20309
+ function runCommand(opts) {
20310
+ const lines = [`# save the PoC above as ${opts.file}, then in your own checkout:`];
20311
+ if (opts.commit)
20312
+ lines.push(`git checkout ${opts.commit} # the commit Sentinel reviewed`);
20313
+ const where = opts.substrate === "fork" ? "needs a Stacks RPC" : "airgapped, no network";
20314
+ lines.push(`bun run ${opts.file} # @stacks/clarinet-sdk — ${where}`);
20315
+ return lines.join(`
20316
+ `);
20317
+ }
20318
+ function handoffBundle(f, ctx) {
20319
+ const proof = f.proof;
20320
+ const file = pocFileName(f);
20321
+ const poc = proof?.script ? {
20322
+ file,
20323
+ substrate: proof.substrate ?? null,
20324
+ reproduced: !!proof.reproduced,
20325
+ exitCode: proof.exitCode ?? null,
20326
+ source: proof.script
20327
+ } : null;
20328
+ return {
20329
+ contract: ctx.contract,
20330
+ fn: f.targetFn ?? null,
20331
+ commit: ctx.commit ?? null,
20332
+ poc,
20333
+ runCommand: poc ? runCommand({ commit: ctx.commit, file, substrate: poc.substrate }) : null
20334
+ };
20335
+ }
20336
+ function assertionsOf(output) {
20337
+ const ratio = output.match(/(\d+)\s*\/\s*(\d+)\s*assertions?/i);
20338
+ if (ratio)
20339
+ return `${ratio[1]}/${ratio[2]}`;
20340
+ const passed = output.match(/(\d+)\s*assertions?\s*passed/i);
20341
+ return passed ? `${passed[1]} passed` : null;
20342
+ }
20343
+ var FENCE = "```";
20344
+ var FOOTER = "Sentinel's findings are a starting point for your own review, not an audit sign-off.";
20345
+ var KIND_LINE = {
20346
+ exploitable: "Exploitable (verified, re-verified, reproduced in the sandbox)",
20347
+ trust: "Trust assumption (watched: Sentinel alerts when it's used)",
20348
+ worth_a_look: "Worth a look (not proven exploitable)",
20349
+ refuted: "Refuted"
20350
+ };
20351
+ var tick = (s) => `\`${s}\``;
20352
+ var cell = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
20353
+ var num = (n) => n.toLocaleString("en-US");
20354
+ function printed(output) {
20355
+ const lines = output.replace(/\[SENTINEL-POC-TRACE\][\s\S]*?\[\/SENTINEL-POC-TRACE\]/g, "").split(`
20356
+ `).filter((l) => l.trim());
20357
+ return lines.slice(-40).join(`
20358
+ `);
20359
+ }
20360
+ function movedMd(trace) {
20361
+ const keys = Object.keys(trace.series ?? {}).filter((k) => trace.steps.some((s) => s.values?.[k] !== undefined));
20362
+ const name = (k) => trace.series?.[k] ?? k;
20363
+ const unit = trace.unit ? ` ${trace.unit}` : "";
20364
+ const changes = (i) => {
20365
+ const prev = trace.steps[i - 1]?.values;
20366
+ const now = trace.steps[i].values;
20367
+ if (!prev || !now)
20368
+ return [];
20369
+ 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}`);
20370
+ };
20371
+ const first = trace.steps.findIndex((_, i) => changes(i).length > 0);
20372
+ const rows = trace.steps.map((s, i) => {
20373
+ const call = [s.call && tick(s.call), s.result && `→ ${tick(s.result)}`].filter(Boolean).join(" ");
20374
+ const start = keys.filter((k) => s.values?.[k] !== undefined).map((k) => `${name(k)} ${num(s.values?.[k])}${unit}`).join(" · ");
20375
+ const change = i === 0 ? start || "start" : changes(i).join(" · ") || "no change";
20376
+ return `| ${i + 1} ${cell(s.label)} | ${cell(call || "—")} | ${cell(change)} |`;
20377
+ });
20378
+ return [
20379
+ "## What the PoC moved",
20380
+ first > 0 ? `**${changes(first).join("; ")} at step ${first + 1} (${trace.steps[first].label}).**` : "No tracked value changed across the steps.",
20381
+ "",
20382
+ "| Step | Call | Value change |",
20383
+ "|---|---|---|",
20384
+ ...rows,
20385
+ ""
20386
+ ];
20387
+ }
20388
+ function variantOf(f) {
20389
+ if (f.proof?.reproduced)
20390
+ return "reproduced";
20391
+ if (f.proof && !f.proof.unavailable && !f.proof.errored && f.proof.script)
20392
+ return "notrepro";
20393
+ if (f.verdict !== "confirmed")
20394
+ return "lead";
20395
+ return "analysis";
20396
+ }
20397
+ function where(f, ctx) {
20398
+ const at = ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : "";
20399
+ 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.`;
20400
+ }
20401
+ function contextMd(f, ctx, kind) {
20402
+ return [
20403
+ "## Context",
20404
+ `- Contract: ${tick(ctx.contract)}`,
20405
+ ...ctx.commit ? [`- Commit reviewed: ${tick(ctx.commit.slice(0, 7))}`] : [],
20406
+ ...f.targetFn ? [`- Function: ${tick(f.targetFn)}`] : [],
20407
+ `- Kind: ${KIND_LINE[kind]}`
20408
+ ];
20409
+ }
20410
+ function reportBackMd(ctx, extra) {
20411
+ return [
20412
+ "## How to report back",
20413
+ `- Cite every claim with ${tick("file:line")}${ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : ""}.`,
20414
+ "- Show the code you discuss, quoted from the repo, not paraphrased.",
20415
+ extra,
20416
+ "- Keep two sections: **Facts** (what the code and the run show) and **Assessment** (your conclusions, each with a confidence level). Don't mix them.",
20417
+ "- Don't change contract code until the team has chosen an approach.",
20418
+ ""
20419
+ ];
20420
+ }
20421
+ function tailMd(ctx, withPoc) {
20422
+ return [
20423
+ "",
20424
+ "## Pull it again",
20425
+ `${FENCE}shell`,
20426
+ `npx @secondlayer/sentinel finding ${ctx.planId} ${ctx.index}${withPoc ? " --poc --run" : ""}`,
20427
+ FENCE,
20428
+ `MCP: ${tick(`get_finding { planId: "${ctx.planId}", index: ${ctx.index} }`)} returns this finding as JSON${withPoc ? ", the PoC source and the run command" : ""}.`,
20429
+ "",
20430
+ "---",
20431
+ FOOTER
20432
+ ];
20433
+ }
20434
+ function issueMd(f, withCondition) {
20435
+ return [
20436
+ "## The issue",
20437
+ f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
20438
+ ...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
20439
+ ...withCondition && f.precondition ? ["", `**Exploitable when:** ${f.precondition}`] : [],
20440
+ ""
20441
+ ];
20442
+ }
20443
+ function prelaunchBrief(f, ctx, kind) {
20444
+ const kindLine = kind === "exploitable" ? "Exploitable · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
20445
+ return [
20446
+ "# Task: review a Sentinel finding on pre-launch code",
20447
+ "",
20448
+ `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.`,
20449
+ "",
20450
+ "## Start here",
20451
+ `1. ${where(f, ctx)}`,
20452
+ "2. Only then discuss approaches that fit this codebase's design.",
20453
+ "",
20454
+ "## How to report back",
20455
+ `- Cite every claim with ${tick("file:line")}${ctx.commit ? ` at commit ${tick(ctx.commit.slice(0, 7))}` : ""}.`,
20456
+ "- Keep **Facts** and **Assessment** apart, with a confidence level on each conclusion.",
20457
+ "",
20458
+ "## Context",
20459
+ `- Contract: ${tick(contractName(ctx.contract))} (pre-launch)${ctx.commit ? `, commit ${tick(ctx.commit.slice(0, 7))}` : ""}`,
20460
+ `- ${f.targetFn ? `Function: ${tick(f.targetFn)} · ` : ""}Kind: ${kindLine}`,
20461
+ "",
20462
+ ...issueMd(f, false),
20463
+ ...f.recommendedAction ? ["## One direction to consider (not a verified patch)", f.recommendedAction, ""] : [],
20464
+ "---",
20465
+ FOOTER
20466
+ ].join(`
20467
+ `);
20468
+ }
20469
+ function trustBrief(f, ctx) {
20470
+ return [
20471
+ "# Task: review a trust assumption and decide whether to reduce it",
20472
+ "",
20473
+ `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.`,
20474
+ "",
20475
+ "## Start here",
20476
+ `1. ${where(f, ctx)}`,
20477
+ `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.`,
20478
+ "3. Only then discuss options that fit this codebase's design.",
20479
+ "",
20480
+ ...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write an exploit for it."),
20481
+ ...contextMd(f, ctx, "trust"),
20482
+ "- Class: trust assumption (a power held by design)",
20483
+ "- Evidence: checked by Sentinel's adversarial verifier. No PoC; nothing to run.",
20484
+ "",
20485
+ "## What it is",
20486
+ f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
20487
+ ...f.powers?.length ? [
20488
+ "",
20489
+ "**Powers:**",
20490
+ ...f.powers.map((p) => `- ${p.holder} can ${p.can}${p.fn ? ` (${tick(p.fn)})` : ""}`)
20491
+ ] : [],
20492
+ ...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
20493
+ ...f.precondition ? ["", `**Usable when:** ${f.precondition}`] : [],
20494
+ "",
20495
+ ...f.recommendedAction ? ["## Options to consider (not a patch)", `- ${f.recommendedAction}`, ""] : [],
20496
+ "## If you change it",
20497
+ "- A test for the new behavior.",
20498
+ "- A test that an account without the role is still refused.",
20499
+ ...tailMd(ctx, false)
20500
+ ].join(`
20501
+ `);
20502
+ }
20503
+ function refutedBrief(f, ctx) {
20504
+ return [
20505
+ "# Note: a Sentinel finding refuted on re-check",
20506
+ "",
20507
+ `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.`,
20508
+ "",
20509
+ ...contextMd(f, ctx, "refuted"),
20510
+ ...tailMd(ctx, false)
20511
+ ].join(`
20512
+ `);
20513
+ }
20514
+ function bugBrief(f, ctx, kind) {
20515
+ const v = variantOf(f);
20516
+ const bundle = handoffBundle(f, { contract: ctx.contract, commit: ctx.commit });
20517
+ const poc = bundle.poc;
20518
+ const p = f.proof;
20519
+ const asserted = p?.output ? assertionsOf(p.output) : null;
20520
+ const counted = asserted?.includes("/") ? `${asserted} assertions, ` : asserted ? `${asserted.replace(" passed", "")} assertions passed, ` : "";
20521
+ const ran = `${counted}exit ${p?.exitCode ?? "?"}`;
20522
+ const sandbox = p?.substrate === "fork" ? "a mainnet-fork sandbox" : `an airgapped sandbox (${tick("--network none")})`;
20523
+ const what = `Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}`;
20524
+ const open = {
20525
+ 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.`,
20526
+ 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.`,
20527
+ 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.`,
20528
+ 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.`
20529
+ }[v];
20530
+ const evidence = {
20531
+ 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."}`,
20532
+ notrepro: `confirmed by Sentinel's adversarial verifier. The PoC ran in ${sandbox} and did not reproduce it (${ran}). Verified by analysis only.`,
20533
+ analysis: "confirmed by Sentinel's adversarial verifier. No PoC result yet.",
20534
+ lead: "found by an auditor, not yet confirmed by the verifier. Treat it as a lead."
20535
+ }[v];
20536
+ 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;
20537
+ const out = p?.output ? printed(p.output) : "";
20538
+ return [
20539
+ "# Task: investigate a Sentinel finding and decide on an approach",
20540
+ "",
20541
+ `${open}${poc ? " Simnet only; nothing touches mainnet." : ""}`,
20542
+ "",
20543
+ "## Start here",
20544
+ `1. ${where(f, ctx)}`,
20545
+ ...runStep ? [runStep] : [],
20546
+ `${runStep ? 3 : 2}. Only then discuss approaches that fit this codebase's design.`,
20547
+ "",
20548
+ ...reportBackMd(ctx, poc ? "- Show the exact command you ran and its full output." : "- Say what you checked and how."),
20549
+ ...contextMd(f, ctx, kind),
20550
+ f.class === "bug" ? "- Class: bug (not a trust assumption; no privileged role needed)" : `- Class: ${f.class}`,
20551
+ `- Evidence: ${evidence}`,
20552
+ "",
20553
+ ...v === "reproduced" && p?.trace ? movedMd(p.trace) : [],
20554
+ ...issueMd(f, true),
20555
+ ...poc ? [
20556
+ v === "notrepro" ? `## PoC as it ran (save as ${tick(poc.file)})` : `## PoC (save as ${tick(poc.file)})`,
20557
+ `${FENCE}typescript`,
20558
+ poc.source,
20559
+ FENCE,
20560
+ "",
20561
+ "## Run it",
20562
+ `${FENCE}shell`,
20563
+ bundle.runCommand ?? "",
20564
+ FENCE,
20565
+ ...out && (v === "reproduced" || v === "notrepro") ? [
20566
+ v === "reproduced" ? "Sentinel's output, before any change:" : "Sentinel's run printed:",
20567
+ FENCE,
20568
+ out,
20569
+ FENCE
20570
+ ] : [],
20571
+ ""
20572
+ ] : [],
20573
+ ...f.recommendedAction ? ["## One direction to consider (not a verified patch)", f.recommendedAction, ""] : [],
20574
+ ...poc ? [
20575
+ "## Confirm a change",
20576
+ "- Re-run the PoC. With your change in place it should no longer succeed.",
20577
+ "- Consider keeping the PoC as a regression test, with its assertions flipped to expect the failure."
20578
+ ] : [],
20579
+ ...tailMd(ctx, !!poc)
20580
+ ].join(`
20581
+ `);
20582
+ }
20583
+ function agentBrief(f, ctx) {
20584
+ const kind = f.kind ?? findingKind(f);
20585
+ if (ctx.prelaunch && kind !== "refuted")
20586
+ return prelaunchBrief(f, ctx, kind);
20587
+ if (kind === "refuted")
20588
+ return refutedBrief(f, ctx);
20589
+ if (kind === "trust")
20590
+ return trustBrief(f, ctx);
20591
+ return bugBrief(f, ctx, kind);
20592
+ }
20593
+
20282
20594
  // mcp/server.ts
20283
20595
  var CLAIMED_NOTE = "Exploit detail is hidden until you verify control of this contract in the web app.";
20284
20596
  var REVERIFY_ESTIMATE = "about 10 min";
@@ -20326,14 +20638,19 @@ var paidRun = (willDo) => ({
20326
20638
  cost: PAID_RUN_NOTE,
20327
20639
  next: NEXT
20328
20640
  });
20329
- function subjectView(g) {
20330
- const status = subjectStatus(g.watches);
20641
+ function intentView(i) {
20642
+ const lead = i.rows[0];
20331
20643
  return {
20332
- subject: g.name,
20333
- kind: [...new Set(g.watches.map((w) => kindTag(w.rule)))],
20334
- status,
20335
- mutedUntil: g.watches.map((w) => mutedUntilOf(w)).find(Boolean) ?? null,
20336
- rules: g.watches.map((w) => ({
20644
+ watch: i.title,
20645
+ subject: lead ? subjectName(lead.subject, lead.units) : i.title,
20646
+ kind: i.kind,
20647
+ on: i.on,
20648
+ alertLevel: i.alertLevel,
20649
+ summary: intentLine(i),
20650
+ members: i.members,
20651
+ status: subjectStatus(i.rows),
20652
+ mutedUntil: i.rows.map((w) => mutedUntilOf(w)).find(Boolean) ?? null,
20653
+ rules: i.rows.map((w) => ({
20337
20654
  watchId: w.id,
20338
20655
  kind: w.rule.kind,
20339
20656
  status: w.status,
@@ -20373,7 +20690,7 @@ function buildTools(client) {
20373
20690
  }))
20374
20691
  },
20375
20692
  get_plan: {
20376
- description: "A monitoring plan: verdict, findings (title, headline, severity, class, verdict, disposition) and watches.",
20693
+ description: "A monitoring plan: verdict, findings (title, headline, severity, class, verdict, disposition, kind, alertLevel) and watches.",
20377
20694
  input: { planId },
20378
20695
  run: async (a) => {
20379
20696
  const p = await client.plan(a.planId);
@@ -20396,21 +20713,32 @@ function buildTools(client) {
20396
20713
  severity: f.severity,
20397
20714
  class: f.class,
20398
20715
  verdict: f.verdict,
20399
- disposition: f.disposition
20716
+ disposition: f.disposition,
20717
+ kind: f.kind,
20718
+ alertLevel: f.alertLevel
20400
20719
  })),
20401
20720
  watches: p.report?.plan?.watches ?? []
20402
20721
  };
20403
20722
  }
20404
20723
  },
20405
20724
  get_finding: {
20406
- description: "One finding in full, including proof and the verifier's note when you are a verified owner.",
20725
+ description: "One finding in full (its kind and its watch's alert level) plus the handoff: the agent brief (the finding page's \"Copy agent brief\", a Markdown task to start your own review from), the stored PoC source and the exact command to re-run it against the reviewed source (the same as `sentinel finding --brief --poc --run`). Proof and the verifier's note need a verified owner.",
20407
20726
  input: { planId, index: number2().int().min(0).describe("Finding index from get_plan") },
20408
20727
  run: async (a) => {
20409
20728
  const [finding, p] = await Promise.all([
20410
20729
  client.finding(a.planId, a.index),
20411
20730
  client.plan(a.planId)
20412
20731
  ]);
20413
- return { ...noteFor(p), finding };
20732
+ const commit = p.report?.target?.commit ?? null;
20733
+ const bundle = handoffBundle(finding, { contract: p.contractId, commit });
20734
+ const brief = p.access === "claimed" ? null : agentBrief(finding, {
20735
+ contract: p.contractId,
20736
+ commit,
20737
+ planId: a.planId,
20738
+ index: a.index,
20739
+ prelaunch: p.access === "prelaunch"
20740
+ });
20741
+ return { ...noteFor(p), finding, brief, poc: bundle.poc, runCommand: bundle.runCommand };
20414
20742
  }
20415
20743
  },
20416
20744
  get_run_status: {
@@ -20529,13 +20857,13 @@ function buildTools(client) {
20529
20857
  run: async (a) => a.confirm === true ? client.testsFromRepo(a.planId) : paidRun("Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox.")
20530
20858
  },
20531
20859
  list_watches: {
20532
- description: "What a plan watches, by subject (a function or a token): kind, status, each rule in token units, and any mute.",
20860
+ description: "What a plan watches, as intent watches (Money out per token, Control changes, one per finding, the fns you added): on or off, the threshold or summary, the fns covered, each rule in token units, any mute, and the subject name the other watch tools take.",
20533
20861
  input: { planId },
20534
20862
  run: async (a) => {
20535
20863
  const ctx = await watchContext(client, a.planId);
20536
20864
  return {
20537
20865
  ...ctx.access === "claimed" ? { note: CLAIMED_NOTE } : {},
20538
- subjects: groupSubjects(ctx.watches).map(subjectView)
20866
+ watches: ctx.intents.map(intentView)
20539
20867
  };
20540
20868
  }
20541
20869
  },
@@ -20713,7 +21041,7 @@ async function runTool(tool, args) {
20713
21041
  }
20714
21042
  }
20715
21043
  function buildServer(client) {
20716
- const server = new McpServer({ name: "sentinel", version: "0.1.1" });
21044
+ const server = new McpServer({ name: "sentinel", version: "0.2.0" });
20717
21045
  for (const [name, tool] of Object.entries(buildTools(client))) {
20718
21046
  server.registerTool(name, { description: tool.description, inputSchema: tool.input }, (args) => runTool(tool, args));
20719
21047
  }
@@ -2,6 +2,7 @@
2
2
  * Client for the Sentinel v1 API (docs/api.md). Shared by the MCP server and the CLI. No dependencies:
3
3
  * global fetch. Auth is one function, `authHeader`, so another credential can be swapped in later.
4
4
  */
5
+ import type { AlertLevel, FindingKind } from "./kind";
5
6
  export declare const DEFAULT_BASE_URL = "https://api.runsentinel.app";
6
7
  export declare class SentinelError extends Error {
7
8
  readonly status: number;
@@ -47,8 +48,25 @@ export type Finding = {
47
48
  verdict: string;
48
49
  disposition: string;
49
50
  poc?: string;
51
+ /** What the finding is for the people watching (client/kind.ts), stamped by the API. */
52
+ kind?: FindingKind;
53
+ /** How the finding's watch pages; absent on a refuted finding. */
54
+ alertLevel?: AlertLevel;
55
+ reverified?: boolean;
56
+ /** "fn" when `targetFn` names the function; "contract" when no single function carries it. */
57
+ scope?: "fn" | "contract";
58
+ /** Trust findings: the powers held by design, one entry each. */
59
+ powers?: Power[];
60
+ /** The watch row's subject, a short fn-level phrase (verified owners only). */
61
+ watchSubject?: string;
50
62
  [field: string]: unknown;
51
63
  };
64
+ /** One power a trust finding describes (monitoring/adjudication.ts `Power`, copied: no deps). */
65
+ export type Power = {
66
+ holder: string;
67
+ can: string;
68
+ fn?: string;
69
+ };
52
70
  /** A plan at the caller's access level: `access` is absent on the teaser. */
53
71
  export type PlanView = {
54
72
  view: string;
@@ -114,6 +132,11 @@ export type WatchSubject = {
114
132
  } | {
115
133
  kind: "asset";
116
134
  asset: string;
135
+ }
136
+ /** Control changes: one group over every fn that changes who controls the contract. */
137
+ | {
138
+ kind: "group";
139
+ group: "control";
117
140
  };
118
141
  export type WatchStatus = "suggested" | "live" | "paused";
119
142
  export type Units = {
@@ -146,8 +169,57 @@ export type StoredWatch = {
146
169
  updatedAt: string;
147
170
  units: Units | null;
148
171
  };
172
+ export type IntentKind = "money_out" | "control" | "finding" | "owner_fn";
173
+ /**
174
+ * One intent watch: what a team sees (Money out per asset, Control changes, one per finding, a fn the team
175
+ * added). `rows` are the stored watches behind it: what a rule edit, a switch or a mute acts on.
176
+ */
177
+ export type IntentWatch<R = StoredWatch & {
178
+ on: boolean;
179
+ }> = {
180
+ id: string;
181
+ kind: IntentKind;
182
+ title: string;
183
+ tag: string;
184
+ summary: string;
185
+ on: boolean;
186
+ alertLevel: "urgent" | "normal";
187
+ watchIds: string[];
188
+ rows: R[];
189
+ members: {
190
+ group: string;
191
+ fns: string[];
192
+ }[];
193
+ /** Money out: the asset, its token units and its threshold (null amount: every outflow alerts). */
194
+ asset?: string;
195
+ units?: Units | null;
196
+ threshold?: {
197
+ amount: string | null;
198
+ basis: string;
199
+ confirmed: boolean;
200
+ suggested: {
201
+ amount: string;
202
+ basis: string;
203
+ } | null;
204
+ };
205
+ findingRef?: {
206
+ title: string;
207
+ index: number | null;
208
+ };
209
+ };
210
+ /** The stored rows behind a list of intent watches, in order. */
211
+ export declare const rowsOf: <R>(intents: {
212
+ rows: R[];
213
+ }[]) => R[];
149
214
  export type WatchesView = {
150
- watches: StoredWatch[];
215
+ intents: IntentWatch[];
216
+ /** "N of M on", over intent watches. */
217
+ counts: {
218
+ on: number;
219
+ total: number;
220
+ };
221
+ /** Finding-based watches hidden until the account verifies control (a count only). */
222
+ locked: number;
151
223
  events: {
152
224
  id: string;
153
225
  watchId: string;
@@ -167,13 +239,13 @@ export type StartedMonitoring = {
167
239
  contractId: string;
168
240
  planId: string;
169
241
  status: "draft" | "requested" | "live";
170
- watches: StoredWatchView[];
242
+ intents: IntentWatch<StoredWatchView>[];
171
243
  webhookUrl: string | null;
172
244
  slackUrl: string | null;
173
245
  emailAlerts: boolean;
174
246
  hasSigningSecret: boolean;
175
247
  };
176
- type StoredWatchView = Pick<StoredWatch, "id" | "subject" | "rule" | "status" | "title">;
248
+ type StoredWatchView = Pick<StoredWatch, "id" | "subject" | "rule" | "suggestedRule" | "delivery" | "provenance" | "status" | "title" | "reason" | "units">;
177
249
  export type WatchChoice = {
178
250
  watchId?: string;
179
251
  fn: string;
@@ -200,7 +272,8 @@ export type DeliveryResult = {
200
272
  detail: string;
201
273
  };
202
274
  export declare const subjectKey: (s: WatchSubject) => string;
203
- /** The subject's display name: the fn name, or the token's symbol (never the raw token id). */
275
+ export declare const CONTROL_NAME = "Control changes";
276
+ /** The subject's display name: the fn name, the token's symbol (never the raw token id), or the group's. */
204
277
  export declare function subjectName(s: WatchSubject, units: Units | null): string;
205
278
  type HasSubject = {
206
279
  subject: WatchSubject;
@@ -220,7 +293,8 @@ export declare class SubjectError extends Error {
220
293
  export declare function groupSubjects<W extends HasSubject>(watches: W[]): SubjectMatch<W>[];
221
294
  /**
222
295
  * The watches on the subject `name` points at: its display name (fn name, token symbol, or the part
223
- * after `::`), its subjectKey (`fn:…` / `asset:…`) or a full asset id, case-insensitive. A name that
296
+ * after `::`, or "Control changes" / "control"), its subjectKey (`fn:…` / `asset:…` / `group:control`) or a
297
+ * full asset id, case-insensitive. A name that
224
298
  * matches nothing, or more than one subject, throws a SubjectError listing the candidates.
225
299
  */
226
300
  export declare function resolveSubject<W extends HasSubject>(watches: W[], name: string): SubjectMatch<W>;
@@ -231,6 +305,8 @@ export declare function ruleSummary(rule: WatchRule, units: Units | null): strin
231
305
  export type WatchKindTag = "finding" | "outflow" | "who-acts" | "governance" | "upgrade";
232
306
  /** The plain tag a rule is listed under. */
233
307
  export declare function kindTag(rule: WatchRule): WatchKindTag;
308
+ /** An intent watch's one line: a Money out threshold in token units (or its fail-safe), else its summary. */
309
+ export declare function intentLine(i: Pick<IntentWatch<unknown>, "kind" | "summary" | "threshold" | "units">): string;
234
310
  /** A subject's status from its rules: live if any is, else paused if all are, else suggested. */
235
311
  export declare function subjectStatus(ws: {
236
312
  status: WatchStatus;
@@ -355,7 +431,11 @@ export type WatchContext = {
355
431
  contractId: string;
356
432
  /** The plan's access level for this caller; a claimed one hides exploit detail. */
357
433
  access: PlanView["access"];
358
- watches: StoredWatch[];
434
+ intents: IntentWatch[];
435
+ /** The stored rows behind the intents: what subjects resolve over. */
436
+ watches: (StoredWatch & {
437
+ on: boolean;
438
+ })[];
359
439
  };
360
440
  /** The caller's watches for a plan. */
361
441
  export declare function watchContext(client: SentinelClient, planId: string): Promise<WatchContext>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secondlayer/sentinel",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Sentinel from a terminal or an agent: the CLI, an MCP server and a client for your Stacks contract monitoring.",
5
5
  "license": "MIT",
6
6
  "repository": "https://runsentinel.app/docs",