@tikoci/centrs 0.1.6 → 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 (38) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +8 -4
  3. package/commands/api/README.md +21 -10
  4. package/commands/api/examples.md +70 -2
  5. package/commands/devices/README.md +10 -1
  6. package/commands/devices/examples.md +33 -2
  7. package/commands/execute/README.md +29 -15
  8. package/commands/execute/examples.md +89 -0
  9. package/commands/explain/README.md +12 -1
  10. package/commands/mcp/README.md +2 -1
  11. package/commands/retrieve/examples.md +20 -0
  12. package/docs/CLI.md +1 -1
  13. package/docs/CONSTITUTION.md +11 -6
  14. package/docs/errors/cdb/create-failed.md +20 -0
  15. package/docs/errors/cdb/file-exists.md +23 -0
  16. package/docs/errors/input/invalid-target.md +25 -3
  17. package/docs/errors/routeros/invalid-value.md +3 -2
  18. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  19. package/package.json +1 -1
  20. package/src/api.ts +109 -25
  21. package/src/cli/devices.ts +16 -3
  22. package/src/core/error-catalog.ts +14 -0
  23. package/src/core/execute-command.ts +313 -6
  24. package/src/core/inspect.ts +22 -22
  25. package/src/core/routeros-errors.ts +66 -0
  26. package/src/data/winbox-cdb-write.ts +22 -8
  27. package/src/devices.ts +142 -28
  28. package/src/execute-fanout.ts +1 -1
  29. package/src/execute.ts +22 -51
  30. package/src/explain/catalog.ts +8 -8
  31. package/src/explain/menus.ts +4 -4
  32. package/src/index.ts +2 -0
  33. package/src/mcp/tools.ts +1 -1
  34. package/src/protocols/adapter.ts +12 -1
  35. package/src/protocols/mac-telnet-console.ts +6 -4
  36. package/src/protocols/native-api.ts +29 -3
  37. package/src/resolver/target.ts +32 -12
  38. package/src/retrieve.ts +17 -4
@@ -13,6 +13,8 @@
13
13
  * re-couples `explain`'s browser entry to the transport graph.
14
14
  */
15
15
 
