@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.
- package/CHANGELOG.md +417 -1
- package/GLOSSARY.txt +40 -1
- package/README.md +16 -8
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +50 -10
- package/commands/api/examples.md +100 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +68 -25
- package/commands/execute/examples.md +168 -7
- package/commands/explain/README.md +1206 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +9 -5
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/examples.md +20 -0
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +3 -3
- package/docs/CONSTITUTION.md +83 -21
- package/docs/MATRIX.md +89 -52
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +346 -75
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/devices.ts +16 -3
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +16 -1
- package/src/core/execute-command.ts +455 -0
- package/src/core/inspect.ts +58 -26
- package/src/core/routeros-errors.ts +66 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +19 -5
- package/src/execute.ts +247 -295
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +21 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +18 -63
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/cli/devices.ts
CHANGED
|
@@ -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:
|
|
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,
|
package/src/cli/explain.ts
CHANGED
|
@@ -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[]` (
|
|
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",
|
package/src/cli/terminal.ts
CHANGED
|
@@ -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,
|
package/src/core/envelope.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|