@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

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 (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
3
- * envelope rendering for the phase-1 OFFLINE analysis (#202b, #202c-2).
3
+ * envelope rendering for offline analysis and read-only live evidence (#491).
4
4
  *
5
5
  * ## The positional grammar is conditional, and that is deliberate
6
6
  *
@@ -20,9 +20,8 @@
20
20
  *
21
21
  * ## What this surface refuses to do
22
22
  *
23
- * - **The live form is rejected, not degraded.** Phase 2 owns the probes; a
24
- * phase-1 run that silently analyzed offline while a router was named would
25
- * report `mode: "offline"` about an invocation that asked for more.
23
+ * - **Live probes never execute the input.** The shared live module attaches
24
+ * device evidence, `--schema` and `--complete` to the offline result.
26
25
  * - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
27
26
  * pattern): `explain` takes at most one router.
28
27
  * - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
@@ -45,9 +44,17 @@ import {
45
44
  renderExplainEnvelope,
46
45
  resolveExplainFormat,
47
46
  } from "../explain.ts";
47
+ import {
48
+ assertExplainTarget,
49
+ type ExplainLiveRequest,
50
+ explainLiveEnvelope,
51
+ } from "../explain-live.ts";
52
+ import { resolveExplainCursor } from "../explain-live-facets.ts";
48
53
  import { describeCentrs } from "../index.ts";
49
54
  import { loadEnvFileDefaults } from "../resolver/config-file.ts";
55
+ import { redactTargetInput } from "../resolver/index.ts";
50
56
  import type { ResolvedSetting } from "../resolver/settings.ts";
57
+ import { apiCommand } from "./api.ts";
51
58
  import {
52
59
  type CliCommandMetadata,
53
60
  expectValue,
@@ -59,10 +66,30 @@ import { selectionFlagTokens } from "./selection.ts";
59
66
 
60
67
  export const explainCliCommand: CliCommandMetadata = {
61
68
  name: "explain",
62
- usage: "centrs explain '<input>' [flags]",
69
+ usage: "centrs explain [<router>] '<input>' [flags]",
63
70
  summary:
64
- "Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics.",
71
+ "Analyze RouterOS input offline or add read-only device evidence from one router.",
65
72
  options: [
73
+ ...apiCommand.options.filter((o) =>
74
+ [
75
+ "--via",
76
+ "--host",
77
+ "--port",
78
+ "--username / --user / -u",
79
+ "--password",
80
+ "--timeout",
81
+ "--insecure",
82
+ "--cdb-file",
83
+ "--cdb-password",
84
+ "--resolve",
85
+ ].includes(o.flag),
86
+ ),
87
+ { flag: "-p", valueName: "<secret>", description: "Alias for --password." },
88
+ {
89
+ flag: "--quickchr",
90
+ valueName: "<name>",
91
+ description: "Resolve one running quickchr VM by name.",
92
+ },
66
93
  {
67
94
  flag: "--file",
68
95
  valueName: "<path>",
@@ -78,12 +105,23 @@ export const explainCliCommand: CliCommandMetadata = {
78
105
  {
79
106
  flag: "--complete",
80
107
  description:
81
- "Continuation candidates at the cursor. Live evidence — offline emits a tip and enumerates nothing.",
108
+ "Candidates at the cursor: menus and verbs, argument names, or values. Live evidence — offline emits a tip and enumerates nothing.",
82
109
  },
83
110
  {
84
111
  flag: "--schema",
85
112
  description:
86
- "Path enumeration (verbs, args, types, enums). Live evidence — offline emits a tip and enumerates nothing.",
113
+ "Describe the path at the cursor: a menu's verbs and submenus, a command's arguments, `print`'s proplist. Live evidence — offline emits a tip and enumerates nothing.",
114
+ },
115
+ {
116
+ flag: "--cursor",
117
+ valueName: "<byte>",
118
+ description:
119
+ "UTF-8 byte offset for --complete/--schema. Default: the end of the input.",
120
+ },
121
+ {
122
+ flag: "--full",
123
+ description:
124
+ "Lift --complete/--schema sizing to its safety ceiling (1024 names, 128 expansion probes).",
87
125
  },
88
126
  {
89
127
  flag: "--curl",
@@ -106,16 +144,20 @@ export const explainCliCommand: CliCommandMetadata = {
106
144
  ],
107
145
  };
108
146
 
109
- export interface ExplainCliArgs {
147
+ export interface ExplainCliArgs extends Omit<ExplainLiveRequest, "input"> {
110
148
  help?: boolean;
111
149
  positionals: string[];
112
150
  filePath?: string;
113
151
  failOn: ExplainFailOn;
114
152
  facets: string[];
153
+ cursor?: number;
154
+ full?: boolean;
115
155
  curl?: boolean;
116
156
  tokens?: boolean;
117
157
  format?: ExplainOutputFormat;
118
158
  verbose?: boolean;
159
+ /** First explicitly supplied connection flag, for the missing-target refusal. */
160
+ connectionFlag?: string;
119
161
  }
120
162
 
121
163
  export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
@@ -133,6 +175,7 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
133
175
  parsed.positionals.push(arg);
134
176
  continue;
135
177
  }
178
+ if (LIVE_CONNECTION_FLAGS.has(arg)) parsed.connectionFlag ??= arg;
136
179
  switch (arg) {
137
180
  case "--":
138
181
  // Everything after `--` is input, however command-shaped it looks:
@@ -144,6 +187,54 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
144
187
  case "-h":
145
188
  parsed.help = true;
146
189
  break;
190
+ case "--via":
191
+ parsed.via = expectValue(args, ++index, arg);
192
+ break;
193
+ case "--host":
194
+ parsed.host = expectValue(args, ++index, arg);
195
+ break;
196
+ case "--quickchr":
197
+ if (parsed.quickchr !== undefined)
198
+ throw fanoutNotSupportedError({ flag: arg });
199
+ parsed.quickchr = expectValue(args, ++index, arg);
200
+ break;
201
+ case "--timeout":
202
+ parsed.timeout = expectValue(args, ++index, arg);
203
+ break;
204
+ case "--cdb-file":
205
+ parsed.cdbFile = expectValue(args, ++index, arg);
206
+ break;
207
+ case "--cdb-password":
208
+ parsed.cdbPassword = expectValue(args, ++index, arg);
209
+ break;
210
+ case "--resolve":
211
+ parsed.resolve = expectValue(args, ++index, arg);
212
+ break;
213
+ case "--password":
214
+ parsed.password = expectValue(args, ++index, arg);
215
+ break;
216
+ case "-p":
217
+ parsed.password = expectValue(args, ++index, arg);
218
+ break;
219
+ case "-u":
220
+ case "--user":
221
+ case "--username":
222
+ parsed.username = expectValue(args, ++index, arg);
223
+ break;
224
+ case "--insecure":
225
+ parsed.insecure = true;
226
+ break;
227
+ case "--port": {
228
+ const value = expectValue(args, ++index, arg);
229
+ if (!/^\d+$/.test(value))
230
+ throw new CentrsError({
231
+ code: "input/invalid-command",
232
+ summary: "Port must be an integer.",
233
+ remediation: "Pass a TCP port between 1 and 65535.",
234
+ });
235
+ parsed.port = Number(value);
236
+ break;
237
+ }
147
238
  case "--file":
148
239
  parsed.filePath = expectValue(args, ++index, arg);
149
240
  break;
@@ -167,6 +258,22 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
167
258
  case "--schema":
168
259
  parsed.facets.push("--schema");
169
260
  break;
261
+ case "--cursor": {
262
+ const value = expectValue(args, ++index, arg);
263
+ if (!/^\d+$/.test(value))
264
+ throw new CentrsError({
265
+ code: "usage/invalid-cursor",
266
+ summary: `--cursor must be a non-negative byte offset; got ${value}.`,
267
+ remediation:
268
+ "Pass a UTF-8 byte offset into the input, or omit --cursor to complete at the end.",
269
+ context: { cursor: value },
270
+ });
271
+ parsed.cursor = Number(value);
272
+ break;
273
+ }
274
+ case "--full":
275
+ parsed.full = true;
276
+ break;
170
277
  case "--curl":
171
278
  parsed.curl = true;
172
279
  break;
@@ -236,35 +343,11 @@ function fanoutNotSupportedError(context: {
236
343
  ...(context.flag === undefined ? {} : { flag: context.flag }),
237
344
  ...(context.targets === undefined
238
345
  ? {}
239
- : { targets: [...context.targets] }),
346
+ : { targets: context.targets.map(redactTargetInput) }),
240
347
  },
241
348
  });
242
349
  }
243
350
 
244
- /**
245
- * The one live-form error.
246
- *
247
- * Named separately because it is a PHASE boundary, not a usage mistake: the
248
- * invocation is the ratified grammar and will work unchanged when phase 2
249
- * lands, so the remediation points at the offline form rather than at `--help`.
250
- */
251
- function liveNotImplementedError(router: string): CentrsError {
252
- // A "router" that starts `/` or `:` is almost always an unquoted input the
253
- // shell split, so name that reading first rather than reporting a router
254
- // nobody meant to give.
255
- const looksLikeInput = /^[/:]/.test(router);
256
- return new CentrsError({
257
- code: "usage/not-implemented",
258
- summary: looksLikeInput
259
- ? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
260
- : `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
261
- remediation: looksLikeInput
262
- ? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
263
- : "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
264
- context: { router, phase: "2" },
265
- });
266
- }
267
-
268
351
  /**
269
352
  * Is fd 0 attached to something that could be carrying input?
270
353
  *
@@ -290,11 +373,8 @@ function stdinMayCarryInput(): boolean {
290
373
  /**
291
374
  * Split the positionals into targets and the one that may be the input.
292
375
  *
293
- * Pure and cheap **on purpose**: the caller must be able to refuse the live and
294
- * fan-out forms BEFORE any input source is touched. Reading first meant
295
- * `centrs explain router --file -` blocked on EOF before returning
296
- * `usage/not-implemented`, and `… --file missing` reported a file error instead
297
- * of the live refusal — the wrong answer, arrived at slowly.
376
+ * Pure and cheap: fan-out and unsupported facets are refused before any file
377
+ * or stdin is read. Supported live calls read the input before connecting.
298
378
  *
299
379
  * `--file` replaces the input positional, so with it every positional is a
300
380
  * target. Otherwise the LAST positional is the input and everything before it is
@@ -320,7 +400,7 @@ function splitPositionals(parsed: ExplainCliArgs): {
320
400
  }
321
401
 
322
402
  /**
323
- * Read the input text, once the invocation is known to be offline.
403
+ * Read the input text after the invocation shape has been checked.
324
404
  *
325
405
  * ## Ambient stdin is read ONLY when nothing else could be the input
326
406
  *
@@ -396,7 +476,40 @@ async function readFileInput(path: string): Promise<string> {
396
476
  }
397
477
 
398
478
  /** Flags whose NEXT token is a value, so a scanner must skip it. */
399
- const VALUE_FLAGS = new Set(["--file", "--fail-on", "--format"]);
479
+ const VALUE_FLAGS = new Set([
480
+ "--file",
481
+ "--fail-on",
482
+ "--format",
483
+ "--via",
484
+ "--host",
485
+ "--port",
486
+ "--username",
487
+ "--user",
488
+ "-u",
489
+ "--password",
490
+ "-p",
491
+ "--timeout",
492
+ "--cdb-file",
493
+ "--cdb-password",
494
+ "--resolve",
495
+ "--quickchr",
496
+ "--cursor",
497
+ ]);
498
+
499
+ const LIVE_CONNECTION_FLAGS = new Set([
500
+ "--via",
501
+ "--port",
502
+ "--username",
503
+ "--user",
504
+ "-u",
505
+ "--password",
506
+ "-p",
507
+ "--timeout",
508
+ "--insecure",
509
+ "--cdb-file",
510
+ "--cdb-password",
511
+ "--resolve",
512
+ ]);
400
513
 
401
514
  /**
402
515
  * The render options, recovered when the parse loop THREW.
@@ -472,22 +585,68 @@ export async function runExplainCli(args: readonly string[]): Promise<number> {
472
585
  const { targets, inputPositional } = splitPositionals(parsed);
473
586
  if (targets.length > 1) throw fanoutNotSupportedError({ targets });
474
587
  const router = targets[0];
475
- if (router !== undefined) throw liveNotImplementedError(router);
588
+ assertExplainTarget({ targetInput: router, quickchr: parsed.quickchr });
589
+ if (router !== undefined && (/^\//.test(router) || /^:[a-z]/i.test(router)))
590
+ throw new CentrsError({
591
+ code: "input/invalid-command",
592
+ summary:
593
+ "The input was probably not quoted; two positionals mean router then input.",
594
+ remediation:
595
+ "Quote the whole input as one argument: `centrs explain '/ip/route print'`.",
596
+ });
597
+ const live =
598
+ router !== undefined ||
599
+ parsed.quickchr !== undefined ||
600
+ parsed.host !== undefined;
601
+ if (!live && parsed.connectionFlag !== undefined)
602
+ throw new CentrsError({
603
+ code: "input/invalid-command",
604
+ summary: `${parsed.connectionFlag} requires a router target for live explain.`,
605
+ remediation:
606
+ "Add <router>, --host <host>, or --quickchr <name>; remove the connection flag for offline analysis.",
607
+ context: { flag: parsed.connectionFlag },
608
+ });
609
+ if (
610
+ parsed.facets.length === 0 &&
611
+ (parsed.cursor !== undefined || parsed.full)
612
+ )
613
+ throw new CentrsError({
614
+ code: "usage/conflicting-flags",
615
+ summary: `${parsed.cursor !== undefined ? "--cursor" : "--full"} applies only with --complete or --schema.`,
616
+ remediation: "Add --complete or --schema, or remove the flag.",
617
+ });
476
618
 
477
619
  const { input, warnings } = await readInput(parsed, inputPositional);
620
+ resolveExplainCursor(input, parsed.cursor);
478
621
 
479
- const envelope = explainEnvelope(input, {
622
+ const options = {
480
623
  format,
481
624
  curl: parsed.curl,
482
625
  tokens: parsed.tokens,
483
626
  warnings,
484
627
  tips:
485
- parsed.facets.length > 0
628
+ !live && parsed.facets.length > 0
486
629
  ? [buildExplainFacetTip(parsed.facets)]
487
630
  : undefined,
488
- });
489
- console.log(renderExplainEnvelope(envelope, format.value));
490
- return explainExitCode(envelope.data.verdict, parsed.failOn);
631
+ };
632
+ const envelope = live
633
+ ? await explainLiveEnvelope(
634
+ {
635
+ ...parsed,
636
+ targetInput: router,
637
+ input,
638
+ schema: parsed.facets.includes("--schema"),
639
+ complete: parsed.facets.includes("--complete"),
640
+ },
641
+ options,
642
+ env,
643
+ )
644
+ : explainEnvelope(input, options);
645
+ if (envelope.ok) console.log(renderExplainEnvelope(envelope, format.value));
646
+ else console.error(renderExplainEnvelope(envelope, format.value));
647
+ return envelope.ok
648
+ ? explainExitCode(envelope.data.verdict, parsed.failOn)
649
+ : 1;
491
650
  } catch (error) {
492
651
  // Only here is the parser's answer unavailable — it threw. `config` is
493
652
  // whatever loaded before the throw, which is `{}` when the loader itself is
@@ -185,10 +185,17 @@ export interface EnvelopeValidationMeta {
185
185
  * `stages[]` then says which stage was not reached and why (GH#435).
186
186
  */
187
187
  result?: "passed" | "failed" | "skipped";
188
- /** Syntax gate status for commands that use RouterOS `:parse`. */
188
+ /**
189
+ * The syntax verdict (`:parse` or the offline analyzer). Present only when a
190
+ * stage gave one; absent means no stage was asked (#535).
191
+ */
189
192
  syntax?: boolean;
190
- /** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
191
- semantic?: boolean | "not-applicable";
193
+ /**
194
+ * The name-level verdict (`/console/inspect` or a console `:parse` `bad
195
+ * parameter`). Present only when a stage gave one; absent means no stage was
196
+ * asked, including for script input, which has no names to check (#535).
197
+ */
198
+ semantic?: boolean;
192
199
  availableAttributes?: readonly string[];
193
200
  /**
194
201
  * Per-stage detail for the gate described in
@@ -431,6 +431,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
431
431
  code: "routeros/mac-telnet-unsupported-auth",
432
432
  summary: "The device requested an unsupported MAC-Telnet auth mode.",
433
433
  },
434
+ {
435
+ code: "routeros/permission-denied",
436
+ summary:
437
+ "RouterOS refused the command because the user's group lacks a policy it needs.",
438
+ },
434
439
  {
435
440
  code: "routeros/protocol-not-implemented",
436
441
  summary: "The requested protocol is planned but not implemented yet.",
@@ -563,6 +568,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
563
568
  code: "transport/dns",
564
569
  summary: "The device host name could not be resolved.",
565
570
  },
571
+ {
572
+ code: "transport/edge-auth-failed",
573
+ summary:
574
+ "An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the request before RouterOS answered it.",
575
+ },
566
576
  {
567
577
  code: "transport/host-key-mismatch",
568
578
  summary: "The device's SSH host key did not match the known key.",
@@ -652,6 +662,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
652
662
  summary:
653
663
  "The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
654
664
  },
665
+ {
666
+ code: "usage/invalid-cursor",
667
+ summary:
668
+ "An explain --cursor is not a byte offset inside the input being analyzed.",
669
+ },
655
670
  {
656
671
  code: "usage/invalid-method",
657
672
  summary: "The -X/--method value is not a supported HTTP method.",
@@ -104,11 +104,64 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
104
104
  if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
105
105
  return true;
106
106
  }
107
+ if (isBareMenuNavigation(command)) {
108
+ return false;
109
+ }
107
110
  return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
108
111
  }
109
112
  return scriptContainsWriteShapedCommand(command.input);
110
113
  }
111
114
 
115
+ /**
116
+ * A truly bare known-menu path (`/system/identity`, no attributes or queries)
117
+ * is console navigation — a no-op read, not a command — so it needs no write
118
+ * confirmation. The APIs refuse it (`no such command`) and the consoles
119
+ * navigate silently; neither mutates.
120
+ *
121
+ * This is narrower than the script branch on purpose. There, `/ip firewall
122
+ * filter` stays write-shaped (#387): a later line may carry a relative verb,
123
+ * so navigation fails closed. Here structured mode proves the input is a
124
+ * single path token (any second word without `=` — including a second line —
125
+ * falls back to script), so no follow-up is possible. The carve-out still
126
+ * fails closed three ways: anything the menu table does not carry stays gated,
127
+ * a verb in the known-mutator list stays gated even if the table ever
128
+ * mis-tags its path, and any attribute or query (including `;`-smuggled text,
129
+ * which lands in the verb or an attribute, never in a clean menu reading)
130
+ * keeps the gate shut. The gate is shared, so bare menus also stop tripping
131
+ * `cdb/write-not-permitted` on `mcp=ro` targets (and stop needing
132
+ * `confirm:true` on `mcp=rw` ones), and `explain`'s `writeShaped` flips to
133
+ * false for these inputs — harmless in all three, since the input is a
134
+ * no-op read either way.
135
+ */
136
+ function isBareMenuNavigation(command: CanonicalExecuteCommand): boolean {
137
+ if (
138
+ Object.keys(command.attributes).length > 0 ||
139
+ command.queries.length > 0
140
+ ) {
141
+ return false;
142
+ }
143
+ if (KNOWN_WRITE_EXECUTE_VERBS.has(command.verb.toLowerCase())) {
144
+ return false;
145
+ }
146
+ const segments = `${command.path}/${command.verb}`.split("/").filter(Boolean);
147
+ if (segments.length < 2) {
148
+ return false;
149
+ }
150
+ return isKnownMenuPath(segments);
151
+ }
152
+
153
+ /**
154
+ * Whether `execute` was asked to run a bare menu path (see
155
+ * {@link isBareMenuNavigation}). The envelope carries a steering tip for it:
156
+ * navigation runs — the gate no longer stops it — but it answers nothing, so
157
+ * the tip names the read that does.
158
+ */
159
+ export function isBareMenuPathCommand(
160
+ command: CanonicalExecuteCommand,
161
+ ): boolean {
162
+ return command.mode === "structured" && isBareMenuNavigation(command);
163
+ }
164
+
112
165
  /**
113
166
  * The attributes that turn an otherwise read-only command into a device write.
114
167
  *
@@ -94,6 +94,7 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
94
94
  "transport/auth-failed",
95
95
  "transport/connection-refused",
96
96
  "transport/dns",
97
+ "transport/edge-auth-failed",
97
98
  "transport/host-key-mismatch",
98
99
  "transport/insecure-trust",
99
100
  "transport/local-tool-missing",
@@ -106,15 +107,24 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
106
107
  * #448: a write whose request was already sent when a transport failure ended
107
108
  * it may have run on the router. It is never retried, and its error envelope
108
109
  * says the outcome is unknown rather than implying nothing happened.
110
+ *
111
+ * #549: the same warning covers a dispatched multi-statement write refused
112
+ * with `routeros/permission-denied`, which can be partly applied — statements
113
+ * before the refused one already ran. A single refused statement did not run
114
+ * (#541), so it stays silent; the caller passes whether the dispatch carried
115
+ * more than one statement.
109
116
  */
110
117
  export function sentWriteWarnings(
111
118
  write: boolean,
112
119
  error: unknown,
113
120
  trace: ValidationTrace | undefined,
121
+ multiStatement = false,
114
122
  ): Warning[] {
115
123
  if (!write || !trace?.dispatched) return [];
116
- if (
117
- !(error instanceof CentrsError) ||
124
+ if (!(error instanceof CentrsError)) return [];
125
+ if (error.code === "routeros/permission-denied") {
126
+ if (!multiStatement) return [];
127
+ } else if (
118
128
  !error.code.startsWith("transport/") ||
119
129
  NOT_SENT_TRANSPORT_CODES.includes(error.code)
120
130
  ) {
@@ -39,12 +39,11 @@ export interface InspectBackend {
39
39
 
40
40
  /**
41
41
  * The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
42
- * and `completion` are wired today (the two the {@link InspectBackend} seam
42
+ * and `completion` are wired here (the two the {@link InspectBackend} seam
43
43
  * exposes), plus `input` for completion over CLI text
44
- * ({@link inspectWhereBooleans}); `highlight` (token-level error detection),
45
- * `syntax` (parse tree) and the `.query`/`.proplist` request fields are the
46
- * documented extension point for value-level validation — widen the adapter
47
- * seam when adding them.
44
+ * ({@link inspectWhereBooleans}). The protocol adapter also supports
45
+ * `highlight` and `syntax` for live explain, which sends `syntax` for argument
46
+ * paths only (`src/explain-live-facets.ts`).
48
47
  */
49
48
  export type InspectRequestKind =
50
49
  | "child"
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
107
107
  * rules here; keep the most specific patterns above broader ones.
108
108
  */
109
109
  export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
110
+ {
111
+ code: "routeros/permission-denied",
112
+ description:
113
+ "RouterOS refused the command because the user's group lacks a policy it needs.",
114
+ // Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
115
+ // `not enough permissions (9)` for a write without `write`, and
116
+ // `std failure: not allowed (9)` for any request (even a GET) when the
117
+ // group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
118
+ // first string; `/execute as-string` returns it as text with a
119
+ // `(<path>; line N)` suffix. In a multi-statement script the refusal is
120
+ // the last line, after earlier statements' stdout (#549: `:put
121
+ // "p5-first"; /system/identity/set …` as a read-only user returns HTTP
122
+ // 200 with ret `p5-first\r\nnot enough permissions (9)
123
+ // (/system/identity/set; line 1)`, identical on native-api), so the
124
+ // trailed form matches too — with the location suffix required, so a
125
+ // script that merely prints the bare phrase stays ordinary output.
126
+ test: /not enough permissions|not allowed \(9\)/i,
127
+ resultTest:
128
+ /^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
129
+ build: (_match, raw) => ({
130
+ summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
131
+ remediation:
132
+ "Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
133
+ }),
134
+ },
110
135
  {
111
136
  code: "routeros/unknown-path",
112
137
  description:
@@ -1002,8 +1002,7 @@ function devicePassed(
1002
1002
  source: CMR_VALIDATION_SOURCE,
1003
1003
  result: "passed",
1004
1004
  syntax: true,
1005
- semantic:
1006
- resolved.canonical.mode === "structured" ? true : "not-applicable",
1005
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
1007
1006
  stages: passedStages(
1008
1007
  offline ?? { verdict: "pass", warnings: [] },
1009
1008
  CMR_VALIDATION_SOURCE,
@@ -1023,12 +1022,7 @@ function deviceRejected(
1023
1022
  enabled: true,
1024
1023
  source: CMR_VALIDATION_SOURCE,
1025
1024
  result: "failed",
1026
- ...(nameRejected
1027
- ? { syntax: true, semantic: false }
1028
- : {
1029
- syntax: false,
1030
- ...(structured ? {} : { semantic: "not-applicable" as const }),
1031
- }),
1025
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
1032
1026
  stages: [
1033
1027
  offlineStagePassed(offline?.verdict),
1034
1028
  { stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },