@lumoai/cli 1.59.0 → 1.61.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.
@@ -43,7 +43,11 @@ function formatSecurityAction(a) {
43
43
  const where = a.line == null ? a.filePath : `${a.filePath}:${a.line}`;
44
44
  const title = (0, sanitize_1.sanitizeField)(a.statement.split(' — ').slice(1).join(' — '));
45
45
  const tag = a.blocking ? 'blocks DONE' : 'advisory';
46
- return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} ${(0, sanitize_1.sanitizeField)(where)} ${title} (PR #${a.prNumber} · ${tag})`;
46
+ // LUM-758 follow-up: a downgraded row is still reported on purpose, so the
47
+ // line has to say WHY it is LOW — otherwise a placeholder the scanner already
48
+ // recognised reads as an unexplained credential.
49
+ const note = (0, sanitize_1.sanitizeField)((0, security_scan_1.secretDowngradeNote)(a.secretDowngrade));
50
+ return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} — ${(0, sanitize_1.sanitizeField)(where)} — ${title} (PR #${a.prNumber} · ${tag}${note})`;
47
51
  }
48
52
  const SECURITY_HINT = 'Fix the findings and push (a fixed finding disappears on the next scan), or ask a human to disposition them in the web delivery panel — disposition is human-only, you cannot disposition them yourself, and a blocking finding refuses DONE with 409.';
49
53
  /** Render the `[SECURITY]` next-action lines (LUM-737 review): shared by the
@@ -57,40 +61,39 @@ function pushSecurityActions(lines, securityActions) {
57
61
  /** Fail-closed security coverage lines (LUM-737): a failed read and every
58
62
  * open PR without a successful scan are stated, never left silent. */
59
63
  const EXTERNAL_ERROR_TAIL = 200;
60
- const SCAN_STAGE_ORDER = [
61
- 'secrets',
62
- 'external',
63
- 'supplyChain',
64
- 'judge',
65
- 'hunt',
66
- ];
64
+ const SCAN_STAGE_ORDER = security_scan_1.SCAN_STAGE_KEYS;
67
65
  /** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
68
- * secrets RAN · external RAN (2 external findings) · supplyChain RAN
69
- * (1 dependency finding, 1 already on main) · judge RAN · hunt RAN`.
66
+ * Secrets: checked · Code scan: checked (2 external findings) ·
67
+ * Dependencies: checked (1 dependency finding, 1 already on main) ·
68
+ * AI review: checked · Exploit paths: checked`.
70
69
  * Stage segments only for keys present, in
71
70
  * secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
72
- * layer); the `(N external findings)` count only decorates the external
71
+ * layer); the `(N external findings)` count only decorates the Code scan
73
72
  * segment, the dependency count (LUM-738, with its already-on-main share)
74
- * only the supplyChain segment, and each only when N > 0. */
73
+ * only the Dependencies segment, and each only when N > 0.
74
+ * LUM-763: the stage keys are internal — what prints is the display label
75
+ * from `shared/src/security-scan.ts`, the same map the GitHub PR summary
76
+ * and the web panel render from. */
75
77
  function formatScanSummaryLine(s) {
76
78
  const segments = [];
77
79
  for (const key of SCAN_STAGE_ORDER) {
78
80
  const state = s.stages[key];
79
81
  if (!state)
80
82
  continue;
83
+ const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
81
84
  if (key === 'external' && s.externalFindings > 0) {
82
85
  const noun = s.externalFindings === 1 ? 'finding' : 'findings';
83
- segments.push(`external ${state} (${s.externalFindings} external ${noun})`);
86
+ segments.push(`${head} (${s.externalFindings} external ${noun})`);
84
87
  }
85
88
  else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
86
89
  const n = s.dependencyFindings ?? 0;
87
90
  const noun = n === 1 ? 'finding' : 'findings';
88
91
  const persisting = s.persistingFindings ?? 0;
89
92
  const tail = persisting > 0 ? `, ${persisting} already on main` : '';
90
- segments.push(`supplyChain ${state} (${n} dependency ${noun}${tail})`);
93
+ segments.push(`${head} (${n} dependency ${noun}${tail})`);
91
94
  }
92
95
  else {
93
- segments.push(`${key} ${state}`);
96
+ segments.push(head);
94
97
  }
95
98
  }
