@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -22,6 +22,7 @@ import {
22
22
  describeCentrs,
23
23
  devicesOutputFormats,
24
24
  editInteractiveOnlyError,
25
+ initCdb,
25
26
  listDevices,
26
27
  listGroups,
27
28
  loadCdb,
@@ -46,7 +47,8 @@ import {
46
47
 
47
48
  export const devicesCommand: CliCommandMetadata = {
48
49
  name: "devices",
49
- usage: "centrs devices <list|show|groups|add|edit|set|remove> [args] [flags]",
50
+ usage:
51
+ "centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
50
52
  summary:
51
53
  "Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
52
54
  options: [
@@ -197,6 +199,7 @@ export const devicesCommand: CliCommandMetadata = {
197
199
  };
198
200
 
199
201
  type DevicesSubcommand =
202
+ | "init"
200
203
  | "list"
201
204
  | "show"
202
205
  | "groups"
@@ -378,6 +381,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
378
381
  }
379
382
 
380
383
  const subcommands: readonly DevicesSubcommand[] = [
384
+ "init",
381
385
  "list",
382
386
  "show",
383
387
  "groups",
@@ -389,14 +393,14 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
389
393
  const [rawSub, ...rest] = positional;
390
394
  if (rawSub === undefined) {
391
395
  throw new Error(
392
- "`centrs devices` requires a subcommand: list, show, groups, add, edit, set, or remove.",
396
+ "`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
393
397
  );
394
398
  }
395
399
  // Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
396
400
  const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
397
401
  if (!subcommands.includes(sub)) {
398
402
  throw new Error(
399
- `Unknown devices subcommand: ${rawSub}. Use list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
403
+ `Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
400
404
  );
401
405
  }
402
406
  parsed.subcommand = sub;
@@ -606,6 +610,15 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
606
610
  }
607
611
 
608
612
  envSnapshot = Bun.env;
613
+ if (parsed.subcommand === "init") {
614
+ const envelope = await initCdb({
615
+ cdbFile: parsed.cdbFile,
616
+ cdbPassword: parsed.cdbPassword,
617
+ env: envSnapshot,
618
+ });
619
+ console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
620
+ return 0;
621
+ }
609
622
  const cdb = await loadCdb({
610
623
  cdbFile: parsed.cdbFile,
611
624
  cdbPassword: parsed.cdbPassword,
@@ -92,7 +92,7 @@ export const explainCliCommand: CliCommandMetadata = {
92
92
  {
93
93
  flag: "--tokens",
94
94
  description:
95
- "Emit the total, gapless token partition behind `data.tokens[]` (provisional `class` until #264 B5: every byte not claimed by an analyzer is `unclassified`).",
95
+ "Emit the total, gapless token partition behind `data.tokens[]` (every byte not claimed by an analyzer is `unclassified`).",
96
96
  },
97
97
  {
98
98
  flag: "--format",
@@ -6,6 +6,7 @@
6
6
  * `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
7
7
  */
8
8
 
9
+ import { toYaml } from "../core/yaml.ts";
9
10
  import {
10
11
  asCentrsError,
11
12
  CentrsError,
@@ -18,7 +19,6 @@ import {
18
19
  type TerminalIo,
19
20
  type TerminalRequest,
20
21
  } from "../index.ts";
21
- import { toYaml } from "../retrieve.ts";
22
22
  import {
23
23
  type CliCommandMetadata,
24
24
  expectValue,
@@ -111,6 +111,50 @@ export interface EnvelopeTargetMeta {
111
111
  sources?: Record<string, SettingSource>;
112
112
  }
113
113
 
114
+ /**
115
+ * One stage of the two-stage validation gate (GH#354).
116
+ *
117
+ * `offline` is the corpus-gated analyzer (`src/offline-gate.ts`): it opens no
118
+ * connection, so a `failed` offline stage means the device was never contacted.
119
+ * `device` is the RouterOS-side preflight — `:put [:parse …]` and/or
120
+ * `/console/inspect request=child`, depending on the command and transport.
121
+ *
122
+ * Stages appear in the order they run, and a stage that did not run is still
123
+ * listed with `result: "skipped"`. That is the point of the array: a reader must
124
+ * be able to tell "the device accepted it" from "the device was never asked".
125
+ */
126
+ export interface EnvelopeValidationStage {
127
+ stage: "offline" | "device";
128
+ /** Validator identity, e.g. `offline explain (canonicalizer)` or `:put [:parse]`. */
129
+ source: string;
130
+ result: "passed" | "failed" | "skipped";
131
+ /** Why a stage was skipped, when the reason is not simply `validate=false`. */
132
+ reason?: string;
133
+ }
134
+
135
+ /**
136
+ * Mutable sink that lets an error path report the validation that ACTUALLY ran.
137
+ *
138
+ * `runResolvedExecute` / `runResolvedApi` finish the gate and then do the real
139
+ * work, and the error envelope is built by a *different* function after the
140
+ * throw unwinds past them. Without this, a `routeros/*` fault on the run — which
141
+ * happens with both gate stages already green — got a reconstructed
142
+ * `result: "failed"` that erased the real outcome. The runner writes
143
+ * {@link ValidationTrace.validation} the moment the gate returns; the error
144
+ * builder prefers it over anything it could guess (GH#354, PR #356 review).
145
+ */
146
+ export interface ValidationTrace {
147
+ /** The completed gate result, set once validation finishes. */
148
+ validation?: EnvelopeValidationMeta;
149
+ /**
150
+ * The stage-1 verdict, recorded as soon as stage 1 returns — before stage 2
151
+ * can throw. `warn` is an abstention, and an error envelope that reported it
152
+ * as `passed` would be telling a consumer the analyzer read bytes it declined
153
+ * to judge.
154
+ */
155
+ offlineVerdict?: "pass" | "warn";
156
+ }
157
+
114
158
  export interface EnvelopeValidationMeta {
115
159
  enabled: boolean;
116
160
  /** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
@@ -121,6 +165,15 @@ export interface EnvelopeValidationMeta {
121
165
  /** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
122
166
  semantic?: boolean | "not-applicable";
123
167
  availableAttributes?: readonly string[];
168
+ /**
169
+ * Per-stage detail for the gate described in
170
+ * `docs/CONSTITUTION.md` → "Validation is the product".
171
+ *
172
+ * The flat `source` / `syntax` / `semantic` fields above stay the summary
173
+ * view and keep their meaning; this is the breakdown behind them. Absent on
174
+ * surfaces that have no RouterOS-command gate at all (`btest`).
175
+ */
176
+ stages?: readonly EnvelopeValidationStage[];
124
177
  }
125
178
 
126
179
  export interface EnvelopeTimingMeta {
@@ -40,6 +40,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
40
40
  code: "cdb/already-exists",
41
41
  summary: "A CDB entry with that target already exists.",
42
42
  },
43
+ {
44
+ code: "cdb/create-failed",
45
+ summary: "A new CDB file could not be created at the resolved location.",
46
+ },
43
47
  {
44
48
  code: "cdb/created",
45
49
  summary: "A new CDB file was created at the resolved location.",
@@ -57,6 +61,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
57
61
  code: "cdb/empty-selection",
58
62
  summary: "The target selection matched no CDB records.",
59
63
  },
64
+ {
65
+ code: "cdb/file-exists",
66
+ summary:
67
+ "`devices init` found a CDB already at the resolved location and wrote nothing.",
68
+ },
60
69
  {
61
70
  code: "cdb/invalid-option",
62
71
  summary: "A comment kv-soup option carried an invalid value.",
@@ -430,6 +439,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
430
439
  summary:
431
440
  "Auto-selection moved the operation to a different method (informational auto-hop).",
432
441
  },
442
+ {
443
+ code: "transport/cancel-unacknowledged",
444
+ summary:
445
+ "RouterOS did not acknowledge a stream's /cancel within the timeout; centrs ended the stream and closed the session locally.",
446
+ },
433
447
  {
434
448
  code: "transport/capability-unsupported",
435
449
  summary: "The chosen transport cannot perform this kind of operation.",
@@ -554,7 +568,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
554
568
  },
555
569
  {
556
570
  code: "validation/syntax",
557
- summary: "RouterOS rejected the command syntax during the :parse gate.",
571
+ summary:
572
+ "The command syntax was rejected by the validation gate — offline analysis, or the RouterOS :parse probe.",
558
573
  },
559
574
  {
560
575
  code: "validation/unknown-attribute",
@@ -0,0 +1,455 @@
1
+ /**
2
+ * The **script-vs-structured execution gate** and its canonical command shape.
3
+ *
4
+ * `execute.ts` owned this pair, which put every consumer of the gate — the MCP
5
+ * tool surface and `explain`'s offline analysis among them — one import away
6
+ * from the transport stack (`mac-telnet` -> `node:dgram`, `native-api` ->
7
+ * `node:crypto`). The functions themselves are pure string work, so they live in
8
+ * `src/core/` and `execute.ts` re-exports them: the public API, the locked
9
+ * contract table in `test/unit/execute-canonicalize-contract.test.ts`, and the
10
+ * gate's behavior are all unchanged by the move (#312).
11
+ *
12
+ * Nothing here reads settings, the filesystem, CDB, or a device. Changing that
13
+ * re-couples `explain`'s browser entry to the transport graph.
14
+ */
15
+
16
+ import { isKnownMenuPath } from "../explain/is-known-menu.ts";
17
+
18
+ export interface CanonicalExecuteCommand {
19
+ mode: "structured" | "script";
20
+ input: string;
21
+ path: string;
22
+ verb: string;
23
+ attributes: Record<string, string>;
24
+ queries: string[];
25
+ }
26
+
27
+ /**
28
+ * The **script-vs-structured execution gate** — centrs's load-bearing
29
+ * discriminator for which validation and transport path runs. Write-shape
30
+ * detection is intentionally independent: a raw-script fallback must not bypass
31
+ * confirmation merely because it cannot be represented as attributes. centrs
32
+ * owns this gate; the shared,
33
+ * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
34
+ * publish is for *canonicalization*, never the structured-mode predicate —
35
+ * widening what counts as `structured` is a product regression. Behavior is
36
+ * pinned by `test/unit/execute-canonicalize-contract.test.ts`.
37
+ *
38
+ * centrs deliberately does **not** vendor the shared parser yet. Preconditions
39
+ * for adopting it: (1) that contract stays green (no gate widening); (2) the
40
+ * vendored file is clean under centrs's strict `tsconfig` (or explicitly
41
+ * quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
42
+ * the same parser shape, so it is genuinely shared, not a premature first copy.
43
+ */
44
+ export function canonicalizeExecuteCommand(
45
+ input: string,
46
+ ): CanonicalExecuteCommand {
47
+ const asScript = (): CanonicalExecuteCommand => ({
48
+ mode: "script",
49
+ input,
50
+ path: "",
51
+ verb: "",
52
+ attributes: {},
53
+ queries: [],
54
+ });
55
+
56
+ const trimmed = input.trim();
57
+ if (!trimmed.startsWith("/")) {
58
+ return asScript();
59
+ }
60
+
61
+ const tokens = tokenizeRouterOsCli(trimmed);
62
+ const pathToken = tokens[0] ?? "";
63
+ const pathParts = pathToken.split("/").filter(Boolean);
64
+ if (pathParts.length < 2) {
65
+ return asScript();
66
+ }
67
+
68
+ const verb = pathParts.at(-1) ?? "";
69
+ const path = `/${pathParts.slice(0, -1).join("/")}`;
70
+ const attributes: Record<string, string> = {};
71
+ const queries: string[] = [];
72
+ for (const token of tokens.slice(1)) {
73
+ // `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
74
+ // represented as a structured attribute map: the inner command contains
75
+ // spaces this tokenizer splits on, which would mangle a write-shaped
76
+ // command into corrupt key=value pairs. Fall back to the raw-script path
77
+ // so RouterOS evaluates the subshell with its real semantics.
78
+ if (token.includes("[") || token.includes("]")) {
79
+ return asScript();
80
+ }
81
+ if (token.startsWith("?")) {
82
+ queries.push(token);
83
+ continue;
84
+ }
85
+ const separator = token.indexOf("=");
86
+ if (separator <= 0) {
87
+ return asScript();
88
+ }
89
+ attributes[token.slice(0, separator)] = token.slice(separator + 1);
90
+ }
91
+
92
+ return {
93
+ mode: "structured",
94
+ input,
95
+ path,
96
+ verb,
97
+ attributes,
98
+ queries,
99
+ };
100
+ }
101
+
102
+ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
103
+ if (command.mode === "structured") {
104
+ if (FILE_OUTPUT_ATTRIBUTE in command.attributes) {
105
+ return true;
106
+ }
107
+ return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
108
+ }
109
+ return scriptContainsWriteShapedCommand(command.input);
110
+ }
111
+
112
+ /**
113
+ * The attribute that turns an otherwise read-only verb into a device write.
114
+ *
115
+ * RouterOS routes command output to a file on the device when `file=` is
116
+ * supplied, so a read verb creates a file: `/ip/address/export file=address`
117
+ * writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
118
+ * from the RouterOS manual — "Configuration Export" and "Scripting Tips and
119
+ * Tricks", where that idiom is the documented way to create a file and needs
120
+ * the script `write` policy). The verb allowlist cannot see that, so the
121
+ * attribute is checked independently of it.
122
+ */
123
+ const FILE_OUTPUT_ATTRIBUTE = "file";
124
+
125
+ /** True for a `file=<name>` token — the write-shaping output redirect. */
126
+ function isFileOutputToken(token: string): boolean {
127
+ return token.startsWith(`${FILE_OUTPUT_ATTRIBUTE}=`);
128
+ }
129
+
130
+ /**
131
+ * Commands that are safe to execute without write confirmation.
132
+ *
133
+ * This is deliberately an allowlist: RouterOS publishes no mutation bit for a
134
+ * command, and treating a newly introduced verb as read-only would silently
135
+ * widen the execution boundary. False positives require `--yes`; false
136
+ * negatives mutate a router. API uses the same conservative rule (only
137
+ * print/get/listen are read-classed).
138
+ */
139
+ const READ_ONLY_EXECUTE_VERBS: ReadonlySet<string> = new Set([
140
+ "check",
141
+ "check-installation",
142
+ "export",
143
+ "find",
144
+ "get",
145
+ "monitor",
146
+ "monitor-traffic",
147
+ "ping",
148
+ "print",
149
+ "scan",
150
+ "sniff",
151
+ "torch",
152
+ "traceroute",
153
+ ]);
154
+
155
+ /** Known mutators disambiguate the space-separated CLI spelling from a menu. */
156
+ const KNOWN_WRITE_EXECUTE_VERBS: ReadonlySet<string> = new Set([
157
+ "add",
158
+ "comment",
159
+ "delete",
160
+ "disable",
161
+ "edit",
162
+ "enable",
163
+ "flush",
164
+ "format",
165
+ "format-drive",
166
+ "import",
167
+ "install",
168
+ "move",
169
+ "password",
170
+ "reboot",
171
+ "redo",
172
+ "remove",
173
+ "reset",
174
+ "reset-configuration",
175
+ "reset-counters",
176
+ "reset-counters-all",
177
+ "run",
178
+ "set",
179
+ "shutdown",
180
+ "sign",
181
+ "start",
182
+ "stop",
183
+ "unset",
184
+ "upgrade",
185
+ "undo",
186
+ ]);
187
+
188
+ /** Script directives that can launch a command outside the visible statement. */
189
+ const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
190
+ ":execute",
191
+ ]);
192
+
193
+ /**
194
+ * Script directives that are safe to run without device-write confirmation:
195
+ * output, control flow and pure value functions, all members of `explain`'s
196
+ * grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`). A
197
+ * directive's `do={…}` or `[…]` body is its own command head and is checked on
198
+ * its own. Deliberately absent: `:execute` and `:parse` (run built code),
199
+ * `:global` and `:set` (outlive the script), and `:import`, `:password`,
200
+ * `:undo`, `:redo` (configuration changes).
201
+ */
202
+ const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
203
+ ":break",
204
+ ":continue",
205
+ ":do",
206
+ ":error",
207
+ ":for",
208
+ ":foreach",
209
+ ":if",
210
+ ":len",
211
+ ":local",
212
+ ":onerror",
213
+ ":pick",
214
+ ":put",
215
+ ":return",
216
+ ":tostr",
217
+ ":typeof",
218
+ ":while",
219
+ ]);
220
+
221
+ function scriptContainsWriteShapedCommand(input: string): boolean {
222
+ for (const statement of splitUnquotedStatements(input)) {
223
+ const tokens = tokenizeRouterOsCli(statement);
224
+ // `file=` redirects output to a device file, so it is a write even under a
225
+ // read verb. Checked before the verb rules for the same reason the
226
+ // structured branch checks it independently of the allowlist.
227
+ if (
228
+ tokens.some((token) => isFileOutputToken(trimGroupingPunctuation(token)))
229
+ ) {
230
+ return true;
231
+ }
232
+ // A script may navigate to a menu on one line and use a relative verb on
233
+ // the next. Recognize grounded mutators even when no slash appears in that
234
+ // statement; quoted words were masked before tokenization.
235
+ if (
236
+ tokens.some((token) =>
237
+ KNOWN_WRITE_EXECUTE_VERBS.has(trimGroupingPunctuation(token)),
238
+ )
239
+ ) {
240
+ return true;
241
+ }
242
+ if (
243
+ tokens.some((token) =>
244
+ WRITE_SHAPED_SCRIPT_DIRECTIVES.has(token.replace(/^[{[(]+/, "")),
245
+ )
246
+ ) {
247
+ return true;
248
+ }
249
+ // Every command the statement runs must be proven read-only, not just the
250
+ // first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
251
+ // reads with `find` and writes with an unlearned mutator.
252
+ if (!commandHeads(statement).every(isReadOnlyCommand)) {
253
+ return true;
254
+ }
255
+ }
256
+ return false;
257
+ }
258
+
259
+ /**
260
+ * The commands a statement runs: one at its start and one after every `[` or
261
+ * `{`, wherever that opener sits — an argument value (`in=[/ip address find]`,
262
+ * `do={/user expire-password $i}`), a sigil (`$[…]`), a concatenation
263
+ * (`"x".[…]`) or a token of its own (`do={ /user … }`). Parentheses group
264
+ * expressions, not commands, so they do not start one. Each head reads only up
265
+ * to the next opener, which begins its own head.
266
+ */
267
+ function commandHeads(statement: string): string[][] {
268
+ const heads: string[][] = [];
269
+ const starts = [0];
270
+ for (let index = 0; index < statement.length; index += 1) {
271
+ const char = statement[index];
272
+ if (char === "[" || char === "{") starts.push(index + 1);
273
+ }
274
+ for (const start of starts) {
275
+ const nextOpener = statement.slice(start).search(/[[{(]/);
276
+ const segment =
277
+ nextOpener < 0
278
+ ? statement.slice(start)
279
+ : statement.slice(start, start + nextOpener);
280
+ const tokens = tokenizeRouterOsCli(segment);
281
+ const head = tokens[0];
282
+ if (head === undefined) continue;
283
+ const words = commandWords(head, tokens.slice(1));
284
+ // A lone closer (`}` ending a multi-line block) runs nothing.
285
+ if (words.length > 0) heads.push(words);
286
+ }
287
+ return heads;
288
+ }
289
+
290
+ /**
291
+ * The path segments and verb of one command, from its head token and the bare
292
+ * words after it. The space-separated spelling puts menu segments and the verb
293
+ * in those words (`/ip firewall filter print`, `/interface 6to4 print`, `/ip
294
+ * firewall/filter print`). A word opening a bracket starts a nested command
295
+ * (`new-mutator [find]`) and a word trailing a closer ends this one (`:put [/ip
296
+ * address find]`), so the run stops at either.
297
+ */
298
+ function commandWords(head: string, following: readonly string[]): string[] {
299
+ const words: string[] = [];
300
+ const headWord = trimGroupingPunctuation(head);
301
+ if (headWord.startsWith(":")) return [headWord];
302
+ words.push(...headWord.split("/").filter(Boolean));
303
+ if (headWord !== head) return words;
304
+ for (const token of following) {
305
+ if (/^[{[(]/.test(token)) break;
306
+ const word = trimGroupingPunctuation(token);
307
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/.test(word))
308
+ break;
309
+ words.push(...word.split("/"));
310
+ if (word !== token) break;
311
+ }
312
+ return words;
313
+ }
314
+
315
+ /**
316
+ * A command is read-only when it is a read-only script directive, or when the
317
+ * first word past the longest known menu prefix is a read verb. Walking the
318
+ * menu table rather than taking the first known verb keeps an unlearned
319
+ * command from borrowing a later argument: `/user expire-password print` names
320
+ * `expire-password` (a published command, absent from every allowlist) with
321
+ * the username `print`. The menu table is a floor, so an unknown menu fails
322
+ * closed here, as does a run of menus with no verb (navigation for a later
323
+ * relative command).
324
+ */
325
+ function isReadOnlyCommand(words: readonly string[]): boolean {
326
+ const head = words[0] ?? "";
327
+ if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
328
+ let depth = 0;
329
+ while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
330
+ depth += 1;
331
+ }
332
+ const verb = words[depth];
333
+ return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
334
+ }
335
+
336
+ function trimGroupingPunctuation(value: string): string {
337
+ let start = 0;
338
+ let end = value.length;
339
+ while (start < end && "{[(".includes(value[start] ?? "")) start += 1;
340
+ while (end > start && "}])".includes(value[end - 1] ?? "")) end -= 1;
341
+ return value.slice(start, end);
342
+ }
343
+
344
+ /**
345
+ * Split at real statement boundaries and blank quoted text, which does not
346
+ * execute — except a `$[…]` or `$(…)` substitution inside a double-quoted
347
+ * string, which is code: `:put "$[/system identity set name=x]"` runs the set.
348
+ * Its body is kept (with its opener, so it becomes a command head) and may nest
349
+ * strings of its own. The frame grammar is `explain`'s (`stepFrame` in
350
+ * `src/explain/quoted-string.ts`).
351
+ */
352
+ function splitUnquotedStatements(input: string): string[] {
353
+ const statements: string[] = [];
354
+ let current = "";
355
+ // `"` / `'` are string frames; `]` / `)` are the closer a code frame inside
356
+ // a string is waiting for. An empty stack is top-level code.
357
+ const frames: string[] = [];
358
+ let escaped = false;
359
+ for (let index = 0; index < input.length; index += 1) {
360
+ const char = input[index] ?? "";
361
+ const top = frames.at(-1);
362
+ const inString = top === '"' || top === "'";
363
+ if (escaped) {
364
+ current += inString ? " " : char;
365
+ escaped = false;
366
+ continue;
367
+ }
368
+ if (char === "\\") {
369
+ escaped = true;
370
+ current += inString ? " " : char;
371
+ continue;
372
+ }
373
+ if (inString) {
374
+ if (char === top) {
375
+ frames.pop();
376
+ } else if (
377
+ top === '"' &&
378
+ char === "$" &&
379
+ (input[index + 1] === "[" || input[index + 1] === "(")
380
+ ) {
381
+ const opener = input[index + 1] ?? "";
382
+ frames.push(opener === "[" ? "]" : ")");
383
+ current += ` ${opener}`;
384
+ index += 1;
385
+ continue;
386
+ }
387
+ current += " ";
388
+ continue;
389
+ }
390
+ if (char === '"' || char === "'") {
391
+ frames.push(char);
392
+ current += " ";
393
+ continue;
394
+ }
395
+ if (top !== undefined) {
396
+ // Inside a string's code frame: track nesting to find its closer.
397
+ if (char === top) frames.pop();
398
+ else if (char === "[") frames.push("]");
399
+ else if (char === "(") frames.push(")");
400
+ }
401
+ if (char === ";" || char === "\n" || char === "\r") {
402
+ statements.push(current);
403
+ current = "";
404
+ continue;
405
+ }
406
+ current += char;
407
+ }
408
+ statements.push(current);
409
+ return statements;
410
+ }
411
+
412
+ function tokenizeRouterOsCli(input: string): string[] {
413
+ const tokens: string[] = [];
414
+ let current = "";
415
+ let quote: '"' | "'" | undefined;
416
+ let escaped = false;
417
+ for (const char of input) {
418
+ if (escaped) {
419
+ current += char;
420
+ escaped = false;
421
+ continue;
422
+ }
423
+ if (char === "\\") {
424
+ escaped = true;
425
+ continue;
426
+ }
427
+ if (quote) {
428
+ if (char === quote) {
429
+ quote = undefined;
430
+ } else {
431
+ current += char;
432
+ }
433
+ continue;
434
+ }
435
+ if (char === '"' || char === "'") {
436
+ quote = char;
437
+ continue;
438
+ }
439
+ if (/\s/.test(char)) {
440
+ if (current.length > 0) {
441
+ tokens.push(current);
442
+ current = "";
443
+ }
444
+ continue;
445
+ }
446
+ current += char;
447
+ }
448
+ if (escaped) {
449
+ current += "\\";
450
+ }
451
+ if (current.length > 0) {
452
+ tokens.push(current);
453
+ }
454
+ return tokens;
455
+ }