@secondlayer/sentinel 0.3.0 → 0.5.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 +2 -1
- package/dist/cli.js +790 -26
- package/dist/index.js +73 -1
- package/dist/mcp.js +592 -19
- package/dist/sentinel-client.d.ts +453 -5
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -85,6 +85,13 @@ class SentinelError extends Error {
|
|
|
85
85
|
this.name = "SentinelError";
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
var runInProgress = (status) => status === "queued" || status === "running";
|
|
89
|
+
function runPhase(run) {
|
|
90
|
+
if (run.status === "queued")
|
|
91
|
+
return "queued";
|
|
92
|
+
const last = run.events.at(-1);
|
|
93
|
+
return last ? [last.name ?? last.kind, last.state].filter(Boolean).join(" ") : "starting";
|
|
94
|
+
}
|
|
88
95
|
var rowsOf = (intents) => intents.flatMap((i) => i.rows);
|
|
89
96
|
var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : s.kind === "group" ? `group:${s.group}` : `asset:${s.asset}`;
|
|
90
97
|
var CONTROL_NAME = "Control changes";
|
|
@@ -211,6 +218,7 @@ function createClient(opts) {
|
|
|
211
218
|
}
|
|
212
219
|
const plan = (id) => `/v1/plans/${encodeURIComponent(id)}`;
|
|
213
220
|
const appPlan = (id) => `/plan/${encodeURIComponent(id)}`;
|
|
221
|
+
const appProject = (id) => `/projects/${encodeURIComponent(id)}`;
|
|
214
222
|
return {
|
|
215
223
|
me: () => call("GET", "/v1/me"),
|
|
216
224
|
plans: async () => (await call("GET", "/v1/plans")).plans,
|
|
@@ -226,6 +234,8 @@ function createClient(opts) {
|
|
|
226
234
|
testsFromRepo: (id) => call("POST", `${appPlan(id)}/tests`, { fromRepo: true }),
|
|
227
235
|
previewPlan: (contractId) => call("POST", "/plan/preview", { contractId }),
|
|
228
236
|
createPlan: (contractId) => call("POST", "/plan", { contractId }),
|
|
237
|
+
previewImport: (url) => call("POST", "/projects/import/preview", { url }),
|
|
238
|
+
importRepo: (input) => call("POST", "/projects/import", input),
|
|
229
239
|
billing: () => call("GET", "/billing"),
|
|
230
240
|
watches: (planId) => call("GET", `${appPlan(planId)}/watches`),
|
|
231
241
|
setRule: (planId, watchId, rule) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/rule`, { rule }),
|
|
@@ -242,9 +252,69 @@ function createClient(opts) {
|
|
|
242
252
|
watchContract: (contractId) => call("POST", "/contracts/watch", {
|
|
243
253
|
contractId
|
|
244
254
|
}),
|
|
245
|
-
stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`)
|
|
255
|
+
stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`),
|
|
256
|
+
watchActivity: (planId, intentId, opts = {}) => call("GET", `${appPlan(planId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`),
|
|
257
|
+
functions: async (planId) => (await call("GET", `${appPlan(planId)}/functions`)).functions,
|
|
258
|
+
functionActivity: (planId, fn) => call("GET", `${appPlan(planId)}/functions/${encodeURIComponent(fn)}/activity`),
|
|
259
|
+
alertPreview: (planId, example) => call("GET", `${appPlan(planId)}/alert-preview${query({ example })}`),
|
|
260
|
+
threshold: (planId, asset) => call("GET", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`),
|
|
261
|
+
setThreshold: (planId, asset, amount) => call("POST", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`, { amount }),
|
|
262
|
+
retryHistory: (planId) => call("POST", `${appPlan(planId)}/history/retry`, {}),
|
|
263
|
+
readAlert: (alertId) => call("POST", `/alerts/${encodeURIComponent(alertId)}/read`),
|
|
264
|
+
readAllAlerts: () => call("POST", "/alerts/read-all"),
|
|
265
|
+
ask: (q) => call("POST", "/ask", { q }),
|
|
266
|
+
acknowledge: (planId, finding, title, reason, note = null) => call("POST", `${appPlan(planId)}/acknowledge`, {
|
|
267
|
+
finding,
|
|
268
|
+
title,
|
|
269
|
+
reason,
|
|
270
|
+
note
|
|
271
|
+
}),
|
|
272
|
+
reopen: (planId, finding, title) => call("POST", `${appPlan(planId)}/reopen`, { finding, title }),
|
|
273
|
+
ackHistory: async (planId) => (await call("GET", `${appPlan(planId)}/acks`)).events,
|
|
274
|
+
notes: async (planId, finding) => (await call("GET", `${appPlan(planId)}/findings/${finding}/notes`)).notes,
|
|
275
|
+
addNote: (planId, finding, title, body) => call("POST", `${appPlan(planId)}/findings/${finding}/notes`, {
|
|
276
|
+
title,
|
|
277
|
+
body
|
|
278
|
+
}),
|
|
279
|
+
tests: (planId, files) => call("POST", `${appPlan(planId)}/tests`, { files }),
|
|
280
|
+
fuzzCheck: (planId, harness) => call("POST", `${appPlan(planId)}/fuzzcheck`, { harness }),
|
|
281
|
+
fuzz: (planId, invariants) => call("POST", `${appPlan(planId)}/fuzz`, { invariants }),
|
|
282
|
+
latestCommit: (planId) => call("GET", `${appPlan(planId)}/latest`),
|
|
283
|
+
updatePlan: (planId, commit) => call("POST", `${appPlan(planId)}/update`, commit ? { commit } : {}),
|
|
284
|
+
setAutoPlan: (planId, auto) => call("POST", `${appPlan(planId)}/wait`, { auto }),
|
|
285
|
+
setLink: (planId, dependency, url) => call("POST", `${appPlan(planId)}/link`, { id: dependency, url }),
|
|
286
|
+
planLinked: (planId) => call("POST", `${appPlan(planId)}/plan-linked`, {}),
|
|
287
|
+
renameContract: (planId, name) => call("POST", `${appPlan(planId)}/settings`, { name }),
|
|
288
|
+
removeContract: (planId, confirm) => call("POST", `${appPlan(planId)}/remove`, {
|
|
289
|
+
confirm
|
|
290
|
+
}),
|
|
291
|
+
projects: () => call("GET", "/projects"),
|
|
292
|
+
project: (projectId) => call("GET", appProject(projectId)),
|
|
293
|
+
projectOverview: (projectId, opts = {}) => call("GET", `${appProject(projectId)}/overview${opts.watches ? "?watches=1" : ""}`),
|
|
294
|
+
renameProject: (projectId, name) => call("POST", `${appProject(projectId)}/rename`, { name }),
|
|
295
|
+
removeProject: (projectId, confirm) => call("POST", `${appProject(projectId)}/remove`, {
|
|
296
|
+
confirm
|
|
297
|
+
}),
|
|
298
|
+
answerMergeSuggestion: (from, into, accept) => call("POST", "/projects/merge", { from, into, accept }),
|
|
299
|
+
mergeProject: (projectId, into) => call("POST", `${appProject(projectId)}/merge-into`, { into }),
|
|
300
|
+
moveProjectContract: (projectId, contractId, into) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/move`, { into }),
|
|
301
|
+
removeProjectContract: (projectId, contractId, confirm) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/remove`, { confirm }),
|
|
302
|
+
previewProjectContract: (projectId, contractId) => call("POST", `${appProject(projectId)}/contracts/preview`, { contractId }),
|
|
303
|
+
addProjectContract: (projectId, contractId, opts = {}) => call("POST", `${appProject(projectId)}/contracts`, { contractId, ...opts }),
|
|
304
|
+
setProjectDestinations: (projectId, dest) => call("POST", `${appProject(projectId)}/destinations`, dest),
|
|
305
|
+
setProjectMonitoring: (projectId, on) => call("POST", `${appProject(projectId)}/monitoring`, { on }),
|
|
306
|
+
projectWatches: (projectId) => call("GET", `${appProject(projectId)}/watches`),
|
|
307
|
+
projectActivity: (projectId, intentId, opts = {}) => call("GET", `${appProject(projectId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`)
|
|
246
308
|
};
|
|
247
309
|
}
|
|
310
|
+
function query(params) {
|
|
311
|
+
const q = new URLSearchParams;
|
|
312
|
+
for (const [k, v] of Object.entries(params))
|
|
313
|
+
if (v !== undefined && v !== null)
|
|
314
|
+
q.set(k, String(v));
|
|
315
|
+
const s = q.toString();
|
|
316
|
+
return s ? `?${s}` : "";
|
|
317
|
+
}
|
|
248
318
|
var HOUR_MS = 3600000;
|
|
249
319
|
var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
|
|
250
320
|
async function watchContext(client, planId) {
|
|
@@ -418,6 +488,9 @@ async function whoami(client, io) {
|
|
|
418
488
|
return 0;
|
|
419
489
|
}
|
|
420
490
|
|
|
491
|
+
// cli/sentinel.ts
|
|
492
|
+
import { readFileSync } from "node:fs";
|
|
493
|
+
|
|
421
494
|
// client/kind.ts
|
|
422
495
|
var isGreen = (f) => f.poc === "green";
|
|
423
496
|
function findingKind(f) {
|
|
@@ -432,7 +505,7 @@ function findingKind(f) {
|
|
|
432
505
|
}
|
|
433
506
|
var URGENT_CONSEQUENCES = new Set(["authority", "code", "value"]);
|
|
434
507
|
var KIND_LABEL = {
|
|
435
|
-
exploitable: "
|
|
508
|
+
exploitable: "Reproducible",
|
|
436
509
|
trust: "Trust assumption",
|
|
437
510
|
worth_a_look: "Worth a look",
|
|
438
511
|
refuted: "refuted"
|
|
@@ -494,9 +567,9 @@ function assertionsOf(output) {
|
|
|
494
567
|
var FENCE = "```";
|
|
495
568
|
var FOOTER = "Sentinel's findings are a starting point for your own review, not an audit sign-off.";
|
|
496
569
|
var KIND_LINE = {
|
|
497
|
-
exploitable: "
|
|
570
|
+
exploitable: "Reproducible (verified, re-verified, reproduced in the sandbox)",
|
|
498
571
|
trust: "Trust assumption (watched: Sentinel alerts when it's used)",
|
|
499
|
-
worth_a_look: "Worth a look (not
|
|
572
|
+
worth_a_look: "Worth a look (not reproduced)",
|
|
500
573
|
refuted: "Refuted"
|
|
501
574
|
};
|
|
502
575
|
var tick = (s) => `\`${s}\``;
|
|
@@ -587,16 +660,16 @@ function issueMd(f, withCondition) {
|
|
|
587
660
|
"## The issue",
|
|
588
661
|
f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
|
|
589
662
|
...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
|
|
590
|
-
...withCondition && f.precondition ? ["", `**
|
|
663
|
+
...withCondition && f.precondition ? ["", `**Reproduces when:** ${f.precondition}`] : [],
|
|
591
664
|
""
|
|
592
665
|
];
|
|
593
666
|
}
|
|
594
667
|
function prelaunchBrief(f, ctx, kind) {
|
|
595
|
-
const kindLine = kind === "exploitable" ? "
|
|
668
|
+
const kindLine = kind === "exploitable" ? "Reproducible · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
|
|
596
669
|
return [
|
|
597
670
|
"# Task: review a Sentinel finding on pre-launch code",
|
|
598
671
|
"",
|
|
599
|
-
`Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. PoCs and
|
|
672
|
+
`Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. PoCs and reproduction 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.`,
|
|
600
673
|
"",
|
|
601
674
|
"## Start here",
|
|
602
675
|
`1. ${where(f, ctx)}`,
|
|
@@ -628,7 +701,7 @@ function trustBrief(f, ctx) {
|
|
|
628
701
|
`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.`,
|
|
629
702
|
"3. Only then discuss options that fit this codebase's design.",
|
|
630
703
|
"",
|
|
631
|
-
...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write
|
|
704
|
+
...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write a PoC for it."),
|
|
632
705
|
...contextMd(f, ctx, "trust"),
|
|
633
706
|
"- Class: trust assumption (a power held by design)",
|
|
634
707
|
"- Evidence: checked by Sentinel's adversarial verifier. No PoC; nothing to run.",
|
|
@@ -750,6 +823,9 @@ var USAGE = `sentinel <command>
|
|
|
750
823
|
logout forget the saved login (also signs the secondlayer CLI out)
|
|
751
824
|
whoami who you are and where the credential came from
|
|
752
825
|
new <contract> [--yes] plan a contract: see what it would do and cost; --yes starts it
|
|
826
|
+
import <repo-url> [--only a,b] [--all] [--project <id>|new] [--yes]
|
|
827
|
+
plan a public Clarinet repo's contracts into one project: see each one and the
|
|
828
|
+
cost (the suggested ones, unless --only or --all); --yes imports them
|
|
753
829
|
balance your balance and what a plan, a deep run and a monitored event cost
|
|
754
830
|
alerts [--contract <id>] your alerts, newest first
|
|
755
831
|
|
|
@@ -758,7 +834,7 @@ var USAGE = `sentinel <command>
|
|
|
758
834
|
finding <id> <n> [--poc] [--run] [--brief] [--json] one finding; --poc adds the PoC source, --run the command
|
|
759
835
|
to re-run it, --brief the agent brief (the finding page's "Copy agent brief")
|
|
760
836
|
runs <id> follow-up runs on a plan
|
|
761
|
-
watch <id> follow running runs until none are left
|
|
837
|
+
watch <id> follow running (and queued) runs until none are left
|
|
762
838
|
reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
|
|
763
839
|
|
|
764
840
|
paid runs on a plan you own (each starts only with --yes; without it you see what it would do):
|
|
@@ -766,10 +842,43 @@ paid runs on a plan you own (each starts only with --yes; without it you see wha
|
|
|
766
842
|
challenge <plan> <n> "<reason>" [--yes] re-verify finding n against your argument
|
|
767
843
|
replay <plan> <n> [--yes] re-run finding n's stored PoC
|
|
768
844
|
directed <plan> "<what to watch>" [--yes] re-plan around what you want watched
|
|
769
|
-
tests <plan> --from-repo [--yes]
|
|
845
|
+
tests <plan> --from-repo|<file>... [--yes] run the Clarinet suite from a pre-launch plan's repo, or your test files
|
|
846
|
+
fuzz <plan> <invariants.clar> [--yes] compile-check the invariants (free); --yes fuzzes them in the sandbox
|
|
847
|
+
|
|
848
|
+
your plans and findings (owners; a write only happens with --yes):
|
|
849
|
+
latest <plan> whether a pre-launch plan's branch moved on, and what an update reviews
|
|
850
|
+
update <plan> [--commit <sha>] [--yes] plan the latest commit (or --commit): free while monitoring is on
|
|
851
|
+
auto-plan <plan> on|off [--yes] plan a waiting plan on its own once the contracts it waits on deploy
|
|
852
|
+
link <plan> <contract> <url>|--clear [--yes] link code for a contract the plan waits on
|
|
853
|
+
plan-linked <plan> [--yes] plan now with the linked code (a paid plan)
|
|
854
|
+
name <plan> "<name>" [--yes] your own name for the contract ("" clears it)
|
|
855
|
+
remove <plan> --confirm <name> [--yes] take the contract off your account: monitoring, alerts and claim
|
|
856
|
+
ack <plan> <n> accepted|fixed|disagree [--note "<text>"] [--yes] label finding n for your team
|
|
857
|
+
reopen <plan> <n> [--yes] put an acknowledged finding back on the list
|
|
858
|
+
acks <plan> every acknowledgement change on the contract
|
|
859
|
+
notes <plan> <n> your notes on finding n
|
|
860
|
+
note <plan> <n> "<text>" [--yes] add a note to finding n
|
|
861
|
+
ask "<question>" an answer from Sentinel's docs
|
|
862
|
+
|
|
863
|
+
projects (the contracts of one protocol; a write only happens with --yes):
|
|
864
|
+
projects [--json] your projects and the merges Sentinel suggests
|
|
865
|
+
projects show <project> [--json] its contracts, each one's plan and monitoring
|
|
866
|
+
projects overview <project> [--watches] the project, its plans and (--watches) watches, as JSON
|
|
867
|
+
projects add <project> <contract> [--into <project>|new] [--yes] where it would go; --yes plans it (paid)
|
|
868
|
+
projects rename <project> "<name>" [--yes]
|
|
869
|
+
projects merge <project> <into> [--yes] move every contract into another of your projects
|
|
870
|
+
projects suggestion <from> <into> accept|dismiss [--yes] answer a merge Sentinel suggested
|
|
871
|
+
projects move <project> <contract> <into>|new [--yes]
|
|
872
|
+
projects remove-contract <project> <contract> [--confirm <name>] [--yes] a contract you added needs --confirm
|
|
873
|
+
projects remove <project> --confirm "<name>" [--yes] every contract you added in it, and the project
|
|
874
|
+
projects watches <project> [--json] the project's watches as one list
|
|
875
|
+
projects activity <project> <watch> [--contract <id>] [--window 7|90] [--json]
|
|
876
|
+
projects destinations <project> [--email on|off] [--webhook URL] [--slack URL] [--yes] a URL left out is cleared
|
|
877
|
+
projects monitoring <project> on|off [--yes]
|
|
770
878
|
|
|
771
|
-
A key needs Plans & findings (read for the above, write for new
|
|
772
|
-
(read to look, write to
|
|
879
|
+
A key needs Plans & findings (read for the above, write for new, the runs and changes above, and project changes);
|
|
880
|
+
monitoring commands and a project's watches, destinations and monitoring need Monitoring (read to look, write to
|
|
881
|
+
change); mute-alert and mark-read need Alerts: write.
|
|
773
882
|
|
|
774
883
|
monitoring (a write only happens with --yes; without it you see what would change):
|
|
775
884
|
watches <plan> [--json] subjects, rules, status, mutes
|
|
@@ -783,23 +892,39 @@ monitoring (a write only happens with --yes; without it you see what would chang
|
|
|
783
892
|
test-alert <plan> [--yes] a sample alert to every saved destination
|
|
784
893
|
follow <contract> [--yes] start monitoring a deployed contract
|
|
785
894
|
unfollow <contract> [--yes]
|
|
895
|
+
activity <plan> <watch> [--window 7|90] [--json] what a watch saw (<watch>: its title or id) and what alerted
|
|
896
|
+
functions <plan> [<fn>] [--json] every public function and its watch; with <fn>, what a watch
|
|
897
|
+
on it would have seen
|
|
898
|
+
alert-preview <plan> [--example money_out|finding] a sample alert as email and Slack get it
|
|
899
|
+
threshold <plan> <asset> [<amount>] [--yes] an asset's Money out limit; with <amount> (base units), set it
|
|
900
|
+
retry-history <plan> [--yes] read the watches' history again after a failed read
|
|
901
|
+
mark-read <alert>|--all [--yes] mark one alert, or every alert, read
|
|
786
902
|
|
|
787
903
|
<subject> is a function name or a token symbol, as the watches command shows it.
|
|
788
904
|
|
|
789
905
|
credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENTINEL_API_URL
|
|
790
906
|
`;
|
|
791
907
|
var POLL_MS = 5000;
|
|
792
|
-
var VALUE_FLAGS = new Set([
|
|
908
|
+
var VALUE_FLAGS = new Set([
|
|
909
|
+
"--email",
|
|
910
|
+
"--webhook",
|
|
911
|
+
"--slack",
|
|
912
|
+
"--contract",
|
|
913
|
+
"--only",
|
|
914
|
+
"--project",
|
|
915
|
+
"--confirm",
|
|
916
|
+
"--note",
|
|
917
|
+
"--commit",
|
|
918
|
+
"--into",
|
|
919
|
+
"--window",
|
|
920
|
+
"--example"
|
|
921
|
+
]);
|
|
793
922
|
var cell2 = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
|
|
794
923
|
var table = (rows) => {
|
|
795
924
|
const text = rows.map((r) => r.map((c) => cell2(c)));
|
|
796
925
|
const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
|
|
797
926
|
return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
|
|
798
927
|
};
|
|
799
|
-
var phaseOf = (r) => {
|
|
800
|
-
const last = r.events.at(-1);
|
|
801
|
-
return last ? [last.name ?? last.kind, last.state].filter(Boolean).join(" ") : "starting";
|
|
802
|
-
};
|
|
803
928
|
var verdictLine = (p) => {
|
|
804
929
|
const r = p.report;
|
|
805
930
|
const head = `${p.contractId} ${p.status}`;
|
|
@@ -837,7 +962,10 @@ ${USAGE}` : USAGE);
|
|
|
837
962
|
"--from-repo",
|
|
838
963
|
"--poc",
|
|
839
964
|
"--run",
|
|
840
|
-
"--brief"
|
|
965
|
+
"--brief",
|
|
966
|
+
"--all",
|
|
967
|
+
"--clear",
|
|
968
|
+
"--watches"
|
|
841
969
|
]);
|
|
842
970
|
if (flags.some((f) => !known.has(f)))
|
|
843
971
|
return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
|
|
@@ -949,7 +1077,7 @@ No run command: no PoC is stored for this finding.`);
|
|
|
949
1077
|
r.id,
|
|
950
1078
|
r.kind,
|
|
951
1079
|
r.status,
|
|
952
|
-
r.status
|
|
1080
|
+
runInProgress(r.status) ? runPhase(r) : r.finishedAt ?? ""
|
|
953
1081
|
])))
|
|
954
1082
|
io.out(l);
|
|
955
1083
|
return 0;
|
|
@@ -964,9 +1092,9 @@ No run command: no PoC is stored for this finding.`);
|
|
|
964
1092
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
965
1093
|
const seen = new Map;
|
|
966
1094
|
for (;; ) {
|
|
967
|
-
const running = (await io.client.runs(id)).filter((r) => r.status
|
|
1095
|
+
const running = (await io.client.runs(id)).filter((r) => runInProgress(r.status));
|
|
968
1096
|
for (const r of running) {
|
|
969
|
-
const phase =
|
|
1097
|
+
const phase = runPhase(r);
|
|
970
1098
|
if (seen.get(r.id) !== phase) {
|
|
971
1099
|
seen.set(r.id, phase);
|
|
972
1100
|
io.out(`${r.kind} ${r.id}: ${phase}`);
|
|
@@ -1019,6 +1147,8 @@ No run command: no PoC is stored for this finding.`);
|
|
|
1019
1147
|
io.out(`Follow it with: sentinel runs ${r.planId}`);
|
|
1020
1148
|
return 0;
|
|
1021
1149
|
}
|
|
1150
|
+
case "import":
|
|
1151
|
+
return await importRepo(args.slice(1), flags, values, io, usage);
|
|
1022
1152
|
case "balance": {
|
|
1023
1153
|
const b = await io.client.billing();
|
|
1024
1154
|
if (json)
|
|
@@ -1051,6 +1181,39 @@ No run command: no PoC is stored for this finding.`);
|
|
|
1051
1181
|
case "directed":
|
|
1052
1182
|
case "tests":
|
|
1053
1183
|
return await ownerRun(cmd, args.slice(1), flags, io, usage);
|
|
1184
|
+
case "fuzz":
|
|
1185
|
+
return await fuzz(args.slice(1), flags, io, usage);
|
|
1186
|
+
case "latest":
|
|
1187
|
+
case "update":
|
|
1188
|
+
case "auto-plan":
|
|
1189
|
+
case "link":
|
|
1190
|
+
case "plan-linked":
|
|
1191
|
+
case "name":
|
|
1192
|
+
case "remove":
|
|
1193
|
+
case "ack":
|
|
1194
|
+
case "reopen":
|
|
1195
|
+
case "acks":
|
|
1196
|
+
case "notes":
|
|
1197
|
+
case "note":
|
|
1198
|
+
return await upkeep(cmd, args.slice(1), flags, values, io, usage);
|
|
1199
|
+
case "ask": {
|
|
1200
|
+
if (!id)
|
|
1201
|
+
return usage("sentinel ask needs a question, in quotes");
|
|
1202
|
+
const a = await io.client.ask(args.slice(1).join(" "));
|
|
1203
|
+
io.out(a.answer);
|
|
1204
|
+
if (a.sources.length)
|
|
1205
|
+
io.out(`Sources: ${a.sources.join(", ")}`);
|
|
1206
|
+
return 0;
|
|
1207
|
+
}
|
|
1208
|
+
case "projects":
|
|
1209
|
+
return await projects(args.slice(1), flags, values, io, usage);
|
|
1210
|
+
case "activity":
|
|
1211
|
+
case "functions":
|
|
1212
|
+
case "alert-preview":
|
|
1213
|
+
case "threshold":
|
|
1214
|
+
case "retry-history":
|
|
1215
|
+
case "mark-read":
|
|
1216
|
+
return await monitoringMore(cmd, args.slice(1), flags, values, io, usage);
|
|
1054
1217
|
case "watches":
|
|
1055
1218
|
case "rule":
|
|
1056
1219
|
case "mute":
|
|
@@ -1071,6 +1234,64 @@ No run command: no PoC is stored for this finding.`);
|
|
|
1071
1234
|
}
|
|
1072
1235
|
var usd = (n) => `$${n.toFixed(2)}`;
|
|
1073
1236
|
var RUN_NOTE = "A paid run from your balance, charged only if it finishes.";
|
|
1237
|
+
async function importRepo(pos, flags, values, io, usage) {
|
|
1238
|
+
const [url] = pos;
|
|
1239
|
+
if (!url)
|
|
1240
|
+
return usage("sentinel import needs a GitHub repo link");
|
|
1241
|
+
const only = values["--only"]?.split(",").map((n) => n.trim()).filter(Boolean);
|
|
1242
|
+
if (only && flags.includes("--all"))
|
|
1243
|
+
return usage("Use --only or --all, not both");
|
|
1244
|
+
const p = await io.client.previewImport(url);
|
|
1245
|
+
const picked = only ? only : p.contracts.filter((c) => flags.includes("--all") || c.suggested).map((c) => c.name);
|
|
1246
|
+
const missing = picked.filter((n) => !p.contracts.some((c) => c.name === n));
|
|
1247
|
+
if (missing.length)
|
|
1248
|
+
return usage(`Not in ${p.repo} at ${p.commit.slice(0, 7)}: ${missing.join(", ")}`);
|
|
1249
|
+
io.out(`${p.owner}/${p.repo}${p.root ? `/${p.root}` : ""} @ ${p.commit.slice(0, 7)} (${p.ref})`);
|
|
1250
|
+
for (const l of table([
|
|
1251
|
+
[" ", "NAME", "DEPS", "STATE", "SUGGESTED"],
|
|
1252
|
+
...p.contracts.map((c) => [
|
|
1253
|
+
picked.includes(c.name) ? "+" : " ",
|
|
1254
|
+
c.name,
|
|
1255
|
+
c.deps.join(",") || "—",
|
|
1256
|
+
c.state,
|
|
1257
|
+
c.suggested ? "yes" : `no (${c.why})`
|
|
1258
|
+
])
|
|
1259
|
+
]))
|
|
1260
|
+
io.out(l);
|
|
1261
|
+
if (p.cycle)
|
|
1262
|
+
io.out("These contracts call each other in a loop: listed in manifest order.");
|
|
1263
|
+
for (const s of p.skipped)
|
|
1264
|
+
io.out(`Skipped ${s.path}: ${s.reason}`);
|
|
1265
|
+
for (const d of p.mainnetMatch.deployers)
|
|
1266
|
+
io.out(`Live on mainnet under ${d.deployer}: ${d.files.map((f) => `${f.name} (${f.kind} match of ${f.contractId})`).join(", ")}`);
|
|
1267
|
+
if (picked.length === 0) {
|
|
1268
|
+
io.out("Nothing to import. Pick contracts with --only a,b or --all.");
|
|
1269
|
+
return 0;
|
|
1270
|
+
}
|
|
1271
|
+
const fresh = p.contracts.filter((c) => picked.includes(c.name) && c.state === "new").length;
|
|
1272
|
+
io.out(fresh === 0 ? `Importing ${picked.length}: no new plan, nothing to pay.` : p.cost.runUsdMicros === null ? `Importing ${picked.length}: ${fresh} new plan${fresh === 1 ? "" : "s"}. Billing isn't linked: the free daily plan limit applies.` : `Importing ${picked.length}: ${fresh} new plan${fresh === 1 ? "" : "s"}, ${usd(p.cost.runUsdMicros * fresh / 1e6)}, each charged only if it finishes.`);
|
|
1273
|
+
if (fresh > p.runsAtOnce)
|
|
1274
|
+
io.out(`Plans run ${p.runsAtOnce} at a time; the rest wait their turn (queued).`);
|
|
1275
|
+
const project = values["--project"];
|
|
1276
|
+
if (!flags.includes("--yes")) {
|
|
1277
|
+
const tail = [
|
|
1278
|
+
only ? `--only ${only.join(",")}` : flags.includes("--all") ? "--all" : "",
|
|
1279
|
+
project ? `--project ${project}` : ""
|
|
1280
|
+
].filter(Boolean).join(" ");
|
|
1281
|
+
io.out(`Run again with --yes to import: sentinel import ${url}${tail ? ` ${tail}` : ""} --yes`);
|
|
1282
|
+
return 0;
|
|
1283
|
+
}
|
|
1284
|
+
const r = await io.client.importRepo({ url, commit: p.commit, contracts: picked, project });
|
|
1285
|
+
io.out(`Project: ${r.projectId ?? "—"}`);
|
|
1286
|
+
for (const x of r.plans)
|
|
1287
|
+
io.out(`${x.name} ${x.status === "kept" ? "already in the project" : x.cached ? "already planned" : x.status === "queued" ? "queued" : "started"} ${x.planId ?? "—"}`);
|
|
1288
|
+
for (const x of r.notStarted ?? [])
|
|
1289
|
+
io.out(`${x.name} not started: ${x.error}`);
|
|
1290
|
+
const first = r.plans.find((x) => x.planId)?.planId;
|
|
1291
|
+
if (first)
|
|
1292
|
+
io.out(`Follow a plan with: sentinel runs ${first}`);
|
|
1293
|
+
return 0;
|
|
1294
|
+
}
|
|
1074
1295
|
async function ownerRun(cmd, pos, flags, io, usage) {
|
|
1075
1296
|
const [planId, second, third] = pos;
|
|
1076
1297
|
if (!planId)
|
|
@@ -1085,16 +1306,19 @@ async function ownerRun(cmd, pos, flags, io, usage) {
|
|
|
1085
1306
|
return usage("sentinel challenge needs a reason, in quotes");
|
|
1086
1307
|
if (cmd === "directed" && !second)
|
|
1087
1308
|
return usage("sentinel directed needs what to watch, in quotes");
|
|
1088
|
-
|
|
1089
|
-
|
|
1309
|
+
const fromRepo = flags.includes("--from-repo");
|
|
1310
|
+
const testPaths = cmd === "tests" ? pos.slice(1) : [];
|
|
1311
|
+
if (cmd === "tests" && fromRepo === testPaths.length > 0)
|
|
1312
|
+
return usage("sentinel tests needs --from-repo (a pre-launch plan's repo tests) or your test files");
|
|
1313
|
+
const files = testPaths.map((path) => ({ path, content: readLocal(io, path) }));
|
|
1090
1314
|
const willDo = {
|
|
1091
1315
|
reproduce: `Write and run a PoC for finding ${finding} in the airgapped sandbox (never touches mainnet).`,
|
|
1092
1316
|
replay: `Re-run finding ${finding}'s stored PoC in the airgapped sandbox.`,
|
|
1093
1317
|
challenge: `Re-verify finding ${finding} against your argument.`,
|
|
1094
1318
|
directed: "Re-plan this contract around what you asked to have watched.",
|
|
1095
|
-
tests: "Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox."
|
|
1319
|
+
tests: fromRepo ? "Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox." : `Run ${files.length} test file(s) against this plan's contract in the airgapped sandbox.`
|
|
1096
1320
|
}[cmd];
|
|
1097
|
-
const flagTail = cmd === "tests" ? " --from-repo" : "";
|
|
1321
|
+
const flagTail = cmd === "tests" ? fromRepo ? " --from-repo" : ` ${testPaths.join(" ")}` : "";
|
|
1098
1322
|
const quoted = (v) => v ? ` ${JSON.stringify(v)}` : "";
|
|
1099
1323
|
const argTail = {
|
|
1100
1324
|
reproduce: ` ${finding}`,
|
|
@@ -1114,7 +1338,7 @@ async function ownerRun(cmd, pos, flags, io, usage) {
|
|
|
1114
1338
|
replay: () => io.client.replay(planId, finding),
|
|
1115
1339
|
challenge: () => io.client.challenge(planId, finding, third),
|
|
1116
1340
|
directed: () => io.client.directed(planId, second),
|
|
1117
|
-
tests: () => io.client.testsFromRepo(planId)
|
|
1341
|
+
tests: () => fromRepo ? io.client.testsFromRepo(planId) : io.client.tests(planId, files)
|
|
1118
1342
|
}[cmd]();
|
|
1119
1343
|
io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}`);
|
|
1120
1344
|
return 0;
|
|
@@ -1276,6 +1500,546 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
|
|
|
1276
1500
|
io.out(mute ? `Muted ${g.name} until ${mutedUntil}` : `Unmuted ${g.name}.`);
|
|
1277
1501
|
return 0;
|
|
1278
1502
|
}
|
|
1503
|
+
function readLocal(io, path) {
|
|
1504
|
+
try {
|
|
1505
|
+
return (io.readFile ?? ((p) => readFileSync(p, "utf8")))(path);
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
throw new SentinelError(0, `Can't read ${path}: ${e.message}`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
var SHA = /^[0-9a-f]{40}$/;
|
|
1511
|
+
var short = (sha) => sha.slice(0, 7);
|
|
1512
|
+
var onOff = (v) => v === "on" ? true : v === "off" ? false : null;
|
|
1513
|
+
async function fuzz(pos, flags, io, usage) {
|
|
1514
|
+
const [planId, file] = pos;
|
|
1515
|
+
if (!planId || !file)
|
|
1516
|
+
return usage("sentinel fuzz needs a plan id and an invariants file");
|
|
1517
|
+
const harness = readLocal(io, file);
|
|
1518
|
+
const check = await io.client.fuzzCheck(planId, harness);
|
|
1519
|
+
if (!check.ok) {
|
|
1520
|
+
io.err(`The invariants don't compile: ${check.message ?? "no detail"}`);
|
|
1521
|
+
return 1;
|
|
1522
|
+
}
|
|
1523
|
+
io.out("The invariants compile.");
|
|
1524
|
+
if (!flags.includes("--yes")) {
|
|
1525
|
+
io.out("Fuzz the contract against them in the airgapped sandbox (never touches mainnet).");
|
|
1526
|
+
io.out(RUN_NOTE);
|
|
1527
|
+
io.out(`Run again with --yes to start: sentinel fuzz ${planId} ${file} --yes`);
|
|
1528
|
+
return 0;
|
|
1529
|
+
}
|
|
1530
|
+
const r = await io.client.fuzz(planId, harness);
|
|
1531
|
+
io.out(`Started: ${r.planId}`);
|
|
1532
|
+
return 0;
|
|
1533
|
+
}
|
|
1534
|
+
async function findingTitle(io, planId, n, usage) {
|
|
1535
|
+
if (n === undefined || !/^\d+$/.test(n))
|
|
1536
|
+
return usage("It needs a plan id and a finding number");
|
|
1537
|
+
return (await io.client.finding(planId, Number(n))).title;
|
|
1538
|
+
}
|
|
1539
|
+
async function upkeep(cmd, pos, flags, values, io, usage) {
|
|
1540
|
+
const [planId, second, third] = pos;
|
|
1541
|
+
if (!planId)
|
|
1542
|
+
return usage(`sentinel ${cmd} needs a plan id`);
|
|
1543
|
+
const yes = flags.includes("--yes");
|
|
1544
|
+
switch (cmd) {
|
|
1545
|
+
case "latest": {
|
|
1546
|
+
const l = await io.client.latestCommit(planId);
|
|
1547
|
+
if (l.status === "unsupported")
|
|
1548
|
+
io.out("Only a plan made from a GitHub link follows newer commits.");
|
|
1549
|
+
else if (l.status === "current")
|
|
1550
|
+
io.out(`On the latest commit of ${l.ref}: ${short(l.commit)}.`);
|
|
1551
|
+
else if (l.status === "gone")
|
|
1552
|
+
io.out(`The file isn't on ${l.ref} anymore (head ${short(l.head)}).`);
|
|
1553
|
+
else {
|
|
1554
|
+
io.out(`${l.ahead} commit(s) behind ${l.ref}: ${short(l.commit)} → ${short(l.head)}. An update would be ${l.mode}${l.affected.length ? ` (${l.affected.join(", ")})` : ""}.`);
|
|
1555
|
+
io.out(l.free ? "Free: monitoring is on." : "Priced as a new plan: monitoring is off.");
|
|
1556
|
+
io.out(`Plan it with: sentinel update ${planId} --yes`);
|
|
1557
|
+
}
|
|
1558
|
+
return 0;
|
|
1559
|
+
}
|
|
1560
|
+
case "update": {
|
|
1561
|
+
const commit = values["--commit"];
|
|
1562
|
+
if (commit !== undefined && !SHA.test(commit))
|
|
1563
|
+
return usage("--commit takes a full 40-character sha");
|
|
1564
|
+
const willDo = `Plan ${commit ? `commit ${short(commit)}` : "the branch's latest commit"}; the new plan replaces this one when it finishes.`;
|
|
1565
|
+
if (previewOnly(io, flags, willDo, "Free while monitoring is on, else a paid plan."))
|
|
1566
|
+
return 0;
|
|
1567
|
+
const r = await io.client.updatePlan(planId, commit);
|
|
1568
|
+
io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}${r.mode ? ` (${r.mode})` : ""}`);
|
|
1569
|
+
return 0;
|
|
1570
|
+
}
|
|
1571
|
+
case "auto-plan": {
|
|
1572
|
+
const on = onOff(second);
|
|
1573
|
+
if (on === null)
|
|
1574
|
+
return usage("sentinel auto-plan takes on or off");
|
|
1575
|
+
const willDo = on ? "Plan this on its own once every contract it waits on is deployed (a paid plan)." : "Don't plan this on its own when its contracts deploy.";
|
|
1576
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
1577
|
+
return 0;
|
|
1578
|
+
await io.client.setAutoPlan(planId, on);
|
|
1579
|
+
io.out(`Auto-plan is ${on ? "on" : "off"}.`);
|
|
1580
|
+
return 0;
|
|
1581
|
+
}
|
|
1582
|
+
case "link": {
|
|
1583
|
+
const clear = flags.includes("--clear");
|
|
1584
|
+
if (!second || clear === Boolean(third))
|
|
1585
|
+
return usage("sentinel link needs a plan id, the contract it waits on, and a link or --clear");
|
|
1586
|
+
const willDo = clear ? `Clear the code linked for ${second}.` : `Link ${third} as the code of ${second} (a repo link also fills the others it holds).`;
|
|
1587
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
1588
|
+
return 0;
|
|
1589
|
+
const r = await io.client.setLink(planId, second, clear ? null : third);
|
|
1590
|
+
io.out(`Linked: ${r.links.map((l) => l.id).join(", ") || "nothing"}`);
|
|
1591
|
+
return 0;
|
|
1592
|
+
}
|
|
1593
|
+
case "plan-linked": {
|
|
1594
|
+
if (!yes) {
|
|
1595
|
+
io.out("Plan this contract now, with the linked code loaded at its real principals.");
|
|
1596
|
+
io.out(RUN_NOTE);
|
|
1597
|
+
io.out(`Run again with --yes to start: sentinel plan-linked ${planId} --yes`);
|
|
1598
|
+
return 0;
|
|
1599
|
+
}
|
|
1600
|
+
const r = await io.client.planLinked(planId);
|
|
1601
|
+
io.out(`Started: ${r.planId}`);
|
|
1602
|
+
return 0;
|
|
1603
|
+
}
|
|
1604
|
+
case "name": {
|
|
1605
|
+
if (second === undefined)
|
|
1606
|
+
return usage('sentinel name needs a plan id and a name ("" clears it)');
|
|
1607
|
+
const willDo = second.trim() ? `Call this contract "${second.trim()}".` : "Clear your name for this contract.";
|
|
1608
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
1609
|
+
return 0;
|
|
1610
|
+
const r = await io.client.renameContract(planId, second);
|
|
1611
|
+
io.out(r.name ? `Named: ${r.name}` : "Name cleared.");
|
|
1612
|
+
return 0;
|
|
1613
|
+
}
|
|
1614
|
+
case "remove": {
|
|
1615
|
+
const p = await io.client.plan(planId);
|
|
1616
|
+
const name = contractName(p.contractId);
|
|
1617
|
+
const confirm = values["--confirm"];
|
|
1618
|
+
if (confirm === undefined)
|
|
1619
|
+
return usage(`sentinel remove needs --confirm ${name} (the contract's name, typed)`);
|
|
1620
|
+
const willDo = `Take ${p.contractId} off your account: its monitoring, alerts and claim go.`;
|
|
1621
|
+
if (previewOnly(io, flags, willDo, "Can't be undone."))
|
|
1622
|
+
return 0;
|
|
1623
|
+
const r = await io.client.removeContract(planId, confirm);
|
|
1624
|
+
io.out(`Removed ${p.contractId}${r.monitoringOff ? "; monitoring is off" : ""}.`);
|
|
1625
|
+
return 0;
|
|
1626
|
+
}
|
|
1627
|
+
case "ack":
|
|
1628
|
+
case "reopen": {
|
|
1629
|
+
const title = await findingTitle(io, planId, second, usage);
|
|
1630
|
+
if (typeof title === "number")
|
|
1631
|
+
return title;
|
|
1632
|
+
if (cmd === "reopen") {
|
|
1633
|
+
if (previewOnly(io, flags, `Put "${title}" back on the list.`, "Takes effect now."))
|
|
1634
|
+
return 0;
|
|
1635
|
+
await io.client.reopen(planId, Number(second), title);
|
|
1636
|
+
io.out(`Reopened: ${title}`);
|
|
1637
|
+
return 0;
|
|
1638
|
+
}
|
|
1639
|
+
if (third !== "accepted" && third !== "fixed" && third !== "disagree")
|
|
1640
|
+
return usage("sentinel ack takes a reason: accepted, fixed or disagree");
|
|
1641
|
+
const note = values["--note"] ?? null;
|
|
1642
|
+
const willDo = `Label "${title}" ${third}${note ? `, noting: ${note}` : ""}. It changes no verdict.`;
|
|
1643
|
+
if (previewOnly(io, flags, willDo, "Your team sees it now."))
|
|
1644
|
+
return 0;
|
|
1645
|
+
const r = await io.client.acknowledge(planId, Number(second), title, third, note);
|
|
1646
|
+
io.out(`Labelled ${third}: ${title}`);
|
|
1647
|
+
if (r.watchId)
|
|
1648
|
+
io.out(`A watch on it is suggested (${r.watchId}): turn it on to keep watching.`);
|
|
1649
|
+
return 0;
|
|
1650
|
+
}
|
|
1651
|
+
case "acks": {
|
|
1652
|
+
const events = await io.client.ackHistory(planId);
|
|
1653
|
+
if (events.length === 0)
|
|
1654
|
+
io.out("No acknowledgements yet.");
|
|
1655
|
+
for (const l of table(events.map((e) => [e.at, e.action, e.reason, e.by ?? "Sentinel", e.title, e.note])))
|
|
1656
|
+
io.out(l);
|
|
1657
|
+
return 0;
|
|
1658
|
+
}
|
|
1659
|
+
case "notes": {
|
|
1660
|
+
if (second === undefined || !/^\d+$/.test(second))
|
|
1661
|
+
return usage("sentinel notes needs a plan id and a finding number");
|
|
1662
|
+
const notes = await io.client.notes(planId, Number(second));
|
|
1663
|
+
if (notes.length === 0)
|
|
1664
|
+
io.out("No notes yet.");
|
|
1665
|
+
for (const n of notes)
|
|
1666
|
+
io.out(`${n.at} ${n.system ? "Sentinel" : n.by ?? ""}
|
|
1667
|
+
${n.body}`);
|
|
1668
|
+
return 0;
|
|
1669
|
+
}
|
|
1670
|
+
default: {
|
|
1671
|
+
const title = await findingTitle(io, planId, second, usage);
|
|
1672
|
+
if (typeof title === "number")
|
|
1673
|
+
return title;
|
|
1674
|
+
if (!third?.trim())
|
|
1675
|
+
return usage("sentinel note needs the note, in quotes");
|
|
1676
|
+
if (previewOnly(io, flags, `Add a note to "${title}": ${third.trim()}`, "Your team sees it now."))
|
|
1677
|
+
return 0;
|
|
1678
|
+
await io.client.addNote(planId, Number(second), title, third);
|
|
1679
|
+
io.out("Noted.");
|
|
1680
|
+
return 0;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
function pickIntent(intents, name) {
|
|
1685
|
+
const q = name.trim().toLowerCase();
|
|
1686
|
+
const hit = intents.find((i) => i.id === name) ?? intents.find((i) => i.title.toLowerCase() === q);
|
|
1687
|
+
if (hit)
|
|
1688
|
+
return hit;
|
|
1689
|
+
throw new SubjectError(`No watch called "${name}". Watches: ${intents.map((i) => i.title).join(", ") || "none yet"}`, intents.map((i) => i.title));
|
|
1690
|
+
}
|
|
1691
|
+
function windowOf(values) {
|
|
1692
|
+
const w = values["--window"];
|
|
1693
|
+
if (w === undefined)
|
|
1694
|
+
return;
|
|
1695
|
+
return w === "7" ? 7 : w === "90" ? 90 : null;
|
|
1696
|
+
}
|
|
1697
|
+
function printActivity(io, title, a) {
|
|
1698
|
+
io.out(`${title}: ${a.counts.total} event(s), ${a.counts.over} over a rule's line, last ${a.window} days${a.history === "ready" ? "" : ` (history ${a.history})`}`);
|
|
1699
|
+
for (const l of table(a.events.map((e) => [
|
|
1700
|
+
e.t,
|
|
1701
|
+
e.fn,
|
|
1702
|
+
e.value !== null && a.unit ? `${toUnits(e.value, a.unit.decimals)} ${a.unit.symbol}` : e.value,
|
|
1703
|
+
e.caller,
|
|
1704
|
+
e.result
|
|
1705
|
+
])))
|
|
1706
|
+
io.out(l);
|
|
1707
|
+
}
|
|
1708
|
+
async function monitoringMore(cmd, pos, flags, values, io, usage) {
|
|
1709
|
+
const [first, second, third] = pos;
|
|
1710
|
+
const json = flags.includes("--json");
|
|
1711
|
+
if (cmd === "mark-read") {
|
|
1712
|
+
const all = flags.includes("--all");
|
|
1713
|
+
if (all === Boolean(first))
|
|
1714
|
+
return usage("sentinel mark-read needs an alert id or --all");
|
|
1715
|
+
if (previewOnly(io, flags, all ? "Mark every alert read." : `Mark alert ${first} read.`, ""))
|
|
1716
|
+
return 0;
|
|
1717
|
+
if (all)
|
|
1718
|
+
io.out(`Marked ${(await io.client.readAllAlerts()).count} read.`);
|
|
1719
|
+
else {
|
|
1720
|
+
await io.client.readAlert(first);
|
|
1721
|
+
io.out("Marked read.");
|
|
1722
|
+
}
|
|
1723
|
+
return 0;
|
|
1724
|
+
}
|
|
1725
|
+
if (!first)
|
|
1726
|
+
return usage(`sentinel ${cmd} needs a plan id`);
|
|
1727
|
+
const planId = first;
|
|
1728
|
+
switch (cmd) {
|
|
1729
|
+
case "activity": {
|
|
1730
|
+
if (!second)
|
|
1731
|
+
return usage("sentinel activity needs a plan id and a watch (its title or id)");
|
|
1732
|
+
const window = windowOf(values);
|
|
1733
|
+
if (window === null)
|
|
1734
|
+
return usage("--window takes 7 or 90");
|
|
1735
|
+
const intent = pickIntent((await io.client.watches(planId)).intents, second);
|
|
1736
|
+
const a = await io.client.watchActivity(planId, intent.id, { window });
|
|
1737
|
+
if (json)
|
|
1738
|
+
io.out(JSON.stringify(a, null, 2));
|
|
1739
|
+
else
|
|
1740
|
+
printActivity(io, intent.title, a);
|
|
1741
|
+
return 0;
|
|
1742
|
+
}
|
|
1743
|
+
case "functions": {
|
|
1744
|
+
if (second) {
|
|
1745
|
+
const r = await io.client.functionActivity(planId, second);
|
|
1746
|
+
if (json)
|
|
1747
|
+
io.out(JSON.stringify(r, null, 2));
|
|
1748
|
+
else if (!r.collected)
|
|
1749
|
+
io.out(`Sentinel is still reading ${second}'s history. Try again in a few minutes.`);
|
|
1750
|
+
else
|
|
1751
|
+
printActivity(io, `${second} (if watched)`, r.activity);
|
|
1752
|
+
return 0;
|
|
1753
|
+
}
|
|
1754
|
+
const fns = await io.client.functions(planId);
|
|
1755
|
+
if (json)
|
|
1756
|
+
io.out(JSON.stringify(fns, null, 2));
|
|
1757
|
+
else
|
|
1758
|
+
for (const l of table([
|
|
1759
|
+
["FUNCTION", "DOES", "WATCH", "DESCRIPTION"],
|
|
1760
|
+
...fns.map((f) => [f.fn, f.consequence, f.intentId ?? "not watched", f.description])
|
|
1761
|
+
]))
|
|
1762
|
+
io.out(l);
|
|
1763
|
+
return 0;
|
|
1764
|
+
}
|
|
1765
|
+
case "alert-preview": {
|
|
1766
|
+
const example = values["--example"];
|
|
1767
|
+
if (example !== undefined && example !== "money_out" && example !== "finding")
|
|
1768
|
+
return usage("--example takes money_out or finding");
|
|
1769
|
+
const p = await io.client.alertPreview(planId, example);
|
|
1770
|
+
if (json)
|
|
1771
|
+
io.out(JSON.stringify(p, null, 2));
|
|
1772
|
+
else
|
|
1773
|
+
io.out(`${p.message.subject}
|
|
1774
|
+
|
|
1775
|
+
${p.message.text}`);
|
|
1776
|
+
return 0;
|
|
1777
|
+
}
|
|
1778
|
+
case "threshold": {
|
|
1779
|
+
if (!second)
|
|
1780
|
+
return usage("sentinel threshold needs a plan id and an asset (its symbol)");
|
|
1781
|
+
const ctx = await watchContext(io.client, planId);
|
|
1782
|
+
const g = resolveSubject(ctx.watches.filter((w) => w.subject.kind === "asset"), second);
|
|
1783
|
+
const subject = g.watches[0].subject;
|
|
1784
|
+
if (subject.kind !== "asset")
|
|
1785
|
+
return usage(`${second} isn't an asset`);
|
|
1786
|
+
const units = g.watches[0].units;
|
|
1787
|
+
const inUnits = (base) => units ? `${toUnits(base, units.decimals)} ${units.symbol}` : `${base} base units`;
|
|
1788
|
+
const { threshold } = await io.client.threshold(planId, subject.asset);
|
|
1789
|
+
io.out(`${g.name}: ${threshold.amount === null ? "every outflow alerts" : `alerts over ${inUnits(threshold.amount)}`}${threshold.confirmed ? "" : " (not confirmed yet)"}`);
|
|
1790
|
+
if (third === undefined)
|
|
1791
|
+
return 0;
|
|
1792
|
+
if (!/^\d{1,30}$/.test(third))
|
|
1793
|
+
return usage("The amount is in base units, digits only");
|
|
1794
|
+
if (previewOnly(io, flags, `Alert on ${g.name} outflows over ${inUnits(third)}.`, "Live now."))
|
|
1795
|
+
return 0;
|
|
1796
|
+
await io.client.setThreshold(planId, subject.asset, third);
|
|
1797
|
+
io.out(`${g.name} alerts over ${inUnits(third)}.`);
|
|
1798
|
+
return 0;
|
|
1799
|
+
}
|
|
1800
|
+
default: {
|
|
1801
|
+
if (previewOnly(io, flags, "Read the history of this plan's watches again.", "Starts now."))
|
|
1802
|
+
return 0;
|
|
1803
|
+
await io.client.retryHistory(planId);
|
|
1804
|
+
io.out("Reading again; activity fills in over the next few minutes.");
|
|
1805
|
+
return 0;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
async function projects(pos, flags, values, io, usage) {
|
|
1810
|
+
const [sub, id, second, third] = pos;
|
|
1811
|
+
const json = flags.includes("--json");
|
|
1812
|
+
if (!sub) {
|
|
1813
|
+
const r = await io.client.projects();
|
|
1814
|
+
if (json) {
|
|
1815
|
+
io.out(JSON.stringify(r, null, 2));
|
|
1816
|
+
return 0;
|
|
1817
|
+
}
|
|
1818
|
+
for (const l of table([
|
|
1819
|
+
["PROJECT", "CONTRACTS", "MONITORING", "FINDINGS", "ALERTS 7D", "ID"],
|
|
1820
|
+
...r.projects.map((p) => [
|
|
1821
|
+
p.name,
|
|
1822
|
+
p.contracts.length,
|
|
1823
|
+
p.status,
|
|
1824
|
+
p.findings,
|
|
1825
|
+
p.alerts7d,
|
|
1826
|
+
p.id
|
|
1827
|
+
])
|
|
1828
|
+
]))
|
|
1829
|
+
io.out(l);
|
|
1830
|
+
const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
|
|
1831
|
+
for (const s of r.suggestions)
|
|
1832
|
+
io.out(`Suggested: merge ${nameOf(s.from)} into ${nameOf(s.into)} (${s.evidence.kind}). Answer with: sentinel projects suggestion ${s.from} ${s.into} accept|dismiss`);
|
|
1833
|
+
return 0;
|
|
1834
|
+
}
|
|
1835
|
+
const subs = [
|
|
1836
|
+
"show",
|
|
1837
|
+
"overview",
|
|
1838
|
+
"add",
|
|
1839
|
+
"rename",
|
|
1840
|
+
"merge",
|
|
1841
|
+
"suggestion",
|
|
1842
|
+
"move",
|
|
1843
|
+
"remove-contract",
|
|
1844
|
+
"remove",
|
|
1845
|
+
"watches",
|
|
1846
|
+
"activity",
|
|
1847
|
+
"destinations",
|
|
1848
|
+
"monitoring"
|
|
1849
|
+
];
|
|
1850
|
+
if (!subs.includes(sub))
|
|
1851
|
+
return usage(`Unknown command projects ${sub}`);
|
|
1852
|
+
if (!id)
|
|
1853
|
+
return usage(`sentinel projects ${sub} needs a project id (sentinel projects lists them)`);
|
|
1854
|
+
const projectNamed = async (pid) => {
|
|
1855
|
+
const { projects: all } = await io.client.projects();
|
|
1856
|
+
const p = all.find((x) => x.id === pid);
|
|
1857
|
+
if (!p)
|
|
1858
|
+
throw new SentinelError(404, `No project ${pid}. Yours: ${all.map((x) => `${x.name} (${x.id})`).join(", ") || "none"}`);
|
|
1859
|
+
return p;
|
|
1860
|
+
};
|
|
1861
|
+
switch (sub) {
|
|
1862
|
+
case "show": {
|
|
1863
|
+
const p = await io.client.project(id);
|
|
1864
|
+
if (json) {
|
|
1865
|
+
io.out(JSON.stringify(p, null, 2));
|
|
1866
|
+
return 0;
|
|
1867
|
+
}
|
|
1868
|
+
io.out(`${p.name} ${p.status} findings ${p.findings} alerts 7d ${p.alerts7d}`);
|
|
1869
|
+
for (const l of table([
|
|
1870
|
+
["CONTRACT", "HOW", "ROLE", "PLAN"],
|
|
1871
|
+
...p.contracts.map((c) => [c.contractId, c.origin, c.role, c.planId])
|
|
1872
|
+
]))
|
|
1873
|
+
io.out(l);
|
|
1874
|
+
return 0;
|
|
1875
|
+
}
|
|
1876
|
+
case "overview":
|
|
1877
|
+
io.out(JSON.stringify(await io.client.projectOverview(id, { watches: flags.includes("--watches") }), null, 2));
|
|
1878
|
+
return 0;
|
|
1879
|
+
case "add": {
|
|
1880
|
+
if (!second)
|
|
1881
|
+
return usage("sentinel projects add needs a project id and a contract (SP….name or a GitHub link)");
|
|
1882
|
+
const into = values["--into"] ?? id;
|
|
1883
|
+
const p = await io.client.previewProjectContract(id, second);
|
|
1884
|
+
const s = p.suggestion;
|
|
1885
|
+
const target = into === "new" ? "a new project" : into;
|
|
1886
|
+
io.out(`${p.contractId} goes into ${target}.`);
|
|
1887
|
+
if (s.project !== into && s.kind !== "none")
|
|
1888
|
+
io.out(`Sentinel suggests ${s.project === "new" ? "a new project" : s.project} (${s.kind}${s.with ? ` via ${s.with}` : ""}): add --into ${s.project} to put it there.`);
|
|
1889
|
+
const b = await io.client.billing();
|
|
1890
|
+
io.out(b.prices ? `A new plan costs ${usd(b.prices.run)}, charged only if it finishes.` : "Billing isn't linked: the free daily plan limit applies.");
|
|
1891
|
+
if (!flags.includes("--yes")) {
|
|
1892
|
+
io.out(`Run again with --yes to add it: sentinel projects add ${id} ${second}${values["--into"] ? ` --into ${into}` : ""} --yes`);
|
|
1893
|
+
return 0;
|
|
1894
|
+
}
|
|
1895
|
+
const r = await io.client.addProjectContract(id, second, into === id ? {} : { project: into });
|
|
1896
|
+
io.out(`${r.cached ? "Already planned" : "Started"}: ${r.planId} in project ${r.projectId}`);
|
|
1897
|
+
return 0;
|
|
1898
|
+
}
|
|
1899
|
+
case "rename": {
|
|
1900
|
+
if (!second?.trim())
|
|
1901
|
+
return usage("sentinel projects rename needs a project id and a name");
|
|
1902
|
+
if (previewOnly(io, flags, `Rename ${id} to "${second.trim()}".`, "Takes effect now."))
|
|
1903
|
+
return 0;
|
|
1904
|
+
io.out(`Renamed: ${(await io.client.renameProject(id, second)).name}`);
|
|
1905
|
+
return 0;
|
|
1906
|
+
}
|
|
1907
|
+
case "merge": {
|
|
1908
|
+
if (!second)
|
|
1909
|
+
return usage("sentinel projects merge needs a project id and the project to merge it into");
|
|
1910
|
+
const [from, to] = [await projectNamed(id), await projectNamed(second)];
|
|
1911
|
+
const willDo = `Move every contract of ${from.name} (${from.contracts.length}) into ${to.name}; ${from.name} goes. Monitoring, watches and verification stay as they are.`;
|
|
1912
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
1913
|
+
return 0;
|
|
1914
|
+
const r = await io.client.mergeProject(id, second);
|
|
1915
|
+
io.out(`Merged ${r.contracts} contract(s) into ${to.name}.`);
|
|
1916
|
+
return 0;
|
|
1917
|
+
}
|
|
1918
|
+
case "suggestion": {
|
|
1919
|
+
const accept = third === "accept" ? true : third === "dismiss" ? false : null;
|
|
1920
|
+
if (!second || accept === null)
|
|
1921
|
+
return usage("sentinel projects suggestion needs <from> <into> accept|dismiss");
|
|
1922
|
+
const r = await io.client.projects();
|
|
1923
|
+
if (!r.suggestions.some((s) => s.from === id && s.into === second))
|
|
1924
|
+
throw new SentinelError(404, `No open suggestion to merge ${id} into ${second}.`);
|
|
1925
|
+
const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
|
|
1926
|
+
const willDo = accept ? `Merge ${nameOf(id)} into ${nameOf(second)}: every contract moves.` : `Dismiss the suggestion to merge ${nameOf(id)} into ${nameOf(second)}.`;
|
|
1927
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
1928
|
+
return 0;
|
|
1929
|
+
await io.client.answerMergeSuggestion(id, second, accept);
|
|
1930
|
+
io.out(accept ? "Merged." : "Dismissed.");
|
|
1931
|
+
return 0;
|
|
1932
|
+
}
|
|
1933
|
+
case "move": {
|
|
1934
|
+
if (!second || !third)
|
|
1935
|
+
return usage("sentinel projects move needs a project id, a contract and where to (a project id or new)");
|
|
1936
|
+
const to = third === "new" ? "a new project named after it" : (await projectNamed(third)).name;
|
|
1937
|
+
if (previewOnly(io, flags, `Move ${second} to ${to}.`, "Takes effect now."))
|
|
1938
|
+
return 0;
|
|
1939
|
+
const r = await io.client.moveProjectContract(id, second, third);
|
|
1940
|
+
io.out(`Moved to ${r.projectId}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
|
|
1941
|
+
return 0;
|
|
1942
|
+
}
|
|
1943
|
+
case "remove-contract": {
|
|
1944
|
+
if (!second)
|
|
1945
|
+
return usage("sentinel projects remove-contract needs a project id and a contract");
|
|
1946
|
+
const member = (await io.client.project(id)).contracts.find((c) => c.contractId === second);
|
|
1947
|
+
if (!member)
|
|
1948
|
+
throw new SentinelError(404, `${second} isn't in project ${id}.`);
|
|
1949
|
+
const added = member.origin === "added";
|
|
1950
|
+
const confirm = values["--confirm"];
|
|
1951
|
+
if (added && confirm === undefined)
|
|
1952
|
+
return usage(`${second} is one you added: removing it takes its monitoring, alerts and claim. Type its name: --confirm ${contractName(second)}`);
|
|
1953
|
+
const willDo = added ? `Remove ${second}: its monitoring, alerts and claim go.` : `Take ${second} out of the project (Sentinel found it; you never claimed it).`;
|
|
1954
|
+
if (previewOnly(io, flags, willDo, added ? "Can't be undone." : ""))
|
|
1955
|
+
return 0;
|
|
1956
|
+
const r = await io.client.removeProjectContract(id, second, added ? confirm : undefined);
|
|
1957
|
+
io.out(`Removed ${second}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
|
|
1958
|
+
return 0;
|
|
1959
|
+
}
|
|
1960
|
+
case "remove": {
|
|
1961
|
+
const p = await io.client.project(id);
|
|
1962
|
+
const confirm = values["--confirm"];
|
|
1963
|
+
if (confirm === undefined)
|
|
1964
|
+
return usage(`sentinel projects remove needs --confirm "${p.name}" (the project's name, typed)`);
|
|
1965
|
+
const added = p.contracts.filter((c) => c.origin === "added").length;
|
|
1966
|
+
const willDo = `Remove ${p.name} and the ${added} contract(s) you added in it: their monitoring, alerts and claims go.`;
|
|
1967
|
+
if (previewOnly(io, flags, willDo, "Can't be undone."))
|
|
1968
|
+
return 0;
|
|
1969
|
+
const r = await io.client.removeProject(id, confirm);
|
|
1970
|
+
io.out(`Removed ${p.name} (${r.contracts} contract(s)).`);
|
|
1971
|
+
return 0;
|
|
1972
|
+
}
|
|
1973
|
+
case "watches": {
|
|
1974
|
+
const w = await io.client.projectWatches(id);
|
|
1975
|
+
if (json) {
|
|
1976
|
+
io.out(JSON.stringify(w, null, 2));
|
|
1977
|
+
return 0;
|
|
1978
|
+
}
|
|
1979
|
+
io.out(`${w.counts.on} of ${w.counts.total} on`);
|
|
1980
|
+
for (const i of w.intents)
|
|
1981
|
+
io.out([i.title, i.on ? "on" : "off", i.summary].join(" "));
|
|
1982
|
+
return 0;
|
|
1983
|
+
}
|
|
1984
|
+
case "activity": {
|
|
1985
|
+
if (!second)
|
|
1986
|
+
return usage("sentinel projects activity needs a project id and a watch (its title or id)");
|
|
1987
|
+
const window = windowOf(values);
|
|
1988
|
+
if (window === null)
|
|
1989
|
+
return usage("--window takes 7 or 90");
|
|
1990
|
+
const intent = pickIntent((await io.client.projectWatches(id)).intents, second);
|
|
1991
|
+
const a = await io.client.projectActivity(id, intent.id, {
|
|
1992
|
+
window,
|
|
1993
|
+
contract: values["--contract"]
|
|
1994
|
+
});
|
|
1995
|
+
if (json)
|
|
1996
|
+
io.out(JSON.stringify(a, null, 2));
|
|
1997
|
+
else
|
|
1998
|
+
printActivity(io, intent.title, a);
|
|
1999
|
+
return 0;
|
|
2000
|
+
}
|
|
2001
|
+
case "destinations": {
|
|
2002
|
+
const email = values["--email"];
|
|
2003
|
+
if (email !== undefined && onOff(email) === null)
|
|
2004
|
+
return usage("--email takes on or off");
|
|
2005
|
+
if (email === undefined && !("--webhook" in values) && !("--slack" in values))
|
|
2006
|
+
return usage("sentinel projects destinations needs --email, --webhook or --slack");
|
|
2007
|
+
const dest = {
|
|
2008
|
+
...email === undefined ? {} : { email: email === "on" },
|
|
2009
|
+
...values["--webhook"] ? { webhookUrl: values["--webhook"] } : {},
|
|
2010
|
+
...values["--slack"] ? { slackUrl: values["--slack"] } : {}
|
|
2011
|
+
};
|
|
2012
|
+
const places = [
|
|
2013
|
+
dest.email === true ? "email" : null,
|
|
2014
|
+
dest.webhookUrl ? "webhook" : null,
|
|
2015
|
+
dest.slackUrl ? "Slack" : null
|
|
2016
|
+
].filter(Boolean).join(", ") || "nowhere new";
|
|
2017
|
+
const willDo = `Alerts for every contract you added in ${id} go to: ${places}. A webhook or Slack URL left out is cleared.`;
|
|
2018
|
+
if (previewOnly(io, flags, willDo, "Takes effect now."))
|
|
2019
|
+
return 0;
|
|
2020
|
+
const r = await io.client.setProjectDestinations(id, dest);
|
|
2021
|
+
for (const c of r.contracts) {
|
|
2022
|
+
io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
|
|
2023
|
+
if (c.signingSecret)
|
|
2024
|
+
io.out(` webhook signing secret (shown once): ${c.signingSecret}`);
|
|
2025
|
+
}
|
|
2026
|
+
return 0;
|
|
2027
|
+
}
|
|
2028
|
+
default: {
|
|
2029
|
+
const on = onOff(second);
|
|
2030
|
+
if (on === null)
|
|
2031
|
+
return usage("sentinel projects monitoring takes on or off");
|
|
2032
|
+
const willDo = `Turn monitoring ${on ? "on" : "off"} for every contract you added in ${id}.`;
|
|
2033
|
+
if (previewOnly(io, flags, willDo, on ? "Live now; events are billed." : "Off now."))
|
|
2034
|
+
return 0;
|
|
2035
|
+
const r = await io.client.setProjectMonitoring(id, on);
|
|
2036
|
+
for (const c of r.contracts)
|
|
2037
|
+
io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
|
|
2038
|
+
io.out(`Project monitoring: ${r.status}`);
|
|
2039
|
+
return 0;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
1279
2043
|
|
|
1280
2044
|
// cli/main.ts
|
|
1281
2045
|
function takeToken(argv) {
|