lockpicks 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/dist/lockpick.mjs +1564 -151
  3. package/package.json +13 -12
package/dist/lockpick.mjs CHANGED
@@ -6683,8 +6683,8 @@ var require_formats = __commonJS({
6683
6683
  return false;
6684
6684
  const year = +matches[1];
6685
6685
  const month = +matches[2];
6686
- const day = +matches[3];
6687
- return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
6686
+ const day2 = +matches[3];
6687
+ return month >= 1 && month <= 12 && day2 >= 1 && day2 <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
6688
6688
  }
6689
6689
  function compareDate(d1, d2) {
6690
6690
  if (!(d1 && d2))
@@ -7386,7 +7386,8 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7386
7386
 
7387
7387
  // src/commands.ts
7388
7388
  import { randomUUID as randomUUID6 } from "node:crypto";
7389
- import { readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
7389
+ import { access as access2, mkdir as mkdir4, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
7390
+ import { dirname as dirname5 } from "node:path";
7390
7391
  import { hostname as hostname3 } from "node:os";
7391
7392
 
7392
7393
  // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.1_zod@4.4.3/node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
@@ -11497,12 +11498,12 @@ var $ZodPipe = /* @__PURE__ */ $constructor("$ZodPipe", (inst, def) => {
11497
11498
  return handlePipeResult(left, def.out, ctx);
11498
11499
  };
11499
11500
  });
11500
- function handlePipeResult(left, next, ctx) {
11501
+ function handlePipeResult(left, next2, ctx) {
11501
11502
  if (left.issues.length) {
11502
11503
  left.aborted = true;
11503
11504
  return left;
11504
11505
  }
11505
- return next._zod.run({ value: left.value, issues: left.issues, fallback: left.fallback }, ctx);
11506
+ return next2._zod.run({ value: left.value, issues: left.issues, fallback: left.fallback }, ctx);
11506
11507
  }
11507
11508
  var $ZodCodec = /* @__PURE__ */ $constructor("$ZodCodec", (inst, def) => {
11508
11509
  $ZodType.init(inst, def);
@@ -23580,6 +23581,74 @@ function readBriefCommand(agent2, file2 = FIX_ALL_FILE) {
23580
23581
  );
23581
23582
  }
23582
23583
 
23584
+ // ../../packages/contracts/src/run-outcome.ts
23585
+ var RUN_OUTCOME_LEVELS = [
23586
+ "full",
23587
+ "headers-only",
23588
+ "blocked",
23589
+ "transport",
23590
+ "unreachable",
23591
+ "terminal-failure"
23592
+ ];
23593
+ var runOutcomeLevelSchema = external_exports.enum(RUN_OUTCOME_LEVELS);
23594
+ var RUN_OUTCOME_COVERAGE = [
23595
+ "page",
23596
+ "headers-and-records",
23597
+ "records-only",
23598
+ "none"
23599
+ ];
23600
+ var runOutcomeCoverageSchema = external_exports.enum(RUN_OUTCOME_COVERAGE);
23601
+ var RUN_CHALLENGE_VENDORS = ["cloudflare", "vercel", "other"];
23602
+ var runChallengeVendorSchema = external_exports.enum(RUN_CHALLENGE_VENDORS);
23603
+ var RUN_OUTCOME_REASONS = [
23604
+ "no-address",
23605
+ "dns-error",
23606
+ "private-address",
23607
+ "refused",
23608
+ "no-answer",
23609
+ "connection-closed",
23610
+ "certificate",
23611
+ "old-tls",
23612
+ "large-headers",
23613
+ "invalid-response",
23614
+ "www",
23615
+ "other-site",
23616
+ "sign-in",
23617
+ "redirect-loop",
23618
+ "redirect",
23619
+ "bot-check",
23620
+ "denied",
23621
+ "rate-limited",
23622
+ "not-found",
23623
+ "gone",
23624
+ "client-error",
23625
+ "server-error",
23626
+ "maintenance",
23627
+ "parked",
23628
+ "empty",
23629
+ "not-html",
23630
+ "app-shell",
23631
+ "interrupted",
23632
+ "not-delivered"
23633
+ ];
23634
+ var runOutcomeReasonSchema = external_exports.enum(RUN_OUTCOME_REASONS);
23635
+ var publicRunOutcomeSchema = external_exports.object({
23636
+ level: runOutcomeLevelSchema,
23637
+ coverage: runOutcomeCoverageSchema,
23638
+ reason: runOutcomeReasonSchema.optional(),
23639
+ vendor: runChallengeVendorSchema.optional(),
23640
+ /** HTTP status the homepage answered with, when one arrived. */
23641
+ homepageStatus: external_exports.number().int().min(100).max(599).optional(),
23642
+ /** Where a www-twin or cross-site redirect pointed, to check instead. */
23643
+ redirectOrigin: external_exports.string().max(300).optional(),
23644
+ /**
23645
+ * Terminal failures only: whether the run had contacted the site before it
23646
+ * stopped. A run is charged at its first contact, so false also means it
23647
+ * did not use one of the account's runs.
23648
+ */
23649
+ contacted: external_exports.boolean().optional()
23650
+ });
23651
+
23583
23652
  // ../../packages/contracts/src/quick-check.ts
23584
23653
  var QUICK_CHECK_IDS = [
23585
23654
  "https",
@@ -23640,6 +23709,7 @@ var QUICK_STACK = [
23640
23709
  ];
23641
23710
  var quickStackSchema = external_exports.enum(QUICK_STACK);
23642
23711
  var QUICK_STACK_MAX = 8;
23712
+ var PULSE_STACK_MAX = 12;
23643
23713
  var QUICK_CHECK_MAX_REQUESTS = 6;
23644
23714
  var quickCheckFailureSchema = external_exports.enum([
23645
23715
  "unreachable",
@@ -23649,7 +23719,9 @@ var quickCheckFailureSchema = external_exports.enum([
23649
23719
  "unsupported-response",
23650
23720
  "timeout",
23651
23721
  "budget",
23652
- "unavailable"
23722
+ "unavailable",
23723
+ /** A bot check answered instead of the homepage (any status, 200 included). */
23724
+ "bot-check"
23653
23725
  ]);
23654
23726
  var severitySchema = external_exports.enum(["high", "medium", "low", "info"]);
23655
23727
  var statusSchema = external_exports.enum(["pass", "attention", "unknown"]);
@@ -23664,6 +23736,8 @@ var quickCheckSummarySchema = external_exports.object({
23664
23736
  outcome: external_exports.enum(["complete", "partial", "failed"]),
23665
23737
  failure: quickCheckFailureSchema.optional(),
23666
23738
  httpStatus: external_exports.int().min(100).max(599).optional(),
23739
+ /** With `bot-check`: whose bot check answered. */
23740
+ challengeVendor: runChallengeVendorSchema.optional(),
23667
23741
  redirectOrigin: external_exports.url().max(300).optional(),
23668
23742
  /** The www twin the homepage redirected to; the checks describe it. */
23669
23743
  finalOrigin: external_exports.url().max(300).optional(),
@@ -23701,6 +23775,8 @@ var quickCheckTldrSchema = external_exports.object({
23701
23775
  status: external_exports.enum(["pending", "complete", "partial", "failed"]),
23702
23776
  failure: quickCheckFailureSchema.optional(),
23703
23777
  httpStatus: external_exports.int().min(100).max(599).optional(),
23778
+ /** With `bot-check`: whose bot check answered. */
23779
+ challengeVendor: runChallengeVendorSchema.optional(),
23704
23780
  redirectOrigin: external_exports.url().max(300).optional(),
23705
23781
  worthFixing: external_exports.int().nonnegative(),
23706
23782
  notes: external_exports.int().nonnegative(),
@@ -23736,6 +23812,25 @@ var quickCheckTldrSchema = external_exports.object({
23736
23812
  expiresAt: external_exports.iso.datetime()
23737
23813
  }).strict();
23738
23814
 
23815
+ // ../../packages/contracts/src/scan-standards.ts
23816
+ var standardsReferenceSchema = external_exports.object({
23817
+ owasp: external_exports.array(external_exports.string().regex(/^A(?:0[1-9]|10):2025$/)).max(10),
23818
+ cwe: external_exports.array(external_exports.string().regex(/^CWE-\d+$/)).max(20),
23819
+ asvs: external_exports.array(external_exports.string().regex(/^v5\.0\.0-\d+\.\d+\.\d+$/)).max(20),
23820
+ wstg: external_exports.array(external_exports.string().regex(/^WSTG-v42-[A-Z]+-\d+$/)).max(20),
23821
+ relationship: external_exports.literal("related-control")
23822
+ }).strict();
23823
+ var standardsSummarySchema = external_exports.object({
23824
+ wstgVersion: external_exports.literal("4.2"),
23825
+ owaspVersion: external_exports.literal("2025"),
23826
+ asvsVersion: external_exports.literal("5.0.0"),
23827
+ mappingVersion: external_exports.literal("1"),
23828
+ levelOneRequirements: external_exports.number().int().nonnegative(),
23829
+ relatedLevelOneRequirements: external_exports.number().int().nonnegative(),
23830
+ verifiedLevelOneRequirements: external_exports.literal(0),
23831
+ unverifiedLevelOneRequirements: external_exports.number().int().nonnegative()
23832
+ }).strict();
23833
+
23739
23834
  // ../../packages/contracts/src/finding-triage.ts
23740
23835
  var FINDING_VERDICTS = [
23741
23836
  "confirmed",
@@ -23774,6 +23869,510 @@ var findingTriageSchema = external_exports.object({
23774
23869
  message: "A not_actionable verdict needs positive defeating evidence."
23775
23870
  });
23776
23871
 
23872
+ // ../../packages/contracts/src/public-pulse.ts
23873
+ var explanationSchema = external_exports.object({
23874
+ title: external_exports.string(),
23875
+ meaning: external_exports.string(),
23876
+ fix: external_exports.string()
23877
+ });
23878
+ var evidenceClassSchema = external_exports.enum([
23879
+ "observed",
23880
+ "suspected",
23881
+ "informational"
23882
+ ]);
23883
+ var publicCoverageAreaSchema = external_exports.enum([
23884
+ "html",
23885
+ "scripts",
23886
+ "maps",
23887
+ "exposure",
23888
+ "cors",
23889
+ "tls",
23890
+ "validation",
23891
+ "components",
23892
+ "security-policy",
23893
+ "domain",
23894
+ "sitemap"
23895
+ ]);
23896
+ var assessmentEvidenceSchema = external_exports.object({
23897
+ version: external_exports.literal("assessment-evidence@1"),
23898
+ observations: external_exports.number().int().nonnegative(),
23899
+ suspected: external_exports.number().int().nonnegative(),
23900
+ informational: external_exports.number().int().nonnegative(),
23901
+ unassessedChecks: external_exports.number().int().nonnegative(),
23902
+ areas: external_exports.array(
23903
+ external_exports.object({
23904
+ area: publicCoverageAreaSchema,
23905
+ state: external_exports.enum(["complete", "limited", "not-run", "unknown"]),
23906
+ reason: external_exports.enum([
23907
+ "sampled",
23908
+ "incomplete",
23909
+ "not-assessed",
23910
+ "historical-unavailable"
23911
+ ])
23912
+ })
23913
+ )
23914
+ });
23915
+ var publicCheckSchema = external_exports.object({
23916
+ standards: standardsReferenceSchema.optional(),
23917
+ id: external_exports.string(),
23918
+ category: external_exports.string(),
23919
+ status: external_exports.enum(["pass", "attention", "unknown"]),
23920
+ severity: external_exports.enum(["medium", "low", "info"]),
23921
+ plain: explanationSchema,
23922
+ technical: explanationSchema,
23923
+ evidence: external_exports.string(),
23924
+ affected: external_exports.array(external_exports.string()),
23925
+ prompt: external_exports.string(),
23926
+ triage: findingTriageSchema.optional()
23927
+ });
23928
+ var publicFindingSchema = external_exports.object({
23929
+ standards: standardsReferenceSchema.optional(),
23930
+ id: external_exports.string(),
23931
+ kind: external_exports.string(),
23932
+ severity: external_exports.enum(["high", "medium", "low", "info"]),
23933
+ confidence: external_exports.enum(["observed", "potential"]),
23934
+ evidenceClass: evidenceClassSchema.optional(),
23935
+ sources: external_exports.array(external_exports.string()),
23936
+ evidence: external_exports.string(),
23937
+ plain: explanationSchema,
23938
+ technical: explanationSchema,
23939
+ prompt: external_exports.string(),
23940
+ validation: external_exports.object({
23941
+ status: external_exports.enum([
23942
+ "repeatable",
23943
+ "not-reproduced",
23944
+ "inconclusive",
23945
+ "not-tested"
23946
+ ]),
23947
+ detail: external_exports.string()
23948
+ }).optional(),
23949
+ triage: findingTriageSchema.optional()
23950
+ });
23951
+ var PULSE_BOOKING_SERVICES = [
23952
+ "calendly",
23953
+ "cal-com",
23954
+ "savvycal",
23955
+ "acuity",
23956
+ "hubspot-meetings",
23957
+ "zoom",
23958
+ "stripe-payment-link"
23959
+ ];
23960
+ var PULSE_COMMUNITY_SERVICES = ["skool"];
23961
+ var PULSE_VISITOR_DATA_SERVICES = [
23962
+ "amplitude",
23963
+ "google-analytics",
23964
+ "hotjar",
23965
+ "mixpanel",
23966
+ "posthog",
23967
+ "segment",
23968
+ "sentry"
23969
+ ];
23970
+ var PULSE_DNS_HOSTS = [
23971
+ "wix",
23972
+ "cloudflare",
23973
+ "godaddy",
23974
+ "namecheap",
23975
+ "route53",
23976
+ "squarespace",
23977
+ "google-cloud-dns",
23978
+ "vercel",
23979
+ "netlify",
23980
+ "hostinger",
23981
+ "ionos",
23982
+ "porkbun",
23983
+ "dnsimple"
23984
+ ];
23985
+ var PULSE_MAIL_PROVIDERS = [
23986
+ "google-workspace",
23987
+ "microsoft-365",
23988
+ "zoho",
23989
+ "proton",
23990
+ "fastmail"
23991
+ ];
23992
+ var PULSE_EMAIL_SENDERS = [
23993
+ "google-workspace",
23994
+ "microsoft-365",
23995
+ "sendgrid",
23996
+ "mailgun",
23997
+ "amazon-ses",
23998
+ "postmark",
23999
+ "mailchimp",
24000
+ "hubspot",
24001
+ "salesforce",
24002
+ "zendesk",
24003
+ "resend"
24004
+ ];
24005
+ var PULSE_CHAIN_IDS = ["impersonation", "borrowed-address"];
24006
+ var day = external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/);
24007
+ var attackChainSchema = external_exports.object({
24008
+ id: external_exports.enum(PULSE_CHAIN_IDS),
24009
+ severity: external_exports.literal("high"),
24010
+ confidence: external_exports.enum(["observed", "potential"]),
24011
+ /** Evidence ids: finding ids, or `contact-surface:*`, `email-policy:*`, `configuration:*`. */
24012
+ ingredients: external_exports.array(external_exports.string()),
24013
+ strengtheners: external_exports.array(external_exports.string())
24014
+ });
24015
+ var certificateRetrievalSchema = external_exports.object({
24016
+ mode: external_exports.enum(["live", "shared-cache", "memory-cache"]),
24017
+ retrievedAt: external_exports.iso.datetime().optional(),
24018
+ expiresAt: external_exports.iso.datetime().optional()
24019
+ }).strict();
24020
+ var domainMapSchema = external_exports.object({
24021
+ domain: external_exports.string(),
24022
+ /** `crtsh-sql` is crt.sh's database; `certspotter` covers current certificates only. */
24023
+ source: external_exports.enum(["crt.sh", "crtsh-sql", "certspotter"]),
24024
+ retrieval: certificateRetrievalSchema.optional(),
24025
+ names: external_exports.array(
24026
+ external_exports.object({
24027
+ name: external_exports.string(),
24028
+ controller: external_exports.enum(["you", "third-party", "nobody"]).optional(),
24029
+ chain: external_exports.array(external_exports.string()),
24030
+ firstSeen: day,
24031
+ lastIssued: day,
24032
+ lastExpiry: day
24033
+ })
24034
+ )
24035
+ });
24036
+ var targetRegistrationSchema = external_exports.discriminatedUnion("state", [
24037
+ external_exports.object({
24038
+ state: external_exports.literal("available"),
24039
+ domain: external_exports.string().min(1).max(253),
24040
+ expiresAt: external_exports.iso.date().optional(),
24041
+ statuses: external_exports.array(external_exports.string().max(128)).max(128)
24042
+ }).strict(),
24043
+ external_exports.object({
24044
+ state: external_exports.enum([
24045
+ "not-found",
24046
+ "unsupported",
24047
+ "unavailable",
24048
+ "not-configured"
24049
+ ]),
24050
+ domain: external_exports.string().min(1).max(253)
24051
+ }).strict(),
24052
+ external_exports.object({ state: external_exports.literal("not-applicable") }).strict()
24053
+ ]);
24054
+ var publicRecordsSchema = external_exports.object({
24055
+ readAt: external_exports.string(),
24056
+ certificateHistory: external_exports.enum(["available", "unavailable", "not-configured"]),
24057
+ policyQueries: external_exports.number().int().nonnegative(),
24058
+ sweepQueries: external_exports.number().int().nonnegative(),
24059
+ spfQueries: external_exports.number().int().nonnegative(),
24060
+ rdapLookups: external_exports.number().int().nonnegative(),
24061
+ targetRegistrationLookups: external_exports.number().int().min(0).max(1).optional(),
24062
+ targetRegistration: targetRegistrationSchema.optional(),
24063
+ /** DS presence queries over DNS-over-HTTPS; absent when not configured. */
24064
+ dnssecQueries: external_exports.number().int().nonnegative().optional()
24065
+ });
24066
+ var contactSurfaceSchema = external_exports.object({
24067
+ emails: external_exports.number().int().nonnegative(),
24068
+ onDomainEmails: external_exports.array(external_exports.string()),
24069
+ phones: external_exports.number().int().nonnegative(),
24070
+ booking: external_exports.array(external_exports.enum(PULSE_BOOKING_SERVICES)),
24071
+ community: external_exports.array(external_exports.enum(PULSE_COMMUNITY_SERVICES))
24072
+ });
24073
+ var providersSchema = external_exports.object({
24074
+ dns: external_exports.enum(PULSE_DNS_HOSTS).optional(),
24075
+ mail: external_exports.enum(PULSE_MAIL_PROVIDERS).optional(),
24076
+ senders: external_exports.array(external_exports.enum(PULSE_EMAIL_SENDERS))
24077
+ });
24078
+ var apiAccessClassificationSchema = external_exports.enum([
24079
+ "protected",
24080
+ "open-data",
24081
+ "open-sensitive",
24082
+ "open-metadata",
24083
+ "error-disclosure",
24084
+ "auth-mismatch",
24085
+ "absent"
24086
+ ]);
24087
+ var baasValueClassificationSchema = external_exports.enum([
24088
+ "protected",
24089
+ "open-data",
24090
+ "open-sensitive",
24091
+ "open-metadata",
24092
+ "error-disclosure",
24093
+ "auth-mismatch",
24094
+ "absent",
24095
+ "inconclusive"
24096
+ ]);
24097
+ var apiSurfaceSchema = external_exports.object({
24098
+ counts: external_exports.object({
24099
+ mapped: external_exports.number().int().nonnegative(),
24100
+ protected: external_exports.number().int().nonnegative(),
24101
+ open: external_exports.number().int().nonnegative(),
24102
+ notChecked: external_exports.number().int().nonnegative(),
24103
+ absent: external_exports.number().int().nonnegative()
24104
+ }),
24105
+ /** The endpoints that answered with data to an unauthenticated GET. */
24106
+ open: external_exports.array(
24107
+ external_exports.object({
24108
+ method: external_exports.enum(["GET", "HEAD"]),
24109
+ path: external_exports.string(),
24110
+ status: external_exports.number().int().min(0).max(599),
24111
+ contentType: external_exports.string(),
24112
+ classification: apiAccessClassificationSchema,
24113
+ /** Top-level field names only, never their values. */
24114
+ fields: external_exports.array(external_exports.string()).max(20).optional(),
24115
+ records: external_exports.number().int().nonnegative().optional()
24116
+ })
24117
+ ).max(50),
24118
+ graphql: external_exports.object({
24119
+ endpoint: external_exports.string(),
24120
+ introspection: external_exports.enum(["enabled", "not-checked"]),
24121
+ queries: external_exports.number().int().nonnegative().optional(),
24122
+ mutations: external_exports.number().int().nonnegative().optional()
24123
+ }).optional(),
24124
+ /**
24125
+ * Anonymous reads of the NextAuth/Auth.js and Better Auth session routes
24126
+ * (engine 2.13). Absent routes are dropped; field names only, never values.
24127
+ */
24128
+ authSessions: external_exports.array(
24129
+ external_exports.object({
24130
+ path: external_exports.string(),
24131
+ library: external_exports.enum(["authjs", "better-auth"]),
24132
+ status: external_exports.number().int().min(0).max(599),
24133
+ classification: external_exports.enum(["protected", "anonymous", "session-exposed"]),
24134
+ sharedCacheable: external_exports.boolean().optional(),
24135
+ fields: external_exports.array(external_exports.string()).max(20).optional()
24136
+ })
24137
+ ).max(2).optional(),
24138
+ /**
24139
+ * Managed-backend follow-through (engine 2.14): Supabase REST tables and
24140
+ * Firebase realtime databases the anon key or a public read could answer.
24141
+ * Table and key names only, never values.
24142
+ */
24143
+ baas: external_exports.object({
24144
+ supabase: external_exports.array(
24145
+ external_exports.object({
24146
+ url: external_exports.string(),
24147
+ tables: external_exports.array(
24148
+ external_exports.object({
24149
+ table: external_exports.string(),
24150
+ status: external_exports.number().int().min(0).max(599),
24151
+ classification: baasValueClassificationSchema,
24152
+ /** Declared column names only, never their values. */
24153
+ fields: external_exports.array(external_exports.string()).max(20).optional(),
24154
+ /** Top-level field count when no declared name is published. */
24155
+ fieldCount: external_exports.number().int().nonnegative().optional(),
24156
+ records: external_exports.number().int().nonnegative().optional()
24157
+ })
24158
+ ).max(5),
24159
+ unprobed: external_exports.number().int().nonnegative().optional()
24160
+ })
24161
+ ).max(1).optional(),
24162
+ firebase: external_exports.array(
24163
+ external_exports.object({
24164
+ url: external_exports.string(),
24165
+ status: external_exports.number().int().min(0).max(599),
24166
+ classification: external_exports.enum(["public", "protected", "absent"]),
24167
+ /** Top-level key count only; a key can itself be customer data. */
24168
+ keyCount: external_exports.number().int().nonnegative().optional()
24169
+ })
24170
+ ).max(1).optional(),
24171
+ convex: external_exports.array(
24172
+ external_exports.object({
24173
+ url: external_exports.string(),
24174
+ functions: external_exports.array(
24175
+ external_exports.object({
24176
+ path: external_exports.string(),
24177
+ status: external_exports.number().int().min(0).max(599),
24178
+ classification: apiAccessClassificationSchema,
24179
+ /** Field count only; a value can be keyed by customer data. */
24180
+ fieldCount: external_exports.number().int().nonnegative().optional(),
24181
+ records: external_exports.number().int().nonnegative().optional()
24182
+ })
24183
+ ).max(5),
24184
+ unprobed: external_exports.number().int().nonnegative().optional()
24185
+ })
24186
+ ).max(1).optional()
24187
+ }).optional(),
24188
+ /**
24189
+ * Deep's coverage ledger (C2): every investigation packet closed as found,
24190
+ * not found, not applicable or could not check, with a reason. This is the
24191
+ * customer-visible "what we checked", made systematic. It carries no prompt,
24192
+ * tool choice or ordering detail, only the packet, its impact, the outcome,
24193
+ * a coded reason and the counts.
24194
+ */
24195
+ coverageLedger: external_exports.object({
24196
+ version: external_exports.literal("1"),
24197
+ entries: external_exports.array(
24198
+ external_exports.object({
24199
+ packet: external_exports.enum([
24200
+ "supabase-rls",
24201
+ "storage-buckets",
24202
+ "script-routes",
24203
+ "auth-endpoints"
24204
+ ]),
24205
+ impact: external_exports.enum([
24206
+ "data-exposure",
24207
+ "auth-surface",
24208
+ "exposed-files",
24209
+ "configuration"
24210
+ ]),
24211
+ outcome: external_exports.enum([
24212
+ "found",
24213
+ "not-found",
24214
+ "not-applicable",
24215
+ "could-not-check"
24216
+ ]),
24217
+ reason: external_exports.string(),
24218
+ requests: external_exports.number().int().nonnegative(),
24219
+ budget: external_exports.number().int().nonnegative(),
24220
+ findings: external_exports.number().int().nonnegative().optional(),
24221
+ references: external_exports.number().int().nonnegative().optional()
24222
+ })
24223
+ ).max(20)
24224
+ }).optional(),
24225
+ truncated: external_exports.number().int().nonnegative().optional()
24226
+ });
24227
+ var SOURCE_MAP_BASELINES = [
24228
+ "published",
24229
+ "none",
24230
+ "not-checked"
24231
+ ];
24232
+ var sourceMapBaselineSchema = external_exports.enum(SOURCE_MAP_BASELINES);
24233
+ var EXPOSURE_CHECK_STATES = [
24234
+ "clear",
24235
+ "found",
24236
+ "not-run",
24237
+ "could-not-run"
24238
+ ];
24239
+ var EXPOSURE_CHECK_LIMITS = [
24240
+ "no-source-map",
24241
+ "source-map-unread",
24242
+ "budget"
24243
+ ];
24244
+ var exposureCheckSchema = external_exports.object({
24245
+ state: external_exports.enum(EXPOSURE_CHECK_STATES),
24246
+ /** Empty when discovery ran at full confidence. */
24247
+ limits: external_exports.array(external_exports.enum(EXPOSURE_CHECK_LIMITS)).max(3),
24248
+ sourceMap: sourceMapBaselineSchema
24249
+ });
24250
+ function exposureCheckHeadline(check3) {
24251
+ return check3.state === "clear" && check3.limits.length ? "clear-limited" : check3.state;
24252
+ }
24253
+ var EXPOSURE_CHECK_TEXT = {
24254
+ title: {
24255
+ clear: "No unintended public API routes or pages found exposed.",
24256
+ "clear-limited": "No exposed API routes or pages found, but coverage was limited, so this is not a full all-clear.",
24257
+ found: "Deep found API routes or pages that answer without sign-in.",
24258
+ "not-run": "This run did not check for exposed API routes and pages.",
24259
+ "could-not-run": "Deep could not finish checking for exposed API routes and pages, so this report makes no claim about them."
24260
+ },
24261
+ limits: {
24262
+ "no-source-map": "Your site publishes no source maps, so Deep had fewer routes and pages to find.",
24263
+ "source-map-unread": "Deep did not read your source maps on this run, so it had fewer routes and pages to find.",
24264
+ budget: "Deep reached its request limit before it finished looking."
24265
+ }
24266
+ };
24267
+ function exposureCheckLines(check3) {
24268
+ return [
24269
+ EXPOSURE_CHECK_TEXT.title[exposureCheckHeadline(check3)],
24270
+ ...check3.state === "clear" ? check3.limits.map((limit) => EXPOSURE_CHECK_TEXT.limits[limit]) : []
24271
+ ];
24272
+ }
24273
+ var publicPulseReportSchema = external_exports.object({
24274
+ schemaVersion: external_exports.literal("public-pulse@1"),
24275
+ assessment: assessmentEvidenceSchema.optional(),
24276
+ standards: standardsSummarySchema.optional(),
24277
+ target: external_exports.object({
24278
+ shape: external_exports.enum(["html", "client-rendered", "api", "document"]),
24279
+ signals: external_exports.array(
24280
+ external_exports.enum([
24281
+ "structured-json",
24282
+ "html-response",
24283
+ "small-html",
24284
+ "empty-root",
24285
+ "script-reference",
24286
+ "few-links"
24287
+ ])
24288
+ ).max(6).optional(),
24289
+ rendering: external_exports.enum([
24290
+ "not-requested",
24291
+ "complete",
24292
+ "limited",
24293
+ "unavailable"
24294
+ ]),
24295
+ blockedRequests: external_exports.number().int().nonnegative(),
24296
+ observedCookies: external_exports.number().int().nonnegative().optional(),
24297
+ secureCookies: external_exports.number().int().nonnegative().optional(),
24298
+ httpOnlyCookies: external_exports.number().int().nonnegative().optional(),
24299
+ /** Built-with chips, shared stack vocabulary. Not a finding. */
24300
+ stack: external_exports.array(quickStackSchema).max(PULSE_STACK_MAX).optional(),
24301
+ /** Explicit technology and version declarations; never findings. */
24302
+ technologies: external_exports.object({
24303
+ limited: external_exports.boolean(),
24304
+ entries: external_exports.array(
24305
+ external_exports.object({
24306
+ id: quickStackSchema,
24307
+ version: external_exports.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}(?:-[A-Za-z0-9.-]{1,40})?$/).optional(),
24308
+ via: external_exports.enum(["header", "generator", "asset", "code", "banner"])
24309
+ })
24310
+ ).max(24)
24311
+ }).optional(),
24312
+ /**
24313
+ * Companies whose code runs on the page: external and preloaded
24314
+ * scripts by registrable domain. Not a finding.
24315
+ */
24316
+ thirdPartyCode: external_exports.array(
24317
+ external_exports.object({
24318
+ domain: external_exports.string(),
24319
+ stack: quickStackSchema.optional(),
24320
+ integrity: external_exports.enum(["declared", "missing"])
24321
+ })
24322
+ ).optional(),
24323
+ /**
24324
+ * Companies the page sends visitor activity to, from known endpoints
24325
+ * in its own script text. Never companies that run code. Not a finding.
24326
+ */
24327
+ thirdPartyData: external_exports.array(
24328
+ external_exports.object({
24329
+ service: external_exports.enum(PULSE_VISITOR_DATA_SERVICES),
24330
+ hosts: external_exports.array(external_exports.string())
24331
+ })
24332
+ ).optional(),
24333
+ /** Owner only. */
24334
+ contactSurface: contactSurfaceSchema.optional(),
24335
+ /** Owner only. */
24336
+ providers: providersSchema.optional()
24337
+ }).optional(),
24338
+ methodology: external_exports.literal("public-surface"),
24339
+ methodologyVersion: external_exports.literal("1"),
24340
+ checks: external_exports.array(publicCheckSchema),
24341
+ surface: external_exports.object({
24342
+ findings: external_exports.array(publicFindingSchema),
24343
+ chains: external_exports.array(attackChainSchema).optional(),
24344
+ /** Deep only. Counts and the endpoints that answered without sign-in. */
24345
+ apiSurface: apiSurfaceSchema.optional(),
24346
+ /** Owner only. */
24347
+ domainMap: domainMapSchema.optional(),
24348
+ /** Owner only. When public records were read and what that cost. */
24349
+ publicRecords: publicRecordsSchema.optional()
24350
+ }).optional(),
24351
+ score: external_exports.number().min(0).max(100).nullable(),
24352
+ coverage: external_exports.number().min(0).max(100),
24353
+ pages: external_exports.number().int().nonnegative(),
24354
+ requests: external_exports.number().int().nonnegative(),
24355
+ limitations: external_exports.array(external_exports.string()),
24356
+ stoppedReason: external_exports.string().optional(),
24357
+ /**
24358
+ * Deep only, and present even when the run stopped before its surface
24359
+ * stage: whether unintended public API routes and pages were found.
24360
+ */
24361
+ exposureCheck: exposureCheckSchema.optional(),
24362
+ /** How far collection got (engine 2.18+; derived for older reports). */
24363
+ outcome: publicRunOutcomeSchema.optional()
24364
+ });
24365
+ var publicPulseChangeSchema = external_exports.object({
24366
+ id: external_exports.string(),
24367
+ kind: external_exports.enum(["new", "persistent", "absent", "unknown"]),
24368
+ area: external_exports.enum(["configuration", "exposure"]),
24369
+ title: external_exports.string(),
24370
+ severity: external_exports.string(),
24371
+ explanation: external_exports.string(),
24372
+ /** What the recheck proves about an earlier finding; absent for a new one. */
24373
+ outcome: recheckStatusSchema.optional()
24374
+ });
24375
+
23777
24376
  // ../../packages/contracts/src/cli-routes.ts
23778
24377
  var CLI_API = "/api/cli/v1";
23779
24378
  var segment = (value) => encodeURIComponent(value);
@@ -23786,6 +24385,8 @@ var CLI_API_ROUTES = {
23786
24385
  projectStatus: (projectId) => `${CLI_API}/projects/${segment(projectId)}/status`,
23787
24386
  /** POST: start a Pulse (needs the `pulse` scope). */
23788
24387
  projectPulse: (projectId) => `${CLI_API}/projects/${segment(projectId)}/pulse`,
24388
+ /** POST: start a release's Deep scan (a CI key with the `deep` scope). */
24389
+ projectDeep: (projectId) => `${CLI_API}/projects/${segment(projectId)}/deep`,
23789
24390
  runs: `${CLI_API}/runs`,
23790
24391
  run: (runId) => `${CLI_API}/runs/${segment(runId)}`,
23791
24392
  findings: (runId) => `${CLI_API}/runs/${segment(runId)}/findings`,
@@ -23978,7 +24579,18 @@ var cliRunDetailSchema = cliRunSummarySchema.extend({
23978
24579
  percent: external_exports.number().nullable(),
23979
24580
  limitations: external_exports.array(external_exports.string())
23980
24581
  }),
23981
- caveats: external_exports.array(external_exports.string())
24582
+ caveats: external_exports.array(external_exports.string()),
24583
+ /**
24584
+ * Pulse, Standard and Deep: how far collection got (blocked, unreachable
24585
+ * and so on) and what the report is based on. Null for pentests and
24586
+ * unfinished runs.
24587
+ */
24588
+ outcome: publicRunOutcomeSchema.nullable().optional(),
24589
+ /**
24590
+ * Deep only: whether unintended public API routes and pages were found,
24591
+ * as a state and coded limits. Never the routes requested. Null otherwise.
24592
+ */
24593
+ exposureCheck: exposureCheckSchema.nullable().optional()
23982
24594
  });
23983
24595
  var CLI_FINDING_STATES = [
23984
24596
  "attention",
@@ -24212,7 +24824,18 @@ var agentScanSchema = external_exports.strictObject({
24212
24824
  percent: external_exports.number().nullable(),
24213
24825
  limitations: external_exports.array(external_exports.string())
24214
24826
  }),
24215
- caveats: external_exports.array(external_exports.string())
24827
+ caveats: external_exports.array(external_exports.string()),
24828
+ /**
24829
+ * How far collection got and what the report is based on. Only a `full`
24830
+ * run is ever graded; the others say why (a bot check, no DNS address).
24831
+ * Null for pentests and unfinished runs.
24832
+ */
24833
+ outcome: publicRunOutcomeSchema.nullable(),
24834
+ /**
24835
+ * Deep only: whether unintended public API routes and pages were found.
24836
+ * Absent for Pulse, Standard and pentests, and from older servers.
24837
+ */
24838
+ exposureCheck: exposureCheckSchema.optional()
24216
24839
  });
24217
24840
  var agentSiteSchema = external_exports.strictObject({
24218
24841
  id: agentIdSchema,
@@ -24909,14 +25532,53 @@ async function readLockfiles(dir) {
24909
25532
  return { packages, sources, skipped };
24910
25533
  }
24911
25534
 
25535
+ // src/terminal.ts
25536
+ var UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f؜‎‏‪-‮⁦-⁩]/g;
25537
+ function neutralize(text2) {
25538
+ return text2.replace(/\r\n/g, "\n").replace(UNSAFE, (char) => {
25539
+ const code = char.charCodeAt(0);
25540
+ if (code < 32) return String.fromCharCode(9216 + code);
25541
+ if (code === 127) return "\u2421";
25542
+ return "\uFFFD";
25543
+ });
25544
+ }
25545
+ function neutralizeDeep(value) {
25546
+ if (typeof value === "string") return neutralize(value);
25547
+ if (Array.isArray(value)) return value.map(neutralizeDeep);
25548
+ if (value && typeof value === "object") {
25549
+ const copy = {};
25550
+ for (const [key, child] of Object.entries(value))
25551
+ copy[key] = neutralizeDeep(child);
25552
+ return copy;
25553
+ }
25554
+ return value;
25555
+ }
25556
+ var OWN_STYLE = /\u001b\[(?:1|2|22|31|32|33|36|39)m/g;
25557
+ function terminalSafe(text2) {
25558
+ const styles = text2.match(OWN_STYLE) ?? [];
25559
+ return text2.split(OWN_STYLE).map((part, index) => neutralize(part) + (styles[index] ?? "")).join("");
25560
+ }
25561
+ function terminalWidth(stream) {
25562
+ return stream.isTTY && stream.columns ? stream.columns : null;
25563
+ }
25564
+
24912
25565
  // src/api.ts
24913
25566
  function friendlyError(status2, message, replace) {
24914
25567
  if (status2 === 404 && message === "Not found" && replace.notFound)
24915
25568
  return replace.notFound;
25569
+ const permission = status2 === 403 && message?.match(/^Missing required permission: (\S+)$/);
25570
+ if (permission)
25571
+ return PERMISSION_TEXT[permission[1]] ?? `Your role does not allow this (it needs ${permission[1]}).`;
24916
25572
  if (status2 === 403 && message === "This CLI key does not have the required scope" && replace.forbidden)
24917
25573
  return replace.forbidden;
24918
25574
  return message ?? null;
24919
25575
  }
25576
+ var PERMISSION_TEXT = {
25577
+ "audit.launch": "Your role on this site lets you read results, not start scans. Ask a project manager for the contributor role.",
25578
+ "project.read": "You do not have access to this site.",
25579
+ "report.read": "You do not have access to this site's results.",
25580
+ "project.manage": "Only a project manager or workspace admin can change this site."
25581
+ };
24920
25582
  var missingRun = (runId) => `No run "${runId}" in this workspace.`;
24921
25583
  var LockpickApiError = class extends Error {
24922
25584
  constructor(message, status2, code, agentError = null) {
@@ -24991,10 +25653,10 @@ function mappedLocalIPv4(name) {
24991
25653
  return localIPv4([high >> 8, high & 255, low >> 8, low & 255]);
24992
25654
  }
24993
25655
  function normalizeHost(value) {
24994
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`;
25656
+ const explicit = /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
24995
25657
  let url3;
24996
25658
  try {
24997
- url3 = new URL(withScheme);
25659
+ url3 = new URL(explicit ? value : `http://${value}`);
24998
25660
  } catch {
24999
25661
  throw new LockpickApiError(
25000
25662
  "Invalid Lockpick host URL.",
@@ -25002,9 +25664,8 @@ function normalizeHost(value) {
25002
25664
  "VALIDATION_ERROR"
25003
25665
  );
25004
25666
  }
25005
- const name = url3.hostname.toLowerCase().replace(/^\[|\]$/g, "");
25006
- const ipKind = isIP(name);
25007
- const local = name === "localhost" || name === "::1" || ipKind === 6 && (/^f[cd]/.test(name) || mappedLocalIPv4(name)) || ipKind === 4 && localIPv4(name.split(".").map(Number)) || ipKind === 0 && (!name.includes(".") || name.endsWith(".local") || name.endsWith(".ts.net"));
25667
+ if (!explicit && !localHostname(url3.hostname)) url3.protocol = "https:";
25668
+ const local = localHostname(url3.hostname);
25008
25669
  if (url3.protocol !== "https:" && !(url3.protocol === "http:" && local) || url3.username || url3.password || url3.search || url3.hash)
25009
25670
  throw new LockpickApiError(
25010
25671
  "Use an HTTPS Lockpick host, or HTTP on a local network; omit credentials, query and fragment.",
@@ -25013,6 +25674,37 @@ function normalizeHost(value) {
25013
25674
  );
25014
25675
  return url3.origin;
25015
25676
  }
25677
+ function localHostname(hostname4) {
25678
+ const name = hostname4.toLowerCase().replace(/^\[|\]$/g, "");
25679
+ const ipKind = isIP(name);
25680
+ return name === "localhost" || name === "::1" || ipKind === 6 && (/^f[cd]/.test(name) || mappedLocalIPv4(name)) || ipKind === 4 && localIPv4(name.split(".").map(Number)) || ipKind === 0 && (!name.includes(".") || name.endsWith(".local") || name.endsWith(".ts.net"));
25681
+ }
25682
+ var HostRedirectError = class extends LockpickApiError {
25683
+ constructor(host, canonicalHost, status2) {
25684
+ super(
25685
+ canonicalHost ? `${host} redirects to ${canonicalHost}. Use \`--host ${canonicalHost}\`.` : `${host} redirected the request elsewhere. Check the Lockpick host.`,
25686
+ status2,
25687
+ "HOST_REDIRECT"
25688
+ );
25689
+ this.host = host;
25690
+ this.canonicalHost = canonicalHost;
25691
+ }
25692
+ host;
25693
+ canonicalHost;
25694
+ };
25695
+ function hostRedirect(host, requested, response) {
25696
+ if (response.status < 300 || response.status > 399) return null;
25697
+ let canonical = null;
25698
+ try {
25699
+ const target = new URL(response.headers.get("location") ?? "", requested);
25700
+ const origin = normalizeHost(target.origin);
25701
+ if (origin !== host && target.pathname === requested.pathname)
25702
+ canonical = origin;
25703
+ } catch {
25704
+ canonical = null;
25705
+ }
25706
+ return new HostRedirectError(host, canonical, response.status);
25707
+ }
25016
25708
  var pageQuery = (query) => Object.fromEntries(
25017
25709
  Object.entries(query).filter(([, value]) => value !== void 0 && value !== false).map(([name, value]) => [name, String(value)])
25018
25710
  );
@@ -25025,8 +25717,14 @@ var LockpickClient = class {
25025
25717
  host;
25026
25718
  key;
25027
25719
  fetchImpl;
25720
+ /**
25721
+ * Server text as the CLI may show it: the key never appears, and terminal
25722
+ * control characters are neutralized (`terminal.ts`).
25723
+ */
25028
25724
  redact(text2) {
25029
- return this.key ? text2.replaceAll(this.key, "[redacted]") : text2;
25725
+ return neutralize(
25726
+ this.key ? text2.replaceAll(this.key, "[redacted]") : text2
25727
+ );
25030
25728
  }
25031
25729
  redactAgent(error51) {
25032
25730
  return {
@@ -25095,7 +25793,8 @@ var LockpickClient = class {
25095
25793
  method: init.method ?? "GET",
25096
25794
  headers,
25097
25795
  body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
25098
- redirect: "error",
25796
+ // Never followed (the key must not travel); see `hostRedirect`.
25797
+ redirect: "manual",
25099
25798
  signal: controller.signal
25100
25799
  }),
25101
25800
  deadline
@@ -25121,6 +25820,8 @@ var LockpickClient = class {
25121
25820
  } finally {
25122
25821
  clearTimeout(timeout);
25123
25822
  }
25823
+ const redirect = hostRedirect(this.host, url3, response);
25824
+ if (redirect) throw redirect;
25124
25825
  if (!envelope || typeof envelope !== "object" || !("data" in envelope))
25125
25826
  throw new LockpickApiError(
25126
25827
  `${this.host} did not answer as Lockpick (HTTP ${response.status}). Check the URL, and that CLI access is enabled there.`,
@@ -25143,7 +25844,7 @@ var LockpickClient = class {
25143
25844
  response.status,
25144
25845
  "CONTRACT_MISMATCH"
25145
25846
  );
25146
- return parsed.data;
25847
+ return neutralizeDeep(parsed.data);
25147
25848
  }
25148
25849
  startDeviceLogin(client) {
25149
25850
  return this.request(
@@ -25627,6 +26328,11 @@ async function clientFor(options = {}) {
25627
26328
  }
25628
26329
 
25629
26330
  // src/doctor.ts
26331
+ var CAPABILITY_GAP = {
26332
+ pulse: "your role on every site here only lets you read results.",
26333
+ sites: "your workspace role cannot add sites.",
26334
+ deep: "Deep is not available to you here (role, early access or server setting)."
26335
+ };
25630
26336
  async function runDoctor(options = {}) {
25631
26337
  const checks = [];
25632
26338
  const nodeVersion = options.nodeVersion ?? process.versions.node;
@@ -25672,14 +26378,22 @@ async function runDoctor(options = {}) {
25672
26378
  }
25673
26379
  const fetchImpl = options.fetchImpl ?? fetch;
25674
26380
  try {
25675
- const response = await fetchImpl(
25676
- new URL("/api/health/ready", session.host),
25677
- {
25678
- method: "GET",
25679
- redirect: "error",
25680
- signal: AbortSignal.timeout(1e4)
25681
- }
25682
- );
26381
+ const health = new URL("/api/health/ready", session.host);
26382
+ const response = await fetchImpl(health, {
26383
+ method: "GET",
26384
+ redirect: "manual",
26385
+ signal: AbortSignal.timeout(1e4)
26386
+ });
26387
+ const redirect = hostRedirect(session.host, health, response);
26388
+ if (redirect) {
26389
+ checks.push({
26390
+ status: "FAIL",
26391
+ name: "Connection",
26392
+ detail: redirect.message,
26393
+ nextStep: redirect.canonicalHost ? `Run lockpick login --host ${redirect.canonicalHost}.` : "Check the host, then rerun doctor."
26394
+ });
26395
+ return checks;
26396
+ }
25683
26397
  checks.push(
25684
26398
  response.ok ? {
25685
26399
  status: "PASS",
@@ -25718,8 +26432,9 @@ async function runDoctor(options = {}) {
25718
26432
  name: "Authentication",
25719
26433
  detail: `${me.user.name} in ${me.workspace.name} (${me.workspace.id}); key ${me.key.prefix}\u2026`
25720
26434
  });
26435
+ let capabilities = null;
25721
26436
  try {
25722
- await client.agent.whoami();
26437
+ ({ capabilities } = await client.agent.whoami());
25723
26438
  checks.push({
25724
26439
  status: "PASS",
25725
26440
  name: "Agent API",
@@ -25739,12 +26454,29 @@ async function runDoctor(options = {}) {
25739
26454
  name: "Scope",
25740
26455
  detail: `The key lacks ${options.scope} permission.`,
25741
26456
  nextStep: `Run lockpick login --host ${session.host} --${options.scope} and approve it in the browser.`
26457
+ } : options.scope && capabilities && !capabilities[options.scope] ? {
26458
+ // The key may, but the person's role (or the server) may not:
26459
+ // a project viewer's Pulse key starts nothing.
26460
+ status: "WARN",
26461
+ name: "Scope",
26462
+ detail: `The key has ${options.scope} permission, but ${CAPABILITY_GAP[options.scope]}`,
26463
+ nextStep: "Ask a workspace admin or project manager for the contributor or manager role on a site."
25742
26464
  } : {
25743
26465
  status: "PASS",
25744
26466
  name: "Scope",
25745
26467
  detail: options.scope ? `${options.scope} permission is present.` : `Granted: ${me.key.scopes.join(", ")}.`
25746
26468
  }
25747
26469
  );
26470
+ const daysLeft = Math.floor(
26471
+ (Date.parse(me.key.expiresAt) - Date.now()) / 864e5
26472
+ );
26473
+ if (daysLeft < 7)
26474
+ checks.push({
26475
+ status: "WARN",
26476
+ name: "Key expiry",
26477
+ detail: `The key expires when idle on ${me.key.expiresAt.slice(0, 10)} (${Math.max(0, daysLeft)} days).`,
26478
+ nextStep: "Using it extends it; for CI, sign in again before then."
26479
+ });
25748
26480
  } catch (error51) {
25749
26481
  const apiError = error51 instanceof LockpickApiError ? error51 : null;
25750
26482
  checks.push({
@@ -25766,7 +26498,9 @@ var EXIT = {
25766
26498
  permission: 4,
25767
26499
  validation: 5,
25768
26500
  rateLimit: 6,
25769
- provider: 7
26501
+ provider: 7,
26502
+ /** A finished scan has open findings at or above `--fail-on`. */
26503
+ findings: 8
25770
26504
  };
25771
26505
  function exitCodeFor(error51) {
25772
26506
  if (!(error51 instanceof LockpickApiError)) return EXIT.error;
@@ -25800,7 +26534,8 @@ var CODES = {
25800
26534
  cyan: [36, 39]
25801
26535
  };
25802
26536
  function painter(stream = process.stdout, env = process.env) {
25803
- const enabled = Boolean(stream.isTTY) && !env.NO_COLOR && env.TERM !== "dumb";
26537
+ const forced = env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0";
26538
+ const enabled = !env.NO_COLOR && env.FORCE_COLOR !== "0" && (forced || Boolean(stream.isTTY) && env.TERM !== "dumb");
25804
26539
  return (style, text2) => {
25805
26540
  if (!enabled) return text2;
25806
26541
  const [open4, close] = CODES[style];
@@ -25830,17 +26565,33 @@ function plural(count, noun) {
25830
26565
  function date5(value) {
25831
26566
  return value ? value.replace("T", " ").slice(0, 16) : "\u2014";
25832
26567
  }
25833
- function table(rows) {
25834
- const visible = (text2) => text2.replace(/\u001b\[\d+m/g, "").length;
26568
+ var STYLE = /\u001b\[\d+m/g;
26569
+ var visible = (text2) => text2.replace(STYLE, "").length;
26570
+ function clip(text2, max) {
26571
+ if (visible(text2) <= max) return text2;
26572
+ return `${text2.replace(STYLE, "").slice(0, Math.max(1, max - 1))}\u2026`;
26573
+ }
26574
+ function table(rows, width2, flex = rows[0].length - 1) {
25835
26575
  const widths = rows[0].map(
25836
26576
  (_, column) => Math.max(...rows.map((row) => visible(row[column] ?? "")))
25837
26577
  );
26578
+ if (width2) {
26579
+ const others = widths.reduce(
26580
+ (sum, columnWidth, column) => column === flex ? sum : sum + columnWidth + 2,
26581
+ 0
26582
+ );
26583
+ widths[flex] = Math.min(widths[flex], Math.max(16, width2 - others - 1));
26584
+ }
25838
26585
  return rows.map(
25839
- (row) => row.map(
25840
- (cell, column) => column === row.length - 1 ? cell : cell + " ".repeat(widths[column] - visible(cell))
25841
- ).join(" ")
26586
+ (row) => row.map((cell, column) => {
26587
+ const fitted = column === flex ? clip(cell, widths[column]) : cell;
26588
+ return column === row.length - 1 ? fitted : fitted + " ".repeat(Math.max(0, widths[column] - visible(fitted)));
26589
+ }).join(" ")
25842
26590
  ).join("\n");
25843
26591
  }
26592
+ function next(paint2, ...commands) {
26593
+ return paint2("dim", `Next: ${commands.join(" \xB7 ")}`);
26594
+ }
25844
26595
  function runKindLabel(run2) {
25845
26596
  if (run2.kind === "pentest")
25846
26597
  return run2.mode ? `pentest/${run2.mode}` : "pentest";
@@ -25848,35 +26599,51 @@ function runKindLabel(run2) {
25848
26599
  if (run2.assessmentLevel === "standard") return "Standard";
25849
26600
  return "Pulse";
25850
26601
  }
25851
- function formatErrorLines(message, agentError) {
26602
+ function formatErrorLines(message, agentError, docsUrl2) {
25852
26603
  const lines = [message];
25853
26604
  if (agentError?.nextStep) lines.push(agentError.nextStep);
25854
26605
  if (agentError?.retryAfterSeconds !== null && agentError?.retryAfterSeconds !== void 0)
25855
26606
  lines.push(`Retry after ${agentError.retryAfterSeconds} seconds.`);
25856
26607
  if (agentError?.webUrl) lines.push(agentError.webUrl);
26608
+ if (docsUrl2) lines.push(`Help: ${docsUrl2}`);
25857
26609
  return lines;
25858
26610
  }
25859
26611
  function runLine(run2, paint2) {
25860
26612
  if (!run2) return paint2("dim", "none yet");
25861
26613
  return `${statusColor(run2.status, paint2)} ${paint2("dim", `${date5(run2.createdAt)} \xB7 ${run2.id}`)}`;
25862
26614
  }
25863
- function formatProjects(projects2, paint2 = plain) {
26615
+ function formatProjects(projects2, paint2 = plain, width2) {
25864
26616
  if (projects2.length === 0)
25865
- return "No projects you can read in this workspace.";
25866
- return table([
26617
+ return [
26618
+ "No projects you can read in this workspace.",
26619
+ paint2(
26620
+ "dim",
26621
+ "Add one with `lockpick sites add <url>`, or ask a workspace admin to grant you a project."
26622
+ )
26623
+ ].join("\n");
26624
+ const rows = table(
25867
26625
  [
25868
- paint2("bold", "PROJECT"),
25869
- paint2("bold", "ID"),
25870
- paint2("bold", "PULSE"),
25871
- paint2("bold", "PENTEST")
26626
+ [
26627
+ paint2("bold", "PROJECT"),
26628
+ paint2("bold", "ID"),
26629
+ paint2("bold", "PULSE"),
26630
+ paint2("bold", "PENTEST")
26631
+ ],
26632
+ ...projects2.map((project) => [
26633
+ project.name,
26634
+ paint2("dim", project.id),
26635
+ project.latestPulse ? statusColor(project.latestPulse.status, paint2) : paint2("dim", "\u2014"),
26636
+ project.latestPentest ? statusColor(project.latestPentest.status, paint2) : paint2("dim", "\u2014")
26637
+ ])
25872
26638
  ],
25873
- ...projects2.map((project) => [
25874
- project.name,
25875
- paint2("dim", project.id),
25876
- project.latestPulse ? statusColor(project.latestPulse.status, paint2) : paint2("dim", "\u2014"),
25877
- project.latestPentest ? statusColor(project.latestPentest.status, paint2) : paint2("dim", "\u2014")
25878
- ])
25879
- ]);
26639
+ width2,
26640
+ 0
26641
+ );
26642
+ return [
26643
+ rows,
26644
+ "",
26645
+ next(paint2, "lockpick status <project>", "lockpick pulse <project>")
26646
+ ].join("\n");
25880
26647
  }
25881
26648
  function runBlock(label, run2, paint2, link) {
25882
26649
  if (!run2) return [`${paint2("bold", label)} ${paint2("dim", "none yet")}`];
@@ -25919,24 +26686,27 @@ function formatStatus(status2, link, paint2 = plain) {
25919
26686
  lines.push("", ...status2.caveats.map((caveat) => paint2("dim", caveat)));
25920
26687
  return lines.filter((line, index) => line !== "" || lines[index - 1] !== "").join("\n");
25921
26688
  }
25922
- function formatRuns(runs2, paint2 = plain) {
26689
+ function formatRuns(runs2, paint2 = plain, width2) {
25923
26690
  if (runs2.length === 0) return "No runs match.";
25924
- return table([
26691
+ return table(
25925
26692
  [
25926
- paint2("bold", "RUN"),
25927
- paint2("bold", "KIND"),
25928
- paint2("bold", "STATUS"),
25929
- paint2("bold", "CREATED"),
25930
- paint2("bold", "TARGET")
26693
+ [
26694
+ paint2("bold", "RUN"),
26695
+ paint2("bold", "KIND"),
26696
+ paint2("bold", "STATUS"),
26697
+ paint2("bold", "CREATED"),
26698
+ paint2("bold", "TARGET")
26699
+ ],
26700
+ ...runs2.map((run2) => [
26701
+ run2.id,
26702
+ runKindLabel(run2),
26703
+ statusColor(run2.status, paint2),
26704
+ date5(run2.createdAt),
26705
+ run2.target
26706
+ ])
25931
26707
  ],
25932
- ...runs2.map((run2) => [
25933
- run2.id,
25934
- runKindLabel(run2),
25935
- statusColor(run2.status, paint2),
25936
- date5(run2.createdAt),
25937
- run2.target
25938
- ])
25939
- ]);
26708
+ width2
26709
+ );
25940
26710
  }
25941
26711
  function formatRun(run2, link, paint2 = plain) {
25942
26712
  const lines = [
@@ -25957,7 +26727,25 @@ function formatRun(run2, link, paint2 = plain) {
25957
26727
  "Limits (anything outside them is unknown, not passing):",
25958
26728
  ...run2.coverage.limitations.map((item) => ` - ${item}`)
25959
26729
  );
26730
+ if (run2.exposureCheck) {
26731
+ const [headline, ...limits] = exposureCheckLines(run2.exposureCheck);
26732
+ lines.push(
26733
+ `Exposure ${headline}`,
26734
+ ...limits.map((item) => ` - ${item}`)
26735
+ );
26736
+ }
25960
26737
  lines.push(paint2("dim", link(run2.webPath)));
26738
+ const open4 = Object.values(run2.open).reduce((sum, count) => sum + count, 0);
26739
+ if (!CLI_TERMINAL_RUN_STATUSES.includes(run2.status))
26740
+ lines.push(next(paint2, `lockpick watch ${run2.id}`));
26741
+ else if (run2.status === "succeeded" && open4 > 0)
26742
+ lines.push(
26743
+ next(
26744
+ paint2,
26745
+ `lockpick findings ${run2.id}`,
26746
+ `lockpick fix ${run2.id} --agent claude`
26747
+ )
26748
+ );
25961
26749
  return lines.join("\n");
25962
26750
  }
25963
26751
  var SEVERITY_STYLE = {
@@ -25967,22 +26755,46 @@ var SEVERITY_STYLE = {
25967
26755
  low: "dim",
25968
26756
  info: "dim"
25969
26757
  };
25970
- function formatFindings(findings2, paint2 = plain) {
25971
- if (findings2.length === 0) return "No findings need attention.";
25972
- return table([
25973
- [
25974
- paint2("bold", "SEVERITY"),
25975
- paint2("bold", "STATE"),
25976
- paint2("bold", "FINDING"),
25977
- paint2("bold", "TITLE")
25978
- ],
25979
- ...findings2.map((finding2) => [
25980
- paint2(SEVERITY_STYLE[finding2.severity], finding2.severity),
25981
- finding2.state,
25982
- finding2.id,
25983
- finding2.title
25984
- ])
25985
- ]);
26758
+ function formatFindings(findings2, paint2 = plain, width2) {
26759
+ if (findings2.length === 0)
26760
+ return paint2("green", "No findings need attention.");
26761
+ const bySeverity = CLI_SEVERITIES.map(
26762
+ (severity) => [
26763
+ severity,
26764
+ findings2.filter((finding2) => finding2.severity === severity).length
26765
+ ]
26766
+ ).filter(([, count]) => count > 0);
26767
+ const runId = findings2[0].runId;
26768
+ return [
26769
+ table(
26770
+ [
26771
+ [
26772
+ paint2("bold", "SEVERITY"),
26773
+ paint2("bold", "STATE"),
26774
+ paint2("bold", "TITLE"),
26775
+ paint2("bold", "FINDING")
26776
+ ],
26777
+ ...findings2.map((finding2) => [
26778
+ paint2(SEVERITY_STYLE[finding2.severity], finding2.severity),
26779
+ finding2.state,
26780
+ finding2.title,
26781
+ paint2("dim", finding2.id)
26782
+ ])
26783
+ ],
26784
+ // Titles give way first; a long id is never cut.
26785
+ width2,
26786
+ 2
26787
+ ),
26788
+ "",
26789
+ `${plural(findings2.length, "finding")}: ${bySeverity.map(
26790
+ ([severity, count]) => paint2(SEVERITY_STYLE[severity], `${count} ${severity}`)
26791
+ ).join(", ")}`,
26792
+ next(
26793
+ paint2,
26794
+ `lockpick finding ${runId} <finding>`,
26795
+ `lockpick brief ${runId}`
26796
+ )
26797
+ ].join("\n");
25986
26798
  }
25987
26799
  function formatFinding(finding2, link, paint2 = plain) {
25988
26800
  const lines = [
@@ -26318,6 +27130,250 @@ function formatRelevant(tags, paint2 = plain) {
26318
27130
  }
26319
27131
  return lines.join("\n");
26320
27132
  }
27133
+ var DOCTOR_MARK = {
27134
+ PASS: ["green", "\u2714"],
27135
+ WARN: ["yellow", "!"],
27136
+ FAIL: ["red", "\u2716"]
27137
+ };
27138
+ function formatDoctor(checks, paint2 = plain, docsUrl2) {
27139
+ const width2 = Math.max(...checks.map((check3) => check3.name.length));
27140
+ const lines = checks.map((check3) => {
27141
+ const [style, mark] = DOCTOR_MARK[check3.status];
27142
+ return [
27143
+ `${paint2(style, `${mark} ${check3.status}`)} ${check3.name.padEnd(width2)} ${check3.detail}`,
27144
+ ...check3.nextStep ? [`${" ".repeat(width2 + 10)}${paint2("dim", `\u2192 ${check3.nextStep}`)}`] : []
27145
+ ].join("\n");
27146
+ });
27147
+ const failed = checks.filter((check3) => check3.status === "FAIL").length;
27148
+ const warned = checks.filter((check3) => check3.status === "WARN").length;
27149
+ lines.push(
27150
+ "",
27151
+ failed ? paint2("red", `${plural(failed, "check")} failed.`) : warned ? paint2("yellow", `Ready, with ${plural(warned, "warning")}.`) : paint2("green", "Everything checks out.")
27152
+ );
27153
+ if (docsUrl2 && (failed || warned))
27154
+ lines.push(paint2("dim", `Help: ${docsUrl2}`));
27155
+ return lines.join("\n");
27156
+ }
27157
+ function formatDocSearch(search, paint2 = plain) {
27158
+ if (search.results.length === 0)
27159
+ return `No Lockpick docs match "${search.query}".`;
27160
+ return search.results.map(
27161
+ (hit) => [
27162
+ `${paint2("bold", hit.title)}${hit.heading ? paint2("dim", ` \u203A ${hit.heading}`) : ""}`,
27163
+ ` ${hit.snippet.replace(/\s+/g, " ").trim()}`,
27164
+ ` ${paint2("cyan", hit.url)}`
27165
+ ].join("\n")
27166
+ ).join("\n\n");
27167
+ }
27168
+
27169
+ // src/docs.ts
27170
+ var DOCS = {
27171
+ cli: "/docs/cli/install",
27172
+ signIn: "/docs/cli/install#sign-in",
27173
+ exitCodes: "/docs/cli/install#exit-codes",
27174
+ commands: "/docs/cli/commands",
27175
+ configuration: "/docs/cli/configuration",
27176
+ ci: "/docs/cli/ci",
27177
+ permissions: "/docs/mcp/permissions",
27178
+ pulseOrDeep: "/docs/get-started/pulse-or-deep",
27179
+ fix: "/docs/get-started/fix-what-we-found",
27180
+ checkAgain: "/docs/get-started/check-again"
27181
+ };
27182
+ function docsPageForError(code) {
27183
+ switch (code) {
27184
+ case "UNAUTHENTICATED":
27185
+ case "NO_HOST":
27186
+ return "signIn";
27187
+ case "INSUFFICIENT_SCOPE":
27188
+ case "FORBIDDEN":
27189
+ return "permissions";
27190
+ case "ENTITLEMENT_REQUIRED":
27191
+ case "FEATURE_UNAVAILABLE":
27192
+ case "PROOF_REQUIRED":
27193
+ return "pulseOrDeep";
27194
+ case "NOT_LOCKPICK":
27195
+ case "CONTRACT_MISMATCH":
27196
+ case "NETWORK":
27197
+ case "TIMEOUT":
27198
+ return "configuration";
27199
+ case "VALIDATION_ERROR":
27200
+ case "INVALID_INPUT":
27201
+ return "commands";
27202
+ default:
27203
+ return null;
27204
+ }
27205
+ }
27206
+ function docsUrl(host, page) {
27207
+ if (!host) return null;
27208
+ try {
27209
+ return new URL(DOCS[page], host).toString();
27210
+ } catch {
27211
+ return null;
27212
+ }
27213
+ }
27214
+
27215
+ // src/ci.ts
27216
+ var FAIL_ON = ["critical", "high", "medium", "low"];
27217
+ function failOnArg(value) {
27218
+ if (value === void 0) return void 0;
27219
+ if (FAIL_ON.includes(value)) return value;
27220
+ throw new LockpickApiError(
27221
+ `--fail-on must be one of: ${FAIL_ON.join(", ")}`,
27222
+ 400,
27223
+ "VALIDATION_ERROR"
27224
+ );
27225
+ }
27226
+ function countAtOrAbove(open4, threshold) {
27227
+ return FAIL_ON.slice(0, FAIL_ON.indexOf(threshold) + 1).reduce(
27228
+ (sum, severity) => sum + open4[severity],
27229
+ 0
27230
+ );
27231
+ }
27232
+ function openCounts(findings2) {
27233
+ const counts2 = { critical: 0, high: 0, medium: 0, low: 0 };
27234
+ for (const finding2 of findings2)
27235
+ if (finding2.severity !== "info") counts2[finding2.severity]++;
27236
+ return counts2;
27237
+ }
27238
+ var LOCKPICK_ACTION = {
27239
+ ref: "promptadvisers/lockpick-action@v1",
27240
+ inputs: {
27241
+ host: "host",
27242
+ site: "site",
27243
+ key: "key",
27244
+ failOn: "fail-on"
27245
+ }
27246
+ };
27247
+ var CI_KEY_SECRET = "LOCKPICK_KEY";
27248
+ var CI_WORKFLOW_FILE = ".github/workflows/lockpick.yml";
27249
+ var SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
27250
+ function githubWorkflow(options) {
27251
+ if (!SAFE_ID.test(options.siteId))
27252
+ throw new LockpickApiError(
27253
+ `Site id "${options.siteId}" cannot be written into a workflow.`,
27254
+ 400,
27255
+ "VALIDATION_ERROR"
27256
+ );
27257
+ const host = new URL(options.host).origin;
27258
+ const name = options.siteName.replace(/[^\p{L}\p{N} ._-]/gu, "").trim();
27259
+ const step = options.action ? [
27260
+ ` - name: Pulse check of ${name || options.siteId}`,
27261
+ ` uses: ${options.actionRef ?? LOCKPICK_ACTION.ref}`,
27262
+ ` with:`,
27263
+ ` ${LOCKPICK_ACTION.inputs.host}: '${host}'`,
27264
+ ` ${LOCKPICK_ACTION.inputs.site}: '${options.siteId}'`,
27265
+ ` ${LOCKPICK_ACTION.inputs.key}: \${{ secrets.${CI_KEY_SECRET} }}`,
27266
+ ` ${LOCKPICK_ACTION.inputs.failOn}: '${options.failOn}'`
27267
+ ] : [
27268
+ ` - uses: actions/setup-node@v4`,
27269
+ ` with:`,
27270
+ ` node-version: 22`,
27271
+ ` - name: Pulse check of ${name || options.siteId}`,
27272
+ ` env:`,
27273
+ ` LOCKPICK_HOST: '${host}'`,
27274
+ ` LOCKPICK_KEY: \${{ secrets.${CI_KEY_SECRET} }}`,
27275
+ ` run: npx -y lockpicks@${options.cliVersion} pulse ${options.siteId} --no-lockfile --fail-on ${options.failOn} --timeout 900`
27276
+ ];
27277
+ return [
27278
+ `# Lockpick Pulse check, written by \`lockpick ci init\`.`,
27279
+ `# Each run uses one Pulse from your allowance (or 1 credit), so it runs`,
27280
+ `# when you publish a release and when you start it by hand.`,
27281
+ `# It fails when open findings are ${options.failOn} or more severe.`,
27282
+ ...options.docsUrl ? [`# Docs: ${options.docsUrl}`] : [],
27283
+ `name: Lockpick`,
27284
+ `on:`,
27285
+ ` release:`,
27286
+ ` types: [published]`,
27287
+ ` workflow_dispatch:`,
27288
+ `permissions:`,
27289
+ ` contents: read`,
27290
+ `concurrency:`,
27291
+ ` group: lockpick-${options.siteId}`,
27292
+ ` cancel-in-progress: false`,
27293
+ `jobs:`,
27294
+ ` pulse:`,
27295
+ ` name: Lockpick Pulse`,
27296
+ ` runs-on: ubuntu-latest`,
27297
+ ` timeout-minutes: 20`,
27298
+ ` steps:`,
27299
+ ...step,
27300
+ ""
27301
+ ].join("\n");
27302
+ }
27303
+ function isPrivateHost(host) {
27304
+ const { hostname: hostname4 } = new URL(host);
27305
+ return hostname4 === "localhost" || hostname4.endsWith(".local") || hostname4.endsWith(".ts.net") || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(
27306
+ hostname4
27307
+ ) || hostname4 === "[::1]" || !hostname4.includes(".");
27308
+ }
27309
+
27310
+ // src/progress.ts
27311
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
27312
+ function elapsed(ms) {
27313
+ const seconds = Math.floor(ms / 1e3);
27314
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
27315
+ }
27316
+ function bar(percent) {
27317
+ const filled = Math.round(Math.min(100, Math.max(0, percent)) / 10);
27318
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(10 - filled)} ${percent}%`;
27319
+ }
27320
+ function progress(paint2, options = {}) {
27321
+ const stream = options.stream ?? process.stderr;
27322
+ const now = options.now ?? Date.now;
27323
+ const env = options.env ?? process.env;
27324
+ const started = now();
27325
+ let text2 = "Waiting";
27326
+ let percent = null;
27327
+ if (options.quiet) return { update: () => {
27328
+ }, stop: () => {
27329
+ } };
27330
+ const live = Boolean(stream.isTTY) && env.TERM !== "dumb" && !env.CI;
27331
+ if (!live) {
27332
+ let last = "";
27333
+ return {
27334
+ update: (next2, nextPercent) => {
27335
+ const line = `${next2}${nextPercent != null ? ` (${nextPercent}%)` : ""}`;
27336
+ if (line !== last)
27337
+ stream.write(
27338
+ `${terminalSafe(`${new Date(now()).toISOString().slice(11, 19)} ${line}`)}
27339
+ `
27340
+ );
27341
+ last = line;
27342
+ },
27343
+ stop: (final) => {
27344
+ if (final) stream.write(`${terminalSafe(final)}
27345
+ `);
27346
+ }
27347
+ };
27348
+ }
27349
+ let frame = 0;
27350
+ const draw = () => {
27351
+ const room = Math.max(10, (stream.columns ?? 80) - 32);
27352
+ const parts = [
27353
+ paint2("cyan", FRAMES[frame++ % FRAMES.length]),
27354
+ text2.length > room ? `${text2.slice(0, room - 1)}\u2026` : text2,
27355
+ percent != null ? paint2("dim", bar(percent)) : "",
27356
+ paint2("dim", elapsed(now() - started))
27357
+ ].filter(Boolean);
27358
+ const line = terminalSafe(parts.join(" "));
27359
+ stream.write(`\r\x1B[2K${line}`);
27360
+ };
27361
+ const timer = setInterval(draw, 100);
27362
+ timer.unref?.();
27363
+ return {
27364
+ update: (next2, nextPercent) => {
27365
+ text2 = next2;
27366
+ percent = nextPercent ?? null;
27367
+ draw();
27368
+ },
27369
+ stop: (final) => {
27370
+ clearInterval(timer);
27371
+ stream.write("\r\x1B[2K");
27372
+ if (final) stream.write(`${terminalSafe(final)}
27373
+ `);
27374
+ }
27375
+ };
27376
+ }
26321
27377
 
26322
27378
  // src/login.ts
26323
27379
  var SCOPE_ACCESS = {
@@ -26422,7 +27478,7 @@ import { delimiter, join as join3, sep } from "node:path";
26422
27478
  import { createInterface } from "node:readline/promises";
26423
27479
 
26424
27480
  // src/version.ts
26425
- var CLI_VERSION = true ? "0.3.0" : "0.0.0-dev";
27481
+ var CLI_VERSION = true ? "0.4.0" : "0.0.0-dev";
26426
27482
  var CLI_PACKAGE = true ? "lockpicks" : "lockpicks";
26427
27483
 
26428
27484
  // src/install.ts
@@ -34990,6 +36046,13 @@ function renderRun(run2, link) {
34990
36046
  "- Limits (anything outside them is unknown, not passing):",
34991
36047
  ...run2.coverage.limitations.map((item) => ` - ${item}`)
34992
36048
  );
36049
+ if (run2.exposureCheck) {
36050
+ const [headline, ...limits] = exposureCheckLines(run2.exposureCheck);
36051
+ lines.push(
36052
+ `- Exposure check: ${headline}`,
36053
+ ...limits.map((item) => ` - ${item}`)
36054
+ );
36055
+ }
34993
36056
  lines.push(`- Web: ${link(run2.webPath)}`);
34994
36057
  return lines.join("\n");
34995
36058
  }
@@ -35168,9 +36231,72 @@ function levelName(scan) {
35168
36231
  const level = scan.assessmentLevel ?? "pulse";
35169
36232
  return level === "deep" ? "Deep" : level === "standard" ? "Standard" : "Pulse";
35170
36233
  }
36234
+ var OUTCOME_REASON = {
36235
+ "no-address": "no DNS address",
36236
+ "dns-error": "the DNS lookup failed",
36237
+ "private-address": "it points to a private network address",
36238
+ refused: "the connection was refused",
36239
+ "no-answer": "the server did not answer in time",
36240
+ "connection-closed": "the server closed the connection",
36241
+ certificate: "the certificate failed the checks a browser makes",
36242
+ "old-tls": "only an old TLS version was offered",
36243
+ "large-headers": "the response headers were over the 64 KB Lockpick reads",
36244
+ "invalid-response": "the response was not valid HTTP",
36245
+ www: "the homepage redirects to its www address",
36246
+ "other-site": "the homepage redirects to another site",
36247
+ "sign-in": "the homepage redirects to a sign-in page",
36248
+ "redirect-loop": "the homepage redirects in a loop",
36249
+ redirect: "the homepage redirect is not one a run follows",
36250
+ "bot-check": "a bot check answered instead of the homepage",
36251
+ denied: "the site refused the request",
36252
+ "rate-limited": "the site asked Lockpick to slow down",
36253
+ "not-found": "the homepage answered not found",
36254
+ gone: "the homepage answered gone",
36255
+ "client-error": "the homepage answered with an error",
36256
+ "server-error": "the server answered with an error",
36257
+ maintenance: "the site is down for maintenance",
36258
+ parked: "the homepage is a parked domain page",
36259
+ empty: "the homepage is empty",
36260
+ "not-html": "the homepage is not an HTML page",
36261
+ "app-shell": "the homepage is an app shell built in the browser",
36262
+ interrupted: "the connection closed while the homepage loaded",
36263
+ "not-delivered": "the run stopped on Lockpick's side before its report was ready"
36264
+ };
36265
+ var VENDOR_CHECK = {
36266
+ cloudflare: "blocked by Cloudflare's bot check",
36267
+ vercel: "blocked by Vercel's bot check",
36268
+ other: "blocked by a firewall"
36269
+ };
36270
+ var OUTCOME_LEVEL = {
36271
+ "headers-only": "headers only",
36272
+ blocked: "blocked",
36273
+ transport: "no secure connection",
36274
+ unreachable: "unreachable",
36275
+ "terminal-failure": "did not finish"
36276
+ };
36277
+ var COVERAGE_TEXT = {
36278
+ page: "pages, their scripts and public records",
36279
+ "headers-and-records": "response headers and public records",
36280
+ "records-only": "public records only",
36281
+ none: "nothing could be collected"
36282
+ };
36283
+ function outcomeText(outcome) {
36284
+ if (!outcome || outcome.level === "full") return null;
36285
+ if (outcome.level === "blocked" && outcome.reason === "bot-check")
36286
+ return VENDOR_CHECK[outcome.vendor ?? "other"];
36287
+ const reason = outcome.reason ? OUTCOME_REASON[outcome.reason] : null;
36288
+ const status2 = outcome.homepageStatus && outcome.reason !== "rate-limited" ? ` (HTTP ${outcome.homepageStatus})` : "";
36289
+ const contact = outcome.contacted === void 0 ? "" : outcome.contacted ? ", after contacting the site (it counted as a run)" : ", before contacting the site (it did not count as a run)";
36290
+ return `${OUTCOME_LEVEL[outcome.level]}${reason ? `, ${reason}${status2}` : ""}${contact}`;
36291
+ }
36292
+ var UNFINISHED = /* @__PURE__ */ new Set(["queued", "running", "cancelling"]);
35171
36293
  function headlineText(scan) {
35172
36294
  const { score, grade, letter } = scan.headline;
35173
- if (score === null) return "no grade yet";
36295
+ if (score === null) {
36296
+ const outcome = outcomeText(scan.outcome);
36297
+ if (outcome) return `not graded, ${outcome}`;
36298
+ return UNFINISHED.has(scan.status) ? "no grade yet" : "not graded";
36299
+ }
35174
36300
  return `${letter ?? ""}${letter ? ", " : ""}${grade ?? "ungraded"}, score ${score}/100`;
35175
36301
  }
35176
36302
  function scanLine(scan) {
@@ -35239,6 +36365,16 @@ function renderScan(scan) {
35239
36365
  lines.push(
35240
36366
  `- Progress: ${inlineText(scan.progress.message, 200)}${scan.progress.percent !== null ? ` (${scan.progress.percent}%)` : ""}`
35241
36367
  );
36368
+ if (scan.outcome)
36369
+ lines.push(`- Based on: ${COVERAGE_TEXT[scan.outcome.coverage]}`);
36370
+ if (scan.outcome?.level === "blocked")
36371
+ lines.push(
36372
+ `- Lockpick never tries to get past a bot check or firewall. The owner can let it through: ${new URL("/docs/protect/firewall", scan.webUrl).href}`
36373
+ );
36374
+ if (scan.outcome?.redirectOrigin)
36375
+ lines.push(
36376
+ `- Check instead: ${codeSpan(scan.outcome.redirectOrigin)} (the address the homepage redirects to)`
36377
+ );
35242
36378
  if (scan.coverage.percent !== null)
35243
36379
  lines.push(`- Coverage: ${scan.coverage.percent}%`);
35244
36380
  if (scan.coverage.limitations.length)
@@ -35246,6 +36382,13 @@ function renderScan(scan) {
35246
36382
  "- Limits (anything outside them is unknown, not passing):",
35247
36383
  ...scan.coverage.limitations.slice(0, 10).map((item) => ` - ${inlineText(item, 300)}`)
35248
36384
  );
36385
+ if (scan.exposureCheck) {
36386
+ const [headline, ...limits] = exposureCheckLines(scan.exposureCheck);
36387
+ lines.push(
36388
+ `- Exposure check: ${headline}`,
36389
+ ...limits.map((item) => ` - ${item}`)
36390
+ );
36391
+ }
35249
36392
  lines.push(`- Web: ${scan.webUrl}`);
35250
36393
  if (scan.suggestedPollAfterSeconds !== null)
35251
36394
  lines.push(
@@ -36433,12 +37576,12 @@ var startsPulse = {
36433
37576
  function runResult(backend, run2, format3) {
36434
37577
  if (format3 === "json") return json2(run2);
36435
37578
  const body = renderRun(run2, (path) => backend.webUrl(path));
36436
- const next = CLI_TERMINAL_RUN_STATUSES.includes(run2.status) ? run2.baselineRunId && run2.status === "succeeded" ? `
37579
+ const next2 = CLI_TERMINAL_RUN_STATUSES.includes(run2.status) ? run2.baselineRunId && run2.status === "succeeded" ? `
36437
37580
 
36438
37581
  This was a recheck of \`${run2.baselineRunId}\`. Call lockpick_compare_runs with runId \`${run2.id}\` to see what changed.` : "" : `
36439
37582
 
36440
37583
  Still ${run2.status}. Call lockpick_wait_for_run with runId \`${run2.id}\` to keep waiting.`;
36441
- return text(body + next);
37584
+ return text(body + next2);
36442
37585
  }
36443
37586
  var whoamiTool = defineTool({
36444
37587
  name: "lockpick_whoami",
@@ -36879,9 +38022,9 @@ function pollSeconds(value) {
36879
38022
  );
36880
38023
  return seconds;
36881
38024
  }
36882
- function progressLine(run2) {
38025
+ function progressLine(run2, withPercent = true) {
36883
38026
  const message = run2.progress?.message ? ` \xB7 ${run2.progress.message}` : "";
36884
- const percent = run2.progress?.percent != null ? ` (${run2.progress.percent}%)` : "";
38027
+ const percent = withPercent && run2.progress?.percent != null ? ` (${run2.progress.percent}%)` : "";
36885
38028
  return `${run2.status}${message}${percent}`;
36886
38029
  }
36887
38030
 
@@ -37118,28 +38261,47 @@ var common = {
37118
38261
  }
37119
38262
  };
37120
38263
  var paint = painter();
37121
- var out = (text2) => void process.stdout.write(`${text2}
38264
+ var epaint = painter(process.stderr);
38265
+ var out = (text2) => void process.stdout.write(`${terminalSafe(text2)}
37122
38266
  `);
37123
- var err = (text2) => void process.stderr.write(`${text2}
38267
+ var err = (text2) => void process.stderr.write(`${terminalSafe(text2)}
37124
38268
  `);
38269
+ var width = () => terminalWidth(process.stdout);
37125
38270
  var sleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
37126
38271
  process.stdout.on("error", (error51) => {
37127
38272
  if (error51.code === "EPIPE") process.exit(EXIT.ok);
37128
38273
  throw error51;
37129
38274
  });
38275
+ async function knownHost() {
38276
+ const flag = process.argv.indexOf("--host");
38277
+ const explicit = (flag > -1 ? process.argv[flag + 1] : void 0) ?? process.argv.find((arg) => arg.startsWith("--host="))?.slice(7) ?? process.env.LOCKPICK_HOST;
38278
+ try {
38279
+ if (explicit) return normalizeHost(explicit);
38280
+ return (await new CredentialStore().profile())?.host ?? null;
38281
+ } catch {
38282
+ return null;
38283
+ }
38284
+ }
38285
+ var docsLink = async (page) => docsUrl(await knownHost(), page);
37130
38286
  async function action(work) {
37131
38287
  try {
37132
38288
  await work();
37133
38289
  } catch (error51) {
37134
38290
  const apiError = error51 instanceof LockpickApiError ? error51 : null;
37135
- const message = error51 instanceof Error ? error51.message : String(error51);
38291
+ const message = neutralize(
38292
+ error51 instanceof Error ? error51.message : String(error51)
38293
+ );
37136
38294
  process.exitCode = exitCodeFor(error51);
37137
38295
  const agentError = apiError?.agentError ?? null;
38296
+ const code = agentError?.code ?? apiError?.code ?? null;
38297
+ const page = docsPageForError(code);
38298
+ const help = page ? await docsLink(page) : null;
37138
38299
  if (process.argv.includes("--json"))
37139
38300
  err(
37140
38301
  JSON.stringify({
37141
38302
  error: agentError?.message ?? message,
37142
- code: agentError?.code ?? apiError?.code ?? null,
38303
+ code,
38304
+ ...help ? { docsUrl: help } : {},
37143
38305
  ...agentError ? {
37144
38306
  nextStep: agentError.nextStep,
37145
38307
  retryAfterSeconds: agentError.retryAfterSeconds,
@@ -37151,10 +38313,11 @@ async function action(work) {
37151
38313
  else {
37152
38314
  const [first, ...rest] = formatErrorLines(
37153
38315
  agentError?.message ?? message,
37154
- agentError
38316
+ agentError,
38317
+ help
37155
38318
  );
37156
- err(paint("red", first));
37157
- for (const line of rest) err(line);
38319
+ err(epaint("red", `\u2716 ${first}`));
38320
+ for (const line of rest) err(` ${line}`);
37158
38321
  }
37159
38322
  }
37160
38323
  }
@@ -37173,6 +38336,46 @@ function parse3(schema, value, name, allowed) {
37173
38336
  );
37174
38337
  }
37175
38338
  }
38339
+ var NOT_IN_IDS = /[\u0000-\u001f\u007f\s]/;
38340
+ function idArg(value, name) {
38341
+ if (/^\.{1,2}$/.test(value) || NOT_IN_IDS.test(value) || !value || value.length > 300)
38342
+ throw new LockpickApiError(
38343
+ `${name} "${neutralize(value).slice(0, 60)}" is not a valid id. Copy it from \`lockpick runs\` or \`lockpick findings\`.`,
38344
+ 400,
38345
+ "VALIDATION_ERROR"
38346
+ );
38347
+ return value;
38348
+ }
38349
+ function limitArg(value) {
38350
+ if (value === void 0) return void 0;
38351
+ const limit = Number(value);
38352
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
38353
+ throw new LockpickApiError(
38354
+ "--limit must be a whole number from 1 to 100.",
38355
+ 400,
38356
+ "VALIDATION_ERROR"
38357
+ );
38358
+ return limit;
38359
+ }
38360
+ var failOnFlag = {
38361
+ failOn: {
38362
+ type: "string",
38363
+ description: `Exit 8 when open findings are this severe or worse: ${FAIL_ON.join(", ")} (for CI)`
38364
+ }
38365
+ };
38366
+ function gate(open4, threshold, json3) {
38367
+ if (!threshold) return;
38368
+ const count = countAtOrAbove(open4, threshold);
38369
+ if (count === 0) return;
38370
+ if (!json3)
38371
+ err(
38372
+ epaint(
38373
+ "red",
38374
+ `\u2716 ${count} open ${count === 1 ? "finding is" : "findings are"} ${threshold} or more severe (--fail-on ${threshold}).`
38375
+ )
38376
+ );
38377
+ process.exitCode = EXIT.findings;
38378
+ }
37176
38379
  function agentArg(value) {
37177
38380
  if (value === void 0) return void 0;
37178
38381
  if (isPromptAgent(value)) return value;
@@ -37229,17 +38432,16 @@ var login = defineCommand({
37229
38432
  run: ({ args }) => action(async () => {
37230
38433
  const store = new CredentialStore();
37231
38434
  const explicit = args.host ?? process.env.LOCKPICK_HOST;
37232
- const host = explicit ? normalizeHost(explicit) : (await store.profile())?.host;
38435
+ let host = explicit ? normalizeHost(explicit) : (await store.profile())?.host;
37233
38436
  if (!host)
37234
38437
  throw new LockpickApiError(
37235
38438
  "Which Lockpick? Run `lockpick login --host <url>`.",
37236
38439
  400,
37237
38440
  "NO_HOST"
37238
38441
  );
37239
- const client = new LockpickClient(host, null);
37240
38442
  const requested = requestedScopes(args);
37241
- const approved = await deviceLogin(
37242
- client,
38443
+ const login2 = (at) => deviceLogin(
38444
+ new LockpickClient(at, null),
37243
38445
  {
37244
38446
  clientName: "Lockpick CLI",
37245
38447
  hostname: hostname3(),
@@ -37248,8 +38450,20 @@ var login = defineCommand({
37248
38450
  scopes: requested
37249
38451
  },
37250
38452
  { print: args.json ? err : out, openBrowser, sleep },
37251
- { browser: args.browser, host }
38453
+ { browser: args.browser, host: at }
37252
38454
  );
38455
+ let approved;
38456
+ try {
38457
+ approved = await login2(host);
38458
+ } catch (error51) {
38459
+ if (!(error51 instanceof HostRedirectError) || !error51.canonicalHost)
38460
+ throw error51;
38461
+ (args.json ? err : out)(
38462
+ `${host} redirects to ${error51.canonicalHost}; signing in there.`
38463
+ );
38464
+ host = error51.canonicalHost;
38465
+ approved = await login2(host);
38466
+ }
37253
38467
  const { inKeychain } = await store.save({
37254
38468
  host,
37255
38469
  workspace: approved.workspace,
@@ -37354,14 +38568,8 @@ var doctor = defineCommand({
37354
38568
  host: args.host,
37355
38569
  scope: args.scope
37356
38570
  });
37357
- emit(
37358
- args.json,
37359
- checks,
37360
- () => checks.map(
37361
- (check3) => `[${check3.status}] ${check3.name}: ${check3.detail}${check3.nextStep ? `
37362
- ${check3.nextStep}` : ""}`
37363
- ).join("\n")
37364
- );
38571
+ const help = await docsLink("cli");
38572
+ emit(args.json, checks, () => formatDoctor(checks, paint, help));
37365
38573
  if (checks.some((check3) => check3.status === "FAIL")) process.exitCode = 1;
37366
38574
  })
37367
38575
  });
@@ -37374,7 +38582,7 @@ var projects = defineCommand({
37374
38582
  run: ({ args }) => action(async () => {
37375
38583
  const client = await clientFor({ host: args.host });
37376
38584
  const list = await client.listProjects();
37377
- emit(args.json, list, () => formatProjects(list, paint));
38585
+ emit(args.json, list, () => formatProjects(list, paint, width()));
37378
38586
  })
37379
38587
  });
37380
38588
  var lockfileArg = {
@@ -37487,7 +38695,11 @@ var status = defineCommand({
37487
38695
  const client = await clientFor({ host: args.host });
37488
38696
  if (!args.project) {
37489
38697
  const list = await client.listProjects();
37490
- return emit(args.json, list, () => formatProjects(list, paint));
38698
+ return emit(
38699
+ args.json,
38700
+ list,
38701
+ () => formatProjects(list, paint, width())
38702
+ );
37491
38703
  }
37492
38704
  const project = await findProject(client, args.project);
37493
38705
  const report = await client.projectStatus(project.id);
@@ -37526,9 +38738,9 @@ var runs = defineCommand({
37526
38738
  "status",
37527
38739
  CLI_RUN_STATUSES
37528
38740
  ),
37529
- limit: args.limit ? Number(args.limit) : void 0
38741
+ limit: limitArg(args.limit)
37530
38742
  });
37531
- emit(args.json, list, () => formatRuns(list, paint));
38743
+ emit(args.json, list, () => formatRuns(list, paint, width()));
37532
38744
  })
37533
38745
  });
37534
38746
  var run = defineCommand({
@@ -37539,7 +38751,7 @@ var run = defineCommand({
37539
38751
  },
37540
38752
  run: ({ args }) => action(async () => {
37541
38753
  const client = await clientFor({ host: args.host });
37542
- const detail = await client.getRun(args.runId);
38754
+ const detail = await client.getRun(idArg(args.runId, "Run"));
37543
38755
  emit(
37544
38756
  args.json,
37545
38757
  detail,
@@ -37548,18 +38760,19 @@ var run = defineCommand({
37548
38760
  })
37549
38761
  });
37550
38762
  async function follow(client, runId, args) {
37551
- let last = "";
37552
- const run2 = await waitForRun(client, runId, {
37553
- intervalMs: pollSeconds(args.interval) * 1e3,
37554
- timeoutMs: waitSeconds(args.timeout, 600) * 1e3,
37555
- sleep,
37556
- onUpdate: (update) => {
37557
- const line = progressLine(update);
37558
- if (line !== last && !args.json)
37559
- err(`${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${line}`);
37560
- last = line;
37561
- }
37562
- });
38763
+ const threshold = failOnArg(args.failOn);
38764
+ const live = progress(epaint, { quiet: args.json });
38765
+ let run2;
38766
+ try {
38767
+ run2 = await waitForRun(client, runId, {
38768
+ intervalMs: pollSeconds(args.interval) * 1e3,
38769
+ timeoutMs: waitSeconds(args.timeout, 600) * 1e3,
38770
+ sleep,
38771
+ onUpdate: (update) => live.update(progressLine(update, false), update.progress?.percent)
38772
+ });
38773
+ } finally {
38774
+ live.stop();
38775
+ }
37563
38776
  emit(
37564
38777
  args.json,
37565
38778
  run2,
@@ -37570,6 +38783,7 @@ async function follow(client, runId, args) {
37570
38783
  `Wait ended; ${run2.id} is still ${run2.status}. Resume with lockpick watch ${run2.id}.`
37571
38784
  );
37572
38785
  if (run2.status !== "succeeded") process.exitCode = EXIT.unfinished;
38786
+ else gate(run2.open, threshold, args.json);
37573
38787
  }
37574
38788
  var watch = defineCommand({
37575
38789
  meta: {
@@ -37578,6 +38792,7 @@ var watch = defineCommand({
37578
38792
  },
37579
38793
  args: {
37580
38794
  runId: { type: "positional", description: "Run id", required: true },
38795
+ ...failOnFlag,
37581
38796
  interval: {
37582
38797
  type: "string",
37583
38798
  description: "Seconds between checks (default 5, backs off to 30)"
@@ -37589,8 +38804,9 @@ var watch = defineCommand({
37589
38804
  ...common
37590
38805
  },
37591
38806
  run: ({ args }) => action(async () => {
38807
+ failOnArg(args.failOn);
37592
38808
  const client = await clientFor({ host: args.host });
37593
- await follow(client, args.runId, args);
38809
+ await follow(client, idArg(args.runId, "Run"), args);
37594
38810
  })
37595
38811
  });
37596
38812
  var pulse = defineCommand({
@@ -37617,12 +38833,14 @@ var pulse = defineCommand({
37617
38833
  type: "string",
37618
38834
  description: "Maximum wait in seconds (default 600, max 3600)"
37619
38835
  },
38836
+ ...failOnFlag,
37620
38837
  ...lockfileArg,
37621
38838
  ...common
37622
38839
  },
37623
38840
  run: ({ args }) => action(async () => {
37624
38841
  pollSeconds(args.interval);
37625
38842
  waitSeconds(args.timeout, 600);
38843
+ failOnArg(args.failOn);
37626
38844
  const client = await clientFor({ host: args.host });
37627
38845
  const project = await findProject(client, args.project);
37628
38846
  const run2 = await client.startPulse(project.id, randomUUID6());
@@ -37635,7 +38853,7 @@ var pulse = defineCommand({
37635
38853
  );
37636
38854
  if (!args.json)
37637
38855
  err(
37638
- `Started Pulse ${run2.id} on ${run2.target}${run2.baselineRunId ? ` (recheck of ${run2.baselineRunId})` : ""}.`
38856
+ `${epaint("green", "\u2714")} Started Pulse ${run2.id} on ${run2.target}${run2.baselineRunId ? ` (recheck of ${run2.baselineRunId})` : ""}.`
37639
38857
  );
37640
38858
  await follow(client, run2.id, args);
37641
38859
  })
@@ -37670,15 +38888,21 @@ var check2 = defineCommand({
37670
38888
  idempotencyKey: randomUUID6()
37671
38889
  });
37672
38890
  const deadline = Date.now() + timeout * 1e3;
37673
- while (args.wait && (result.status === "queued" || result.status === "running") && Date.now() < deadline) {
37674
- await sleep(
37675
- Math.min(
37676
- (result.suggestedPollAfterSeconds ?? 5) * 1e3,
37677
- deadline - Date.now()
37678
- )
37679
- );
37680
- if (Date.now() >= deadline) break;
37681
- result = await client.agent.getQuickCheck(result.checkId);
38891
+ const live = progress(epaint, { quiet: args.json || !args.wait });
38892
+ try {
38893
+ while (args.wait && (result.status === "queued" || result.status === "running") && Date.now() < deadline) {
38894
+ live.update(`Quick check of ${args.url} \xB7 ${result.status}`);
38895
+ await sleep(
38896
+ Math.min(
38897
+ (result.suggestedPollAfterSeconds ?? 5) * 1e3,
38898
+ deadline - Date.now()
38899
+ )
38900
+ );
38901
+ if (Date.now() >= deadline) break;
38902
+ result = await client.agent.getQuickCheck(result.checkId);
38903
+ }
38904
+ } finally {
38905
+ live.stop();
37682
38906
  }
37683
38907
  emit(args.json, result, () => formatQuickCheck(result, paint));
37684
38908
  if (args.wait && (result.status === "queued" || result.status === "running"))
@@ -37737,8 +38961,10 @@ var sitesSwitch = defineCommand({
37737
38961
  },
37738
38962
  run: ({ args }) => action(async () => {
37739
38963
  if (!args.yes)
37740
- throw new Error(
37741
- `This removes site ${args.site} from monitoring. Run it again with --yes to confirm.`
38964
+ throw new LockpickApiError(
38965
+ `This removes site ${args.site} from monitoring. Run it again with --yes to confirm.`,
38966
+ 400,
38967
+ "VALIDATION_ERROR"
37742
38968
  );
37743
38969
  const client = await clientFor({ host: args.host });
37744
38970
  const switched = await client.agent.switchFreeSite({
@@ -37762,7 +38988,7 @@ var sites = defineCommand({
37762
38988
  return action(async () => {
37763
38989
  const client = await clientFor({ host: args.host });
37764
38990
  const list = await client.listProjects();
37765
- emit(args.json, list, () => formatProjects(list, paint));
38991
+ emit(args.json, list, () => formatProjects(list, paint, width()));
37766
38992
  });
37767
38993
  }
37768
38994
  });
@@ -37794,23 +39020,33 @@ var recheck = defineCommand({
37794
39020
  type: "string",
37795
39021
  description: "Maximum wait in seconds (default 600, max 3600)"
37796
39022
  },
39023
+ ...failOnFlag,
37797
39024
  ...lockfileArg,
37798
39025
  ...common
37799
39026
  },
37800
39027
  run: ({ args }) => action(async () => {
37801
39028
  pollSeconds(args.interval);
37802
39029
  waitSeconds(args.timeout, 600);
39030
+ failOnArg(args.failOn);
37803
39031
  const client = await clientFor({ host: args.host });
37804
39032
  if (args.dns) {
39033
+ idArg(args.target, "Scan");
39034
+ idArg(args.dns, "Finding");
37805
39035
  let result = await client.agent.recheckDns(args.target, {
37806
39036
  findingId: args.dns,
37807
39037
  idempotencyKey: randomUUID6()
37808
39038
  });
37809
39039
  const deadline = Date.now() + waitSeconds(args.timeout, 60) * 1e3;
37810
- while (args.wait && result.pending && Date.now() < deadline) {
37811
- await sleep(Math.min(3e3, deadline - Date.now()));
37812
- if (Date.now() >= deadline) break;
37813
- result = await client.agent.getDnsRecheck(args.target);
39040
+ const live = progress(epaint, { quiet: args.json || !args.wait });
39041
+ try {
39042
+ while (args.wait && result.pending && Date.now() < deadline) {
39043
+ live.update(`Reading DNS records again for ${args.dns}`);
39044
+ await sleep(Math.min(3e3, deadline - Date.now()));
39045
+ if (Date.now() >= deadline) break;
39046
+ result = await client.agent.getDnsRecheck(args.target);
39047
+ }
39048
+ } finally {
39049
+ live.stop();
37814
39050
  }
37815
39051
  emit(args.json, result, () => formatDnsRecheck(result, paint));
37816
39052
  if (args.wait && result.pending) {
@@ -37908,7 +39144,7 @@ var verifyCommand = (name, description) => defineCommand({
37908
39144
  "Not seen. Once it is published, run lockpick verify again. The value stays the same."
37909
39145
  )
37910
39146
  );
37911
- process.exitCode = 3;
39147
+ process.exitCode = EXIT.unfinished;
37912
39148
  })
37913
39149
  });
37914
39150
  var deepVerify = verifyCommand(
@@ -37942,14 +39178,14 @@ var deepCheck = defineCommand({
37942
39178
  const project = await findProject(client, args.site);
37943
39179
  const verification = await client.agent.checkDeepVerification(
37944
39180
  project.id,
37945
- args.verificationId
39181
+ idArg(args.verificationId, "Proof")
37946
39182
  );
37947
39183
  emit(
37948
39184
  args.json,
37949
39185
  verification,
37950
39186
  () => formatVerification(verification, paint)
37951
39187
  );
37952
- if (verification.state !== "verified") process.exitCode = 3;
39188
+ if (verification.state !== "verified") process.exitCode = EXIT.unfinished;
37953
39189
  })
37954
39190
  });
37955
39191
  var deepPrepare = defineCommand({
@@ -37979,7 +39215,7 @@ var deepPrepare = defineCommand({
37979
39215
  const client = await clientFor({ host: args.host });
37980
39216
  const project = await findProject(client, args.site);
37981
39217
  const prepared = await client.agent.prepareDeep(project.id, {
37982
- verificationId: args.verificationId,
39218
+ verificationId: idArg(args.verificationId, "Proof"),
37983
39219
  idempotencyKey: randomUUID6()
37984
39220
  });
37985
39221
  emit(args.json, prepared, () => formatPreparedDeep(prepared, paint));
@@ -38011,13 +39247,17 @@ var deepStart = defineCommand({
38011
39247
  type: "string",
38012
39248
  description: "Maximum wait in seconds (default 600, max 3600)"
38013
39249
  },
39250
+ ...failOnFlag,
38014
39251
  ...common
38015
39252
  },
38016
39253
  run: ({ args }) => action(async () => {
38017
39254
  pollSeconds(args.interval);
38018
39255
  waitSeconds(args.timeout, 600);
39256
+ failOnArg(args.failOn);
38019
39257
  const client = await clientFor({ host: args.host });
38020
- const launch = await client.agent.startDeep(args.operationId);
39258
+ const launch = await client.agent.startDeep(
39259
+ idArg(args.operationId, "Operation")
39260
+ );
38021
39261
  const started = `Started Deep scan ${launch.scan.id} on ${launch.scan.target}. ${chargeText(launch)}`;
38022
39262
  if (!args.wait)
38023
39263
  return emit(
@@ -38055,11 +39295,13 @@ var findings = defineCommand({
38055
39295
  description: "Include passing and not-assessed checks",
38056
39296
  default: false
38057
39297
  },
39298
+ ...failOnFlag,
38058
39299
  ...common
38059
39300
  },
38060
39301
  run: ({ args }) => action(async () => {
39302
+ const threshold = failOnArg(args.failOn);
38061
39303
  const client = await clientFor({ host: args.host });
38062
- const list = await client.listFindings(args.runId, {
39304
+ const list = await client.listFindings(idArg(args.runId, "Run"), {
38063
39305
  severity: parse3(
38064
39306
  cliSeveritySchema,
38065
39307
  args.severity,
@@ -38068,7 +39310,12 @@ var findings = defineCommand({
38068
39310
  ),
38069
39311
  all: args.all
38070
39312
  });
38071
- emit(args.json, list, () => formatFindings(list, paint));
39313
+ emit(args.json, list, () => formatFindings(list, paint, width()));
39314
+ gate(
39315
+ openCounts(list.filter((item) => item.state === "attention")),
39316
+ threshold,
39317
+ args.json
39318
+ );
38072
39319
  })
38073
39320
  });
38074
39321
  var finding = defineCommand({
@@ -38084,7 +39331,10 @@ var finding = defineCommand({
38084
39331
  },
38085
39332
  run: ({ args }) => action(async () => {
38086
39333
  const client = await clientFor({ host: args.host });
38087
- const detail = await client.getFinding(args.runId, args.findingId);
39334
+ const detail = await client.getFinding(
39335
+ idArg(args.runId, "Run"),
39336
+ idArg(args.findingId, "Finding")
39337
+ );
38088
39338
  emit(
38089
39339
  args.json,
38090
39340
  detail,
@@ -38107,7 +39357,11 @@ var brief = defineCommand({
38107
39357
  run: ({ args }) => action(async () => {
38108
39358
  const agent2 = agentArg(args.agent);
38109
39359
  const client = await clientFor({ host: args.host });
38110
- const result = await client.repairBrief(args.runId, args.finding, agent2);
39360
+ const result = await client.repairBrief(
39361
+ idArg(args.runId, "Run"),
39362
+ args.finding === void 0 ? void 0 : idArg(args.finding, "Finding"),
39363
+ agent2
39364
+ );
38111
39365
  if (args.out) {
38112
39366
  await writeFile2(args.out, `${result.markdown}
38113
39367
  `);
@@ -38139,7 +39393,11 @@ var fix = defineCommand({
38139
39393
  run: ({ args }) => action(async () => {
38140
39394
  const agent2 = agentArg(args.agent);
38141
39395
  const client = await clientFor({ host: args.host });
38142
- const result = await client.repairBrief(args.runId, void 0, agent2);
39396
+ const result = await client.repairBrief(
39397
+ idArg(args.runId, "Run"),
39398
+ void 0,
39399
+ agent2
39400
+ );
38143
39401
  if (result.findingIds.length === 0) {
38144
39402
  if (args.json)
38145
39403
  return emit(true, { ...result, file: null, command: null }, () => "");
@@ -38178,7 +39436,7 @@ var compare = defineCommand({
38178
39436
  },
38179
39437
  run: ({ args }) => action(async () => {
38180
39438
  const client = await clientFor({ host: args.host });
38181
- const comparison = await client.comparison(args.runId);
39439
+ const comparison = await client.comparison(idArg(args.runId, "Run"));
38182
39440
  emit(args.json, comparison, () => formatComparison(comparison, paint));
38183
39441
  })
38184
39442
  });
@@ -38411,6 +39669,159 @@ var skills = defineCommand({
38411
39669
  );
38412
39670
  })
38413
39671
  });
39672
+ var docs = defineCommand({
39673
+ meta: {
39674
+ name: "docs",
39675
+ description: "Search the Lockpick docs from the terminal, or list the CLI guides (--open opens the first match)"
39676
+ },
39677
+ args: {
39678
+ query: {
39679
+ type: "positional",
39680
+ description: 'What to look for, e.g. "fix a missing CSP"',
39681
+ required: false
39682
+ },
39683
+ open: {
39684
+ type: "boolean",
39685
+ description: "Open the best match (or the CLI guide) in the browser",
39686
+ default: false
39687
+ },
39688
+ ...common
39689
+ },
39690
+ run: ({ args, rawArgs }) => action(async () => {
39691
+ const hostValue = rawArgs.indexOf("--host") + 1;
39692
+ const query = rawArgs.filter(
39693
+ (arg, index) => !arg.startsWith("-") && (hostValue === 0 || index !== hostValue)
39694
+ ).join(" ").trim();
39695
+ if (!query) {
39696
+ const session = await resolveSession({ host: args.host });
39697
+ const pages = [
39698
+ ["Install and sign in", "cli"],
39699
+ ["Every command", "commands"],
39700
+ ["Keys and environment", "configuration"],
39701
+ ["Run Lockpick in CI", "ci"],
39702
+ ["What each permission allows", "permissions"],
39703
+ ["Pulse or Deep", "pulseOrDeep"],
39704
+ ["Fix what Lockpick found", "fix"]
39705
+ ].map(([title, page]) => ({
39706
+ title,
39707
+ url: docsUrl(session.host, page)
39708
+ }));
39709
+ emit(
39710
+ args.json,
39711
+ pages,
39712
+ () => [
39713
+ ...pages.map(
39714
+ (page) => `${page.title.padEnd(28)} ${paint("cyan", page.url)}`
39715
+ ),
39716
+ "",
39717
+ paint("dim", "Search them with: lockpick docs <words>")
39718
+ ].join("\n")
39719
+ );
39720
+ if (args.open && !args.json) await openBrowser(pages[0].url);
39721
+ return;
39722
+ }
39723
+ const client = await clientFor({ host: args.host });
39724
+ const found = await client.agent.searchDocs({ query, limit: 5 });
39725
+ emit(args.json, found, () => formatDocSearch(found, paint));
39726
+ if (args.open && !args.json && found.results[0])
39727
+ await openBrowser(found.results[0].url);
39728
+ })
39729
+ });
39730
+ var ciInit = defineCommand({
39731
+ meta: {
39732
+ name: "init",
39733
+ description: `Write a GitHub Actions workflow (${CI_WORKFLOW_FILE}) that runs a Pulse on each release and fails on serious findings`
39734
+ },
39735
+ args: {
39736
+ site: {
39737
+ type: "positional",
39738
+ description: "Site id or name to check",
39739
+ required: true
39740
+ },
39741
+ failOn: {
39742
+ type: "string",
39743
+ description: `Fail the workflow at this severity or worse: ${FAIL_ON.join(", ")} (default high)`,
39744
+ default: "high"
39745
+ },
39746
+ file: {
39747
+ type: "string",
39748
+ description: `Where to write it (default ${CI_WORKFLOW_FILE})`
39749
+ },
39750
+ action: {
39751
+ type: "boolean",
39752
+ description: "Use the Lockpick GitHub Action instead of running the CLI with npx (preview)",
39753
+ default: false
39754
+ },
39755
+ force: {
39756
+ type: "boolean",
39757
+ description: "Replace an existing workflow file",
39758
+ default: false
39759
+ },
39760
+ dryRun: {
39761
+ type: "boolean",
39762
+ description: "Print the workflow instead of writing it",
39763
+ default: false
39764
+ },
39765
+ ...common
39766
+ },
39767
+ run: ({ args }) => action(async () => {
39768
+ const failOn = failOnArg(args.failOn) ?? "high";
39769
+ const client = await clientFor({ host: args.host });
39770
+ const project = await findProject(client, args.site);
39771
+ const workflow = githubWorkflow({
39772
+ host: client.host,
39773
+ siteId: project.id,
39774
+ siteName: project.name,
39775
+ failOn,
39776
+ cliVersion: CLI_VERSION,
39777
+ docsUrl: docsUrl(client.host, "ci"),
39778
+ action: args.action,
39779
+ actionRef: process.env.LOCKPICK_ACTION_REF
39780
+ });
39781
+ const file2 = args.file ?? CI_WORKFLOW_FILE;
39782
+ const warnings = isPrivateHost(client.host) ? [
39783
+ `${client.host} is not reachable from GitHub-hosted runners. Use your public Lockpick address with --host, or a self-hosted runner.`
39784
+ ] : [];
39785
+ if (args.dryRun)
39786
+ return emit(args.json, { file: file2, workflow, warnings }, () => workflow);
39787
+ if (!args.force && await access2(file2).then(
39788
+ () => true,
39789
+ () => false
39790
+ ))
39791
+ throw new LockpickApiError(
39792
+ `${file2} already exists. Pass --force to replace it, or --dry-run to see the new one.`,
39793
+ 400,
39794
+ "VALIDATION_ERROR"
39795
+ );
39796
+ await mkdir4(dirname5(file2), { recursive: true });
39797
+ await writeFile2(file2, workflow);
39798
+ emit(
39799
+ args.json,
39800
+ { file: file2, workflow, warnings },
39801
+ () => [
39802
+ `${paint("green", "\u2714")} Wrote ${file2} for ${project.name} (${project.id}).`,
39803
+ ...warnings.map((warning) => paint("yellow", `! ${warning}`)),
39804
+ "",
39805
+ "Next:",
39806
+ ` 1. Save a Lockpick key allowed to start Pulses as the repository secret ${CI_KEY_SECRET}:`,
39807
+ ` gh secret set ${CI_KEY_SECRET}`,
39808
+ ` 2. Commit ${file2}, then run it from the Actions tab or publish a release.`,
39809
+ paint(
39810
+ "dim",
39811
+ `Each run uses one Pulse (or 1 credit). It fails on ${failOn} findings or worse.`
39812
+ ),
39813
+ ...docsUrl(client.host, "ci") ? [paint("dim", `Docs: ${docsUrl(client.host, "ci")}`)] : []
39814
+ ].join("\n")
39815
+ );
39816
+ })
39817
+ });
39818
+ var ci = defineCommand({
39819
+ meta: {
39820
+ name: "ci",
39821
+ description: "Run Lockpick in CI: `lockpick ci init <site>` writes a GitHub Actions workflow"
39822
+ },
39823
+ subCommands: { init: ciInit }
39824
+ });
38414
39825
  var main = defineCommand({
38415
39826
  meta: {
38416
39827
  name: "lockpick",
@@ -38442,7 +39853,9 @@ var main = defineCommand({
38442
39853
  import: importFindings,
38443
39854
  open: open3,
38444
39855
  mcp,
38445
- skills
39856
+ skills,
39857
+ docs,
39858
+ ci
38446
39859
  }
38447
39860
  });
38448
39861