16
+ import { isKnownMenuPath } from "../explain/is-known-menu.ts";
17
+
16
18
  export interface CanonicalExecuteCommand {
17
19
  mode: "structured" | "script";
18
20
  input: string;
@@ -24,8 +26,10 @@ export interface CanonicalExecuteCommand {
24
26
 
25
27
  /**
26
28
  * The **script-vs-structured execution gate** — centrs's load-bearing
27
- * discriminator (with {@link isWriteShaped}) for which validation runs and
28
- * whether the write-confirmation prompt fires. centrs owns this gate; the shared,
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,
29
33
  * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
30
34
  * publish is for *canonicalization*, never the structured-mode predicate —
31
35
  * widening what counts as `structured` is a product regression. Behavior is
@@ -96,10 +100,313 @@ export function canonicalizeExecuteCommand(
96
100
  }
97
101
 
98
102
  export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
99
- return (
100
- command.mode === "structured" &&
101
- ["add", "set", "remove"].includes(command.verb)
102
- );
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;
103
410
  }
104
411
 
105
412
  function tokenizeRouterOsCli(input: string): string[] {
@@ -55,12 +55,20 @@ export interface InspectChildItem {
55
55
  }
56
56
 
57
57
  /** A row from `request=completion`: a candidate token for a path/argument. */
58
+ /**
59
+ * One `request=completion` row. These are the only fields RouterOS sends, on
60
+ * every build recorded from 7.9.2 to 7.25beta3 (lsp-routeros-ts
61
+ * `test-data/inspect-shapes.v*.json`). `text` is the candidate's help string,
62
+ * never its name.
63
+ */
58
64
  export interface InspectCompletionItem {
59
65
  type?: string;
60
- name?: string;
61
66
  completion?: string;
62
- value?: string;
67
+ show?: string;
68
+ style?: string;
63
69
  text?: string;
70
+ offset?: string;
71
+ preference?: string;
64
72
  }
65
73
 
66
74
  /**
@@ -99,31 +107,23 @@ export function isCommandNode(child: InspectChildItem, name: string): boolean {
99
107
  }
100
108
 
101
109
  /**
102
- * Flatten completion rows into attribute names. Reads every name-like field a
103
- * RouterOS build might populate (`completion`/`name`/`value`/`text`), strips
104
- * everything from the first `=` onward (the `name=value` completion form), and
105
- * drops blanks. Returns the names in row order **without** de-duplication or
106
- * sorting — callers that need a stable set wrap with `[...new Set(names)].sort()`
107
- * (both current callers do).
110
+ * The candidate names from completion rows: `completion` of each
111
+ * `show: "true"` row that is not `syntax-meta`. `show: "false"` rows are
112
+ * syntax completions (`[`, `(`, `$`, `"`, `*`, `<value>`), not properties
113
+ * (CHR 7.23.7, on both `print,proplist` and `get,value-name`; #380). At an
114
+ * argument boundary RouterOS also offers a shown `=` ("argument value
115
+ * separator", `style: "syntax-meta"`) on every recorded build, 7.9.2 →
116
+ * 7.25beta3; no build offers a `name=value` completion. Returns
117
+ * the names in row order **without** de-duplication or sorting; callers that
118
+ * need a stable set wrap with `[...new Set(names)].sort()`.
108
119
  */
109
120
  export function extractCompletionNames(
110
121
  rows: readonly InspectCompletionItem[],
111
122
  ): string[] {
112
123
  return rows
113
- .flatMap((row) => [row.completion, row.name, row.value, row.text])
114
- .filter((value): value is string => typeof value === "string")
115
- .map((value) => nameBeforeEquals(value).trim())
116
- .filter((value) => value.length > 0);
117
- }
118
-
119
- /**
120
- * The token before the first `=` in a RouterOS completion row. Plain string
121
- * slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
122
- * polynomial-ReDoS flag on this exported function's caller-supplied input.
123
- */
124
- function nameBeforeEquals(value: string): string {
125
- const eq = value.indexOf("=");
126
- return eq === -1 ? value : value.slice(0, eq);
124
+ .filter((row) => row.show === "true" && row.style !== "syntax-meta")
125
+ .map((row) => row.completion?.trim() ?? "")
126
+ .filter((name) => name.length > 0);
127
127
  }
128
128
 
129
129
  /** `request=child` for a token path. */
@@ -88,6 +88,12 @@ export interface RouterOsErrorRule {
88
88
  description: string;
89
89
  /** Pattern matched against the trimmed RouterOS string. */
90
90
  test: RegExp;
91
+ /**
92
+ * Anchored form safe for classifying a successful transport's returned text.
93
+ * Unlike an HTTP error body / native trap, `/execute as-string` also carries
94
+ * ordinary stdout, so substring matches would turn user output into failures.
95
+ */
96
+ resultTest?: RegExp;
91
97
  /** Builds the normalized error fields from the regex match and raw string. */
92
98
  build: (match: RegExpMatchArray, raw: string) => RouterOsErrorRuleResult;
93
99
  }
@@ -106,6 +112,8 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
106
112
  description:
107
113
  "RouterOS did not recognize the command path or menu item ('no such ...').",
108
114
  test: /no such command prefix|no such item|no such entry/i,
115
+ resultTest:
116
+ /^(?:failure:\s*)?(?:no such command prefix|no such item|no such entry)\b.*$/i,
109
117
  build: (_match, raw) => ({
110
118
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
111
119
  remediation:
@@ -119,6 +127,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
119
127
  // (mac-telnet) says "bad parameter <name> (line N column M)" for the same
120
128
  // fault. Grounded on CHR 7.23.1.
121
129
  test: /(?:unknown|bad) parameter\s+(\S+)/i,
130
+ resultTest: /^(?:failure:\s*)?(?:unknown|bad) parameter\s+\S+.*$/i,
122
131
  build: (match) => {
123
132
  const parameter = cleanToken(match[1] ?? "");
124
133
  return {
@@ -135,6 +144,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
135
144
  "RouterOS console rejected the command word ('bad command name …').",
136
145
  // Console form (mac-telnet) of an unrecognized command/path word.
137
146
  test: /bad command name/i,
147
+ resultTest: /^(?:failure:\s*)?(?:\(<%%\s*)?bad command name\b.*$/i,
138
148
  build: (_match, raw) => ({
139
149
  summary: `RouterOS does not recognize the command: ${raw.trim()}`,
140
150
  remediation:
@@ -145,6 +155,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
145
155
  code: "routeros/invalid-value",
146
156
  description: "RouterOS rejected the value supplied for an argument.",
147
157
  test: /invalid value (?:for argument|of)\s+(\S+)/i,
158
+ resultTest: /^(?:failure:\s*)?invalid value (?:for argument|of)\s+\S+.*$/i,
148
159
  build: (match) => {
149
160
  const argument = cleanToken(match[1] ?? "");
150
161
  return {
@@ -155,11 +166,24 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
155
166
  };
156
167
  },
157
168
  },
169
+ {
170
+ code: "routeros/invalid-value",
171
+ description:
172
+ "RouterOS rejected a value because it does not match the accepted domain.",
173
+ test: /input does not match/i,
174
+ resultTest: /^(?:failure:\s*)?input does not match\b.*$/i,
175
+ build: (_match, raw) => ({
176
+ summary: `RouterOS rejected the supplied value: ${raw.trim()}`,
177
+ remediation:
178
+ "Check the accepted values for this command on the target RouterOS version, then retry with a matching value.",
179
+ }),
180
+ },
158
181
  {
159
182
  code: "routeros/session-closed",
160
183
  description:
161
184
  "RouterOS closed the REST session before the request completed (60-second cap).",
162
185
  test: /session closed/i,
186
+ resultTest: /^(?:failure:\s*)?session closed\b.*$/i,
163
187
  build: () => ({
164
188
  summary: "RouterOS closed the session before the request completed.",
165
189
  remediation:
@@ -171,6 +195,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
171
195
  description:
172
196
  "Generic RouterOS command failure carrying a `failure: <msg>` string.",
173
197
  test: /^\s*failure:\s*(.+?)\s*$/i,
198
+ resultTest: /^\s*failure:\s*.+?\s*$/i,
174
199
  build: (match) => {
175
200
  const failure = (match[1] ?? "").trim();
176
201
  return {
@@ -181,11 +206,27 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
181
206
  };
182
207
  },
183
208
  },
209
+ {
210
+ code: "routeros/command-failed",
211
+ description: "Generic RouterOS command failure carrying an error prefix.",
212
+ test: /^\s*error:\s*(.+?)\s*$/i,
213
+ resultTest: /^\s*error:\s*.+?\s*$/i,
214
+ build: (match) => {
215
+ const failure = (match[1] ?? "").trim();
216
+ return {
217
+ summary: `RouterOS command failed: ${failure}`,
218
+ remediation:
219
+ "Inspect the RouterOS error message, then adjust the script or request shape accordingly.",
220
+ context: { failure },
221
+ };
222
+ },
223
+ },
184
224
  {
185
225
  code: "routeros/command-failed",
186
226
  description:
187
227
  "RouterOS /execute as-string returned a script :error value with source location.",
188
228
  test: /^\s*(.+?)\s*\(:error; line \d+\)\s*$/i,
229
+ resultTest: /^\s*.+?\s*\(:error; line \d+\)\s*$/i,
189
230
  build: (match) => {
190
231
  const failure = (match[1] ?? "").trim();
191
232
  return {
@@ -203,6 +244,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
203
244
  code: "routeros/unknown-path",
204
245
  description: "RouterOS reported a path-shaped 'not found'.",
205
246
  test: /not found/i,
247
+ resultTest: /^\s*\/\S+\s+not found\s*$/i,
206
248
  build: (_match, raw) => ({
207
249
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
208
250
  remediation:
@@ -211,6 +253,30 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
211
253
  },
212
254
  ];
213
255
 
256
+ /**
257
+ * Grounded result shapes that intentionally use the transport-specific
258
+ * catch-all mapping. They are errors, but do not carry enough information for a
259
+ * more specific normalized code.
260
+ */
261
+ const ROUTEROS_RESULT_CATCH_ALL =
262
+ /^(?:syntax error|expected (?:end of command|command name|input value))\b[^\n]*$/i;
263
+
264
+ /**
265
+ * Map text returned by a successful transport only when it matches a grounded,
266
+ * stdout-safe result shape. Returns `undefined` for ordinary script output.
267
+ */
268
+ export function mapRouterOsResultError(
269
+ raw: string,
270
+ opts: MapRouterOsErrorOptions = {},
271
+ ): CentrsError | undefined {
272
+ const trimmed = raw.trim();
273
+ if (trimmed.length === 0) return undefined;
274
+ const matched =
275
+ ROUTEROS_RESULT_CATCH_ALL.test(trimmed) ||
276
+ routerOsErrorRules.some((rule) => rule.resultTest?.test(trimmed));
277
+ return matched ? mapRouterOsError(raw, opts) : undefined;
278
+ }
279
+
214
280
  /**
215
281
  * Maps a raw RouterOS error string to a normalized {@link CentrsError}.
216
282
  *
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import {
23
+ chmod,
23
24
  copyFile,
24
25
  mkdir,
25
26
  open,
@@ -137,10 +138,21 @@ export async function writeWinBoxCdb(
137
138
  let backupPath: string | undefined;
138
139
  const prunedBackups: string[] = [];
139
140
  const targetExists = await pathExists(target);
141
+ // The backup and the replacement both carry the CDB's mode (a new CDB gets
142
+ // 0600): the file holds device credentials, and neither a plain open nor
143
+ // `copyFile` is guaranteed to keep it narrower than the umask default,
144
+ // e.g. 0644 (#382 review).
145
+ const mode = targetExists ? (await stat(target)).mode & 0o777 : 0o600;
140
146
  if (targetExists && !options.skipBackup) {
141
147
  const stamp = backupStamp(options.now ?? new Date());
142
148
  backupPath = join(dir, `${backupPrefix(target)}${stamp}`);
143
149
  await copyFile(target, backupPath);
150
+ try {
151
+ await chmod(backupPath, mode);
152
+ } catch (error) {
153
+ await unlink(backupPath).catch(() => undefined);
154
+ throw error;
155
+ }
144
156
 
145
157
  if (retention >= 0) {
146
158
  const backups = await listWinBoxCdbBackups(target);
@@ -159,15 +171,17 @@ export async function writeWinBoxCdb(
159
171
  dir,
160
172
  `${basename(target)}${TEMP_INFIX}${process.pid}.${Date.now().toString(36)}`,
161
173
  );
162
- const handle = await open(tempPath, "w");
163
- try {
164
- await handle.write(bytes);
165
- await handle.sync();
166
- } finally {
167
- await handle.close();
168
- }
169
-
174
+ // Any failure before the rename removes the temp file: it holds the
175
+ // serialized CDB, credentials included.
170
176
  try {
177
+ const handle = await open(tempPath, "w", mode);
178
+ try {
179
+ await handle.chmod(mode);
180
+ await handle.write(bytes);
181
+ await handle.sync();
182
+ } finally {
183
+ await handle.close();
184
+ }
171
185
  await rename(tempPath, target);
172
186
  } catch (error) {
173
187
  await unlink(tempPath).catch(() => undefined);