96
99
  let line = ` PR #${s.prNumber} · scan ${s.status}`;
@@ -108,6 +111,29 @@ function formatScanSummaryLine(s) {
108
111
  }
109
112
  return line;
110
113
  }
114
+ /**
115
+ * LUM-762 — the hunt's per-task read-out, one indented line under its scan:
116
+ * `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
117
+ * (2 attempts) · t3 race skipped`. Headed by the same display label as the
118
+ * layer's own segment (LUM-763) — the detail line and the line above it
119
+ * must not name the layer two different ways. This is the only place the stored `huntAudit` surfaces, so
120
+ * it is what answers "how long does a healthy batch take" and "which ones
121
+ * never ran" (`skipped`) without paging through deploy logs. Elapsed time is
122
+ * summed over a task's attempts; a skipped task has none to print.
123
+ *
124
+ * LUM-758: a plan may hold two tasks of the same category, so the row is led
125
+ * by its task id. Pre-task-graph audits carry no id — the row then prints
126
+ * exactly as it always did.
127
+ */
128
+ function formatHuntCoverageLine(hunt) {
129
+ const parts = hunt.categories.map(c => {
130
+ const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
131
+ const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
132
+ const id = c.taskId === undefined ? '' : `${c.taskId} `;
133
+ return `${id}${c.category} ${spent}${c.stopped}${attempts}`;
134
+ });
135
+ return ` ${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
136
+ }
111
137
  function pushSecurityCoverage(lines, data) {
112
138
  if (data.securityFindings === undefined)
113
139
  return; // older server
@@ -118,6 +144,10 @@ function pushSecurityCoverage(lines, data) {
118
144
  // LUM-735: absent `scans` = older server; tolerate silently, no block.
119
145
  for (const s of data.securityFindings.scans ?? []) {
120
146
  lines.push(formatScanSummaryLine(s));
147
+ // LUM-762: absent/null = older server or no usable audit — stay silent.
148
+ if (s.hunt && s.hunt.categories.length > 0) {
149
+ lines.push(formatHuntCoverageLine(s.hunt));
150
+ }
121
151
  }
122
152
  for (const u of data.securityFindings.unconfirmedPrs) {
123
153
  const why = u.reason === 'NONE'
@@ -581,15 +611,24 @@ function pushOpenCrossings(lines, extras) {
581
611
  const open = result.crossings;
582
612
  if (open.length === 0)
583
613
  return;
614
+ // LUM-771: say which rows the server DONE gate actually counts — advisory
615
+ // rows (and, with the gate off, non-HIGH rows) are awareness, not blockers.
616
+ const blocking = open.filter(c => c.blocking);
617
+ const rest = open.filter(c => !c.blocking);
584
618
  lines.push('');
585
- lines.push(`⚠ Open boundary crossings (${open.length} undispositioned):`);
619
+ lines.push(`⚠ Open boundary crossings (${open.length} undispositioned: ${blocking.length} blocking DONE, ${rest.length} advisory / non-blocking):`);
586
620
  for (const c of open) {
587
621
  const detail = oneLineDetail(c.detail);
588
622
  const tail = detail ? ` — ${detail}` : '';
589
- lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
623
+ const gate = c.blocking
624
+ ? ''
625
+ : c.advisory
626
+ ? ' (advisory — does not block DONE)'
627
+ : ' (below the workspace gate — does not block DONE)';
628
+ lines.push(` ${c.blocking ? '•' : '◦'} [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${gate}${tail}`);
590
629
  lines.push(` ${formatAttribution(c.attribution)}`);
591
630
  }
592
- lines.push(' Disposition is human-only in the web acceptance panel:');
631
+ lines.push(" Disposition is the user's call: relay each crossing, then `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope; never self-approve), or the web acceptance panel:");
593
632
  if (extras.dispositionUrl) {
594
633
  lines.push(` ${extras.dispositionUrl}`);
595
634
  }
@@ -391,7 +391,7 @@ async function runTaskUpdate(identifier, opts, collected) {
391
391
  if (res.status === 409 &&
392
392
  status === 'DONE' &&
393
393
  (0, blocked_error_1.isDoneGateRefusal)(serverMsg)) {
394
- const { blockers, unconfirmed } = await (0, blocked_error_1.collectDoneBlockers)({
394
+ const { blockers, unconfirmed, nonBlocking } = await (0, blocked_error_1.collectDoneBlockers)({
395
395
  base,
396
396
  token: creds.token,
397
397
  identifier,
@@ -401,6 +401,7 @@ async function runTaskUpdate(identifier, opts, collected) {
401
401
  message: serverMsg,
402
402
  blockers,
403
403
  unconfirmed,
404
+ nonBlocking,
404
405
  apiUrl,
405
406
  workspaceSlug: creds.workspaceSlug ?? '',
406
407
  }));
@@ -66,6 +66,7 @@ const priority_1 = require("./commands/priority");
66
66
  const criteria_audit_1 = require("./commands/criteria-audit");
67
67
  const verify_1 = require("./commands/verify");
68
68
  const crossing_explain_1 = require("./commands/crossing-explain");
69
+ const crossing_disposition_1 = require("./commands/crossing-disposition");
69
70
  const outcome_1 = require("./commands/outcome");
70
71
  const task_context_1 = require("./commands/task-context");
71
72
  const task_create_1 = require("./commands/task-create");
@@ -274,12 +275,23 @@ program
274
275
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
275
276
  const crossing = program
276
277
  .command('crossing')
277
- .description('Inspect and annotate boundary crossings');
278
+ .description('Inspect, annotate and (with user approval) disposition boundary crossings');
278
279
  crossing
279
280
  .command('explain <id>')
280
281
  .description('Append an agent self-explanation ("申辩") to a boundary crossing (LUM-542). Append-only and for the human reviewer — it never clears the crossing or unblocks Done (a human dispositions that). Targets a crossing on the session-bound task.')
281
282
  .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
282
283
  .action(wrap((id, options) => (0, crossing_explain_1.crossingExplain)(id, options)));
284
+ crossing
285
+ .command('disposition <id>')
286
+ .description("Rule on a boundary crossing from the terminal (LUM-769) through a three-step confirmation handshake the server enforces: (1) no step flag — prints the crossing (severity, detail, agent explanations) as an envelope with a stage-1 read receipt, exit 4, writes nothing; (2) --confirm-read --receipt <r1> after the user confirms they READ it — returns the stage-2 receipt and a second envelope for the ruling, exit 4; (3) --confirm --receipt <r2> after the user approves the ruling — writes. Confirming before the read is acknowledged is refused and told to confirm the read first. Relay each envelope; never supply a step flag yourself. Either ruling clears the crossing's block on DONE and is audited as a CLI-channel disposition. Reverting to OPEN and repository suppression rules stay web-only. Targets a crossing on the session-bound task unless --task is given.")
287
+ .option('--false-positive', 'Rule the crossing a false positive (not a real issue)')
288
+ .option('--confirmed', 'Rule the crossing confirmed (a real issue, acknowledged)')
289
+ .option('--note <text>', 'Optional disposition note recorded with the ruling')
290
+ .option('--task <identifier>', 'Task the crossing belongs to (defaults to the session-bound task)')
291
+ .option('--receipt <token>', "Read receipt from the previous step's envelope (its confirmCommand carries it); required with --confirm-read (stage 1 receipt) and --confirm (stage 2 receipt), verified server-side")
292
+ .option('--confirm-read', 'Step 2: the user confirmed they have read the envelope (never add this on your own initiative). Returns the ruling envelope; writes nothing')
293
+ .option('--confirm', 'Step 3: the user approved the ruling (never add this on your own initiative). Refused unless the read was confirmed first')
294
+ .action(wrap((id, options) => (0, crossing_disposition_1.crossingDisposition)(id, options)));
283
295
  const outcome = program
284
296
  .command('outcome')
285
297
  .description('Read the post-hoc outcome well (LUM-598)');
@@ -744,11 +756,10 @@ milestoneCmd
744
756
  .action(wrap((identifier, options) => (0, milestone_show_1.milestoneShow)(identifier, options)));
745
757
  milestoneCmd
746
758
  .command('update <identifier>')
747
- .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target, --token-budget. Use "" to clear nullable fields.')
759
+ .description('Update a milestone. Provide at least one of --name, --description, --start, --target, --token-budget. Use "" to clear nullable fields. Completion is derived from task completion (LUM-714) — there is no status flag.')
748
760
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
749
761
  .option('-n, --name <text>', 'New name')
750
762
  .option('-d, --description <text>', 'New description (empty string to clear)')
751
- .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
752
763
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
753
764
  .option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
754
765
  .option('--token-budget <tokens>', 'Advisory token budget, positive integer (empty string to clear). Overview shows burn vs budget; over-budget raises a risk-queue alert — never a hard gate.')
@@ -13,10 +13,14 @@ exports.emitBlocked = emitBlocked;
13
13
  * The confirmation protocol (confirmation.ts, exit 4) covers mutations the
14
14
  * USER can approve. Some refusals are different in kind: the server's DONE
15
15
  * gates — an unresolved send-back, an undispositioned boundary crossing, a
16
- * blocking security finding — are verdicts on the agent's own work, and by
17
- * design there is no CLI path to clear them (the interested party may not
18
- * adjudicate itself). Echoing the 409 text and exiting 1 left the agent
19
- * grepping prose to learn that. Instead the CLI now:
16
+ * blocking security finding — are verdicts on the agent's own work, and the
17
+ * agent may not adjudicate itself: nothing on `task update` clears them.
18
+ * Since LUM-769 a boundary crossing does have a terminal path, but it is the
19
+ * USER's `lumo crossing disposition` walks the same exit-4 protocol (the
20
+ * agent relays the crossing, the user rules, the agent re-runs with the
21
+ * approval flag); send-backs and security findings stay human-side / web.
22
+ * Echoing the 409 text and exiting 1 left the agent grepping prose to learn
23
+ * that. Instead the CLI now:
20
24
  *
21
25
  * 1. recognises the gate refusal (`isDoneGateRefusal`);
22
26
  * 2. re-reads the existing read models — `GET …/status` for send-backs and
@@ -42,6 +46,7 @@ async function collectDoneBlockers(args) {
42
46
  const { base, token, identifier } = args;
43
47
  const blockers = [];
44
48
  const unconfirmed = [];
49
+ const nonBlocking = [];
45
50
  const headers = { Authorization: `Bearer ${token}` };
46
51
  // Send-backs + blocking security findings: the acceptance-status read model.
47
52
  try {
@@ -87,22 +92,36 @@ async function collectDoneBlockers(args) {
87
92
  unconfirmed.push(`boundary-crossings read failed: ${crossings.reason}`);
88
93
  }
89
94
  else {
95
+ // LUM-771: mirror the server gate — an open row is a blocker only when
96
+ // the gate counts it; the rest are surfaced separately, never as blockers.
90
97
  for (const c of crossings.crossings) {
91
- blockers.push({
92
- kind: 'BOUNDARY_CROSSING',
93
- id: c.id,
94
- severity: c.severity,
95
- category: c.category,
96
- detail: c.detail,
97
- });
98
+ if (c.blocking) {
99
+ blockers.push({
100
+ kind: 'BOUNDARY_CROSSING',
101
+ id: c.id,
102
+ severity: c.severity,
103
+ category: c.category,
104
+ detail: c.detail,
105
+ });
106
+ }
107
+ else {
108
+ nonBlocking.push({
109
+ id: c.id,
110
+ severity: c.severity,
111
+ category: c.category,
112
+ detail: c.detail,
113
+ advisory: c.advisory,
114
+ });
115
+ }
98
116
  }
99
117
  }
100
- return { blockers, unconfirmed };
118
+ return { blockers, unconfirmed, nonBlocking };
101
119
  }
102
120
  /**
103
- * One line per blocker kind present. Every line names a human-side path or
104
- * the append-only `crossing explain` — never a flag or command that would let
105
- * the agent clear its own blocker.
121
+ * One line per blocker kind present. Every line names a human-side path, the
122
+ * append-only `crossing explain`, or the user-approved `crossing disposition`
123
+ * (exit-4 protocol) never a ready-to-run flag that would let the agent
124
+ * clear its own blocker on its own initiative.
106
125
  */
107
126
  function buildRemediation(blockers, unconfirmed, ctx) {
108
127
  const kinds = new Set(blockers.map(b => b.kind));
@@ -114,7 +133,7 @@ function buildRemediation(blockers, unconfirmed, ctx) {
114
133
  const ids = blockers
115
134
  .filter(b => b.kind === 'BOUNDARY_CROSSING')
116
135
  .map(b => b.id);
117
- lines.push(`BOUNDARY_CROSSING: leave a rationale with \`lumo crossing explain ${ids[0]} --note "…"\` (append-only; it does not clear anything). A human dispositions each crossing (false positive / confirmed) at ${ctx.dispositionUrl}`);
136
+ lines.push(`BOUNDARY_CROSSING: leave a rationale with \`lumo crossing explain ${ids[0]} --note "…"\` (append-only; it does not clear anything), then relay each crossing to the user. Once they rule, record it with \`lumo crossing disposition ${ids[0]} --false-positive\` or \`--confirmed\` it prints an exit-4 envelope for the user to approve; never self-approve. The web panel also works: ${ctx.dispositionUrl}`);
118
137
  }
119
138
  if (kinds.has('SECURITY_FINDING')) {
120
139
  lines.push('SECURITY_FINDING: fix and push — a fixed fingerprint disappears from the next scan — or a human marks it false positive / accepted risk in the web delivery panel.');
@@ -133,6 +152,7 @@ function buildBlockedError(args) {
133
152
  task: args.identifier,
134
153
  message: args.message,
135
154
  blockers: args.blockers,
155
+ nonBlocking: args.nonBlocking ?? [],
136
156
  unconfirmed: args.unconfirmed,
137
157
  remediation: buildRemediation(args.blockers, args.unconfirmed, {
138
158
  identifier: args.identifier,
@@ -161,13 +181,20 @@ function renderBlocked(err, isTTY) {
161
181
  '',
162
182
  err.blockers.length > 0 ? 'Blockers:' : 'Blockers: (none listed)',
163
183
  ...err.blockers.map(b => ` • ${(0, sanitize_1.sanitizeField)(blockerLine(b))}`),
184
+ ...(err.nonBlocking.length > 0
185
+ ? [
186
+ '',
187
+ 'Also open, not blocking DONE (advisory / below the workspace gate):',
188
+ ...err.nonBlocking.map(c => ` ◦ [${c.severity}${c.advisory ? ' advisory' : ''}] ${(0, sanitize_1.sanitizeField)(c.category)} ${c.id}: ${(0, sanitize_1.sanitizeField)(c.detail)}`),
189
+ ]
190
+ : []),
164
191
  ...(err.unconfirmed.length > 0
165
192
  ? ['', ...err.unconfirmed.map(u => ` ⚠ ${u}`)]
166
193
  : []),
167
194
  '',
168
195
  'What can move it:',
169
196
  ...err.remediation.map(r => ` - ${r}`),
170
- ` Disposition (human-only): ${err.dispositionUrl}`,
197
+ ` Web disposition panel: ${err.dispositionUrl}`,
171
198
  ];
172
199
  return lines.join('\n') + '\n';
173
200
  }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBoundTask = resolveBoundTask;
4
+ async function resolveBoundTask(args) {
5
+ const explicit = args.explicit?.trim();
6
+ if (explicit)
7
+ return { ok: true, taskIdentifier: explicit };
8
+ if (!args.sessionId) {
9
+ return {
10
+ ok: false,
11
+ message: 'Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`, or pass --task <LUM-N>.',
12
+ };
13
+ }
14
+ let bound;
15
+ try {
16
+ const res = await fetch(`${args.base}/api/sessions/${encodeURIComponent(args.sessionId)}`, { headers: args.headers });
17
+ bound = res.ok
18
+ ? (await res.json())
19
+ : null;
20
+ }
21
+ catch (err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ return { ok: false, message: `Error: could not reach Lumo API (${msg})` };
24
+ }
25
+ if (!bound?.taskIdentifier) {
26
+ return {
27
+ ok: false,
28
+ message: 'Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first, or pass --task <LUM-N>.',
29
+ };
30
+ }
31
+ return { ok: true, taskIdentifier: bound.taskIdentifier };
32
+ }
@@ -42,27 +42,54 @@ function shellQuote(arg) {
42
42
  return arg;
43
43
  return `'${arg.replace(/'/g, `'\\''`)}'`;
44
44
  }
45
+ /** Flags that belong to a previous step of a handshake and must not carry
46
+ * over into the next step's command: the step flags themselves and a prior
47
+ * `--receipt <token>` pair (the next envelope brings its own receipt). */
48
+ const STEP_FLAGS = new Set(['--confirm', '--confirm-read']);
49
+ function stripStepFlags(argv) {
50
+ const out = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (STEP_FLAGS.has(a))
54
+ continue;
55
+ if (a === '--receipt') {
56
+ i++; // drop its value too
57
+ continue;
58
+ }
59
+ if (a.startsWith('--receipt='))
60
+ continue;
61
+ out.push(a);
62
+ }
63
+ return out;
64
+ }
45
65
  /**
46
- * Rebuild the invocation the user ran, plus `--confirm` (and any
47
- * `extraFlags`). Flags already present in argv are never duplicated, and
48
- * `--confirm` always lands last so the command reads as "…, approved".
66
+ * Rebuild the invocation the user ran, plus the approval flag (default
67
+ * `--confirm`) and any `extraFlags`. Step flags and a prior `--receipt` from
68
+ * an earlier handshake step are dropped first, flags already present in argv
69
+ * are never duplicated, and the approval flag always lands last so the
70
+ * command reads as "…, approved".
49
71
  */
50
- function buildConfirmCommand(argv = process.argv.slice(2), extraFlags = []) {
51
- const kept = argv.filter(a => a !== '--confirm');
72
+ function buildConfirmCommand(argv = process.argv.slice(2), extraFlags = [], confirmFlag = '--confirm') {
73
+ const kept = stripStepFlags(argv);
52
74
  const parts = ['lumo', ...kept.map(shellQuote)];
53
75
  for (const flag of extraFlags) {
54
76
  if (!kept.includes(flag))
55
77
  parts.push(flag);
56
78
  }
57
- parts.push('--confirm');
79
+ parts.push(confirmFlag);
58
80
  return parts.join(' ');
59
81
  }
60
82
  function buildEnvelope(input) {
83
+ const extraFlags = [
84
+ ...(input.extraFlags ?? []),
85
+ ...(input.receipt ? ['--receipt', input.receipt.token] : []),
86
+ ];
61
87
  return {
62
88
  status: 'confirmation_required',
63
89
  command: input.command,
64
90
  changes: input.changes,
65
- confirmCommand: buildConfirmCommand(input.argv, input.extraFlags),
91
+ confirmCommand: buildConfirmCommand(input.argv, extraFlags, input.confirmFlag ?? '--confirm'),
92
+ ...(input.receipt ? { receipt: input.receipt } : {}),
66
93
  };
67
94
  }
68
95
  /** JSON for pipes; the same fields as readable text for a terminal. */
@@ -72,7 +99,10 @@ function renderConfirmation(envelope, isTTY) {
72
99
  const lines = [
73
100
  `Confirmation required — \`lumo ${envelope.command}\` was not run.`,
74
101
  ...envelope.changes.map(c => ` - ${c}`),
75
- 'Re-run with --confirm once the user has approved:',
102
+ ...(envelope.receipt
103
+ ? [`Read receipt issued (valid until ${envelope.receipt.expiresAt}).`]
104
+ : []),
105
+ `Re-run with ${envelope.confirmCommand.endsWith('--confirm-read') ? '--confirm-read' : '--confirm'} once the user has approved:`,
76
106
  ` ${envelope.confirmCommand}`,
77
107
  ];
78
108
  return lines.join('\n') + '\n';
@@ -1,8 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.blocksDone = blocksDone;
3
4
  exports.fetchOpenCrossings = fetchOpenCrossings;
4
5
  exports.dispositionUrl = dispositionUrl;
5
6
  const api_1 = require("./api");
7
+ /**
8
+ * LUM-771: the server's DONE-gate predicate for one open crossing (mirrors
9
+ * `assertNoUnacknowledgedBoundaryCrossings` and the panel): an advisory row
10
+ * never blocks; with the workspace gate disabled only HIGH rows block.
11
+ */
12
+ function blocksDone(c, gateEnabled) {
13
+ return !c.advisory && (gateEnabled || c.severity === 'HIGH');
14
+ }
6
15
  const SEVERITY_RANK = {
7
16
  HIGH: 3,
8
17
  MEDIUM: 2,
@@ -28,9 +37,9 @@ function normalizeSeverity(s) {
28
37
  * first, via the EXISTING LUM-435 read endpoint — `GET …/boundary-crossings`
29
38
  * returns every crossing (open and dispositioned); we keep only the
30
39
  * undispositioned ones (`disposition == null`). This is the **read/awareness**
31
- * half of the acceptance loop: there is no new query and, by construction, no
32
- * way to clear a crossing disposition stays web + human-only
33
- * (LUM-426/435/422).
40
+ * half of the acceptance loop: there is no new query and this helper cannot
41
+ * clear anything a ruling goes through `lumo crossing disposition` (exit-4
42
+ * user approval, LUM-769) or the web panel.
34
43
  *
35
44
  * Fails *closed*, not open (LUM-480): any transport / non-ok HTTP / parse
36
45
  * failure returns `{ status: 'error', reason }` so the caller can say "check
@@ -62,22 +71,33 @@ async function fetchOpenCrossings(apiUrl, token, taskIdentifier) {
62
71
  return { status: 'error', reason: 'invalid response body' };
63
72
  }
64
73
  const rows = Array.isArray(data.crossings) ? data.crossings : [];
74
+ // LUM-771: only an explicit `false` disables the gate — absent (older server)
75
+ // or malformed reads as enabled, so a hiccup can never hide a blocker.
76
+ const gateEnabled = data.gateEnabled !== false;
65
77
  const crossings = rows
66
78
  .filter(c => c.disposition == null)
67
- .map(c => ({
68
- id: c.id,
69
- category: c.category,
70
- severity: normalizeSeverity(c.severity),
71
- detail: c.detail,
72
- attribution: normalizeAttribution(c.attribution),
73
- }))
74
- .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
75
- return { status: 'ok', crossings };
79
+ .map(c => {
80
+ const severity = normalizeSeverity(c.severity);
81
+ const advisory = c.advisory === true;
82
+ return {
83
+ id: c.id,
84
+ category: c.category,
85
+ severity,
86
+ detail: c.detail,
87
+ attribution: normalizeAttribution(c.attribution),
88
+ advisory,
89
+ blocking: blocksDone({ advisory, severity }, gateEnabled),
90
+ };
91
+ })
92
+ // Blocking rows first, then by severity — what the human must act on leads.
93
+ .sort((a, b) => Number(b.blocking) - Number(a.blocking) ||
94
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
95
+ return { status: 'ok', crossings, gateEnabled };
76
96
  }
77
97
  /**
78
- * The web deep link where a HUMAN dispositions crossings. Disposition is
79
- * web-only and human-only (LUM-426/435/422); the terminal only ever points
80
- * here, it never clears anything itself. Built from the workspace slug +
98
+ * The web deep link where a human dispositions crossings in the panel — the
99
+ * counterpart of `lumo crossing disposition` (LUM-769); the awareness surfaces
100
+ * (task status, DONE_BLOCKED) point here. Built from the workspace slug +
81
101
  * identifier alone (the `/my-tasks/<id>` route needs no project slug), so no
82
102
  * extra fetch is required.
83
103
  */
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EXTERNAL_ERROR_PREFIX = void 0;
3
+ exports.SECRET_DOWNGRADE_LABELS = exports.SCAN_STAGE_STATE_LABELS = exports.SCAN_STAGE_LABELS = exports.SCAN_STAGE_STATES = exports.SCAN_STAGE_KEYS = exports.EXTERNAL_ERROR_PREFIX = void 0;
4
4
  exports.externalFailureReason = externalFailureReason;
5
+ exports.scanStageLabel = scanStageLabel;
6
+ exports.scanStageStateLabel = scanStageStateLabel;
7
+ exports.secretDowngradeNote = secretDowngradeNote;
5
8
  /**
6
9
  * PR security scan — the one `PrSecurityScan.error` shape that may be shown
7
10
  * as an external-scanner reason (spec P8). Shared by the web panel, the PR
@@ -27,3 +30,96 @@ function externalFailureReason(error) {
27
30
  const reason = error.slice(exports.EXTERNAL_ERROR_PREFIX.length);
28
31
  return reason.length > 0 ? reason : null;
29
32
  }
33
+ /**
34
+ * LUM-763 — the scan's five layers, as stored.
35
+ *
36
+ * These keys are a persisted contract, not a display choice: they sit in
37
+ * `PrSecurityScan.stages` on every historical row, travel over the
38
+ * task-status API, and are read by CLIs older than this change. They do not
39
+ * get renamed; only what a reader is shown does.
40
+ */
41
+ exports.SCAN_STAGE_KEYS = [
42
+ 'secrets',
43
+ 'external',
44
+ 'supplyChain',
45
+ 'judge',
46
+ 'hunt',
47
+ ];
48
+ /** The states a layer can be in. `ScanStageState` in `lib/security-scan/types.ts` derives from this. */
49
+ exports.SCAN_STAGE_STATES = [
50
+ 'RAN',
51
+ 'FAILED',
52
+ 'SKIPPED',
53
+ 'NOT_CONFIGURED',
54
+ 'PENDING',
55
+ 'PARTIAL',
56
+ 'SUPERSEDED',
57
+ ];
58
+ /**
59
+ * What each layer is called where a person reads it — the PR comment and
60
+ * `lumo task status`, which share this map so the two cannot drift (the web
61
+ * panel renders the same wording through `securityScan.stage.*` in i18n).
62
+ *
63
+ * Named for what the layer checks, never for who runs it or which model it
64
+ * uses: `external` was accurate only while the scanners were the customer's,
65
+ * and stopped being true the moment Lumo started running them itself.
66
+ */
67
+ exports.SCAN_STAGE_LABELS = {
68
+ secrets: 'Secrets',
69
+ external: 'Code scan',
70
+ supplyChain: 'Dependencies',
71
+ judge: 'AI review',
72
+ hunt: 'Exploit paths',
73
+ };
74
+ /**
75
+ * What each state is called. `RAN` deliberately reads as "checked", not as a
76
+ * tick or "clean": it means the layer completed, and a layer that completed
77
+ * may well have found something — the findings are counted on their own
78
+ * lines. Only the states that leave the scan incomplete carry a glyph, so
79
+ * attention is drawn to "do not trust this line" and never to "this is fine"
80
+ * (spec P9 — a security signal never claims safety it did not establish).
81
+ */
82
+ exports.SCAN_STAGE_STATE_LABELS = {
83
+ RAN: 'checked',
84
+ FAILED: '✗ failed',
85
+ SKIPPED: 'skipped',
86
+ NOT_CONFIGURED: 'off',
87
+ PENDING: 'scanning',
88
+ PARTIAL: '⚠ incomplete',
89
+ SUPERSEDED: '⚠ superseded',
90
+ };
91
+ /** The layer's display name; an unrecognised key renders as itself rather than vanishing. */
92
+ function scanStageLabel(key) {
93
+ return exports.SCAN_STAGE_LABELS[key] ?? key;
94
+ }
95
+ /** The state's display name; an unrecognised state renders as itself. */
96
+ function scanStageStateLabel(state) {
97
+ return exports.SCAN_STAGE_STATE_LABELS[state] ?? state;
98
+ }
99
+ /**
100
+ * LUM-758 follow-up — why a deterministic secret hit was lowered to LOW.
101
+ *
102
+ * Downgrading is not suppression: the row is still reported, deliberately, so
103
+ * that a human decides. But a reported row with no stated cause reads as an
104
+ * unexplained credential, which is how a test fixture the scanner *already
105
+ * recognised* as a placeholder ends up looking like an open risk. These say
106
+ * which rule fired, in the same words on every surface.
107
+ *
108
+ * Phrased as a property of the value or the path, never as a verdict: the
109
+ * scanner knows the string looks like a placeholder, not that it is harmless.
110
+ */
111
+ exports.SECRET_DOWNGRADE_LABELS = {
112
+ PLACEHOLDER_VALUE: 'placeholder value',
113
+ DOWNGRADED_PATH: 'test/fixture/doc path',
114
+ };
115
+ /**
116
+ * The parenthesised note appended to a rendered finding line, or `''` when the
117
+ * row carries no downgrade — an unrecognised reason renders as itself rather
118
+ * than vanishing, so a value added server-side is never silently dropped by an
119
+ * older CLI.
120
+ */
121
+ function secretDowngradeNote(reason) {
122
+ if (!reason)
123
+ return '';
124
+ return ` — ${exports.SECRET_DOWNGRADE_LABELS[reason] ?? reason}, downgraded`;
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.59.0",
3
+ "version": "1.61.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",