@namzu/cli 29.1.0 → 32.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +296 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts +20 -2
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +42 -5
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +2 -1
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/exec-json.d.ts.map +1 -1
- package/dist/commands/exec-json.js +24 -1
- package/dist/commands/exec-json.js.map +1 -1
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +5 -1
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/prior-messages.d.ts.map +1 -1
- package/dist/commands/prior-messages.js +42 -2
- package/dist/commands/prior-messages.js.map +1 -1
- package/dist/commands/schedule.d.ts.map +1 -1
- package/dist/commands/schedule.js +7 -5
- package/dist/commands/schedule.js.map +1 -1
- package/dist/config/composer-triggers.d.ts +41 -0
- package/dist/config/composer-triggers.d.ts.map +1 -0
- package/dist/config/composer-triggers.js +80 -0
- package/dist/config/composer-triggers.js.map +1 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +58 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +34 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/context/doctrine.d.ts +3 -3
- package/dist/context/doctrine.d.ts.map +1 -1
- package/dist/context/doctrine.js +3 -3
- package/dist/context/doctrine.js.map +1 -1
- package/dist/context/environment.d.ts +26 -3
- package/dist/context/environment.d.ts.map +1 -1
- package/dist/context/environment.js +68 -9
- package/dist/context/environment.js.map +1 -1
- package/dist/context/runtime-message.d.ts.map +1 -1
- package/dist/context/runtime-message.js +4 -0
- package/dist/context/runtime-message.js.map +1 -1
- package/dist/integrations/mcp/servers.d.ts +72 -5
- package/dist/integrations/mcp/servers.d.ts.map +1 -1
- package/dist/integrations/mcp/servers.js +144 -23
- package/dist/integrations/mcp/servers.js.map +1 -1
- package/dist/integrations/notifications/desktop.d.ts +2 -0
- package/dist/integrations/notifications/desktop.d.ts.map +1 -1
- package/dist/integrations/notifications/desktop.js +6 -5
- package/dist/integrations/notifications/desktop.js.map +1 -1
- package/dist/integrations/plugins/runtime.d.ts +10 -2
- package/dist/integrations/plugins/runtime.d.ts.map +1 -1
- package/dist/integrations/plugins/runtime.js +16 -29
- package/dist/integrations/plugins/runtime.js.map +1 -1
- package/dist/integrations/providers/harness-credentials.d.ts +9 -4
- package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
- package/dist/integrations/providers/harness-credentials.js +16 -8
- package/dist/integrations/providers/harness-credentials.js.map +1 -1
- package/dist/integrations/resident/session-step.d.ts.map +1 -1
- package/dist/integrations/resident/session-step.js +2 -1
- package/dist/integrations/resident/session-step.js.map +1 -1
- package/dist/integrations/sessions/store.d.ts.map +1 -1
- package/dist/integrations/sessions/store.js +20 -2
- package/dist/integrations/sessions/store.js.map +1 -1
- package/dist/integrations/subagents/NamzuCliAgent.d.ts +8 -0
- package/dist/integrations/subagents/NamzuCliAgent.d.ts.map +1 -0
- package/dist/integrations/subagents/NamzuCliAgent.js +9 -0
- package/dist/integrations/subagents/NamzuCliAgent.js.map +1 -0
- package/dist/integrations/subagents/runtime.d.ts +5 -4
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +46 -11
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/integrations/web/open-url.d.ts +24 -0
- package/dist/integrations/web/open-url.d.ts.map +1 -0
- package/dist/integrations/web/open-url.js +73 -0
- package/dist/integrations/web/open-url.js.map +1 -0
- package/dist/integrations/web/search.d.ts.map +1 -1
- package/dist/integrations/web/search.js +8 -6
- package/dist/integrations/web/search.js.map +1 -1
- package/dist/permissions/rules.d.ts +5 -1
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +85 -3
- package/dist/permissions/rules.js.map +1 -1
- package/dist/schedule/build.d.ts +26 -2
- package/dist/schedule/build.d.ts.map +1 -1
- package/dist/schedule/build.js +139 -20
- package/dist/schedule/build.js.map +1 -1
- package/dist/schedule/changes.d.ts +6 -0
- package/dist/schedule/changes.d.ts.map +1 -1
- package/dist/schedule/changes.js +23 -0
- package/dist/schedule/changes.js.map +1 -1
- package/dist/schedule/commands/add.d.ts +1 -1
- package/dist/schedule/commands/add.d.ts.map +1 -1
- package/dist/schedule/commands/add.js +188 -54
- package/dist/schedule/commands/add.js.map +1 -1
- package/dist/schedule/commands/lifecycle.d.ts.map +1 -1
- package/dist/schedule/commands/lifecycle.js +2 -1
- package/dist/schedule/commands/lifecycle.js.map +1 -1
- package/dist/schedule/commands/list.d.ts +2 -0
- package/dist/schedule/commands/list.d.ts.map +1 -1
- package/dist/schedule/commands/list.js +69 -20
- package/dist/schedule/commands/list.js.map +1 -1
- package/dist/schedule/daemon/daemon.d.ts +1 -1
- package/dist/schedule/daemon/daemon.d.ts.map +1 -1
- package/dist/schedule/daemon/daemon.js +22 -5
- package/dist/schedule/daemon/daemon.js.map +1 -1
- package/dist/schedule/daemon/notify.d.ts +29 -2
- package/dist/schedule/daemon/notify.d.ts.map +1 -1
- package/dist/schedule/daemon/notify.js +71 -2
- package/dist/schedule/daemon/notify.js.map +1 -1
- package/dist/schedule/env.d.ts +8 -4
- package/dist/schedule/env.d.ts.map +1 -1
- package/dist/schedule/env.js +13 -6
- package/dist/schedule/env.js.map +1 -1
- package/dist/schedule/fire/calls.d.ts +49 -0
- package/dist/schedule/fire/calls.d.ts.map +1 -0
- package/dist/schedule/fire/calls.js +114 -0
- package/dist/schedule/fire/calls.js.map +1 -0
- package/dist/schedule/fire/fire.d.ts +1 -1
- package/dist/schedule/fire/fire.d.ts.map +1 -1
- package/dist/schedule/fire/fire.js +209 -15
- package/dist/schedule/fire/fire.js.map +1 -1
- package/dist/schedule/fire/result.d.ts +1 -1
- package/dist/schedule/fire/result.d.ts.map +1 -1
- package/dist/schedule/fire/result.js +2 -1
- package/dist/schedule/fire/result.js.map +1 -1
- package/dist/schedule/fire/run-script.d.ts +46 -0
- package/dist/schedule/fire/run-script.d.ts.map +1 -0
- package/dist/schedule/fire/run-script.js +81 -0
- package/dist/schedule/fire/run-script.js.map +1 -0
- package/dist/schedule/fire/unattended-note.d.ts +8 -0
- package/dist/schedule/fire/unattended-note.d.ts.map +1 -1
- package/dist/schedule/fire/unattended-note.js +5 -0
- package/dist/schedule/fire/unattended-note.js.map +1 -1
- package/dist/schedule/fire/wake-gate.d.ts +39 -0
- package/dist/schedule/fire/wake-gate.d.ts.map +1 -0
- package/dist/schedule/fire/wake-gate.js +90 -0
- package/dist/schedule/fire/wake-gate.js.map +1 -0
- package/dist/schedule/floor.d.ts +28 -8
- package/dist/schedule/floor.d.ts.map +1 -1
- package/dist/schedule/floor.js +624 -114
- package/dist/schedule/floor.js.map +1 -1
- package/dist/schedule/policy.d.ts +40 -0
- package/dist/schedule/policy.d.ts.map +1 -1
- package/dist/schedule/policy.js +36 -0
- package/dist/schedule/policy.js.map +1 -1
- package/dist/schedule/script-check.d.ts +53 -0
- package/dist/schedule/script-check.d.ts.map +1 -0
- package/dist/schedule/script-check.js +145 -0
- package/dist/schedule/script-check.js.map +1 -0
- package/dist/schedule/script-shell.d.ts +3 -0
- package/dist/schedule/script-shell.d.ts.map +1 -0
- package/dist/schedule/script-shell.js +5 -0
- package/dist/schedule/script-shell.js.map +1 -0
- package/dist/schedule/service/index.d.ts +1 -1
- package/dist/schedule/service/index.d.ts.map +1 -1
- package/dist/schedule/service/index.js +20 -11
- package/dist/schedule/service/index.js.map +1 -1
- package/dist/schedule/service/manifest.js +1 -1
- package/dist/schedule/service/manifest.js.map +1 -1
- package/dist/schedule/service/windows-task.d.ts +7 -0
- package/dist/schedule/service/windows-task.d.ts.map +1 -1
- package/dist/schedule/service/windows-task.js +65 -0
- package/dist/schedule/service/windows-task.js.map +1 -1
- package/dist/schedule/service/wsl.d.ts +13 -1
- package/dist/schedule/service/wsl.d.ts.map +1 -1
- package/dist/schedule/service/wsl.js +16 -6
- package/dist/schedule/service/wsl.js.map +1 -1
- package/dist/schedule/store/atomic.d.ts +5 -2
- package/dist/schedule/store/atomic.d.ts.map +1 -1
- package/dist/schedule/store/atomic.js +7 -4
- package/dist/schedule/store/atomic.js.map +1 -1
- package/dist/schedule/store/claims.js +1 -1
- package/dist/schedule/store/claims.js.map +1 -1
- package/dist/schedule/store/history.d.ts +1 -1
- package/dist/schedule/store/history.d.ts.map +1 -1
- package/dist/schedule/store/history.js +4 -1
- package/dist/schedule/store/history.js.map +1 -1
- package/dist/schedule/store/jobs.d.ts +19 -3
- package/dist/schedule/store/jobs.d.ts.map +1 -1
- package/dist/schedule/store/jobs.js +83 -16
- package/dist/schedule/store/jobs.js.map +1 -1
- package/dist/schedule/store/state.js +1 -1
- package/dist/schedule/store/state.js.map +1 -1
- package/dist/schedule/types.d.ts +98 -6
- package/dist/schedule/types.d.ts.map +1 -1
- package/dist/schedule/types.js +25 -1
- package/dist/schedule/types.js.map +1 -1
- package/dist/schedule/visible-source.d.ts +18 -0
- package/dist/schedule/visible-source.d.ts.map +1 -0
- package/dist/schedule/visible-source.js +59 -0
- package/dist/schedule/visible-source.js.map +1 -0
- package/dist/skills/directory.d.ts +38 -0
- package/dist/skills/directory.d.ts.map +1 -0
- package/dist/skills/directory.js +51 -0
- package/dist/skills/directory.js.map +1 -0
- package/dist/test-support/toolset.d.ts +23 -0
- package/dist/test-support/toolset.d.ts.map +1 -0
- package/dist/test-support/toolset.js +27 -0
- package/dist/test-support/toolset.js.map +1 -0
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +343 -64
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/ChoicePicker.d.ts +2 -0
- package/dist/tui/ChoicePicker.d.ts.map +1 -1
- package/dist/tui/ChoicePicker.js +10 -2
- package/dist/tui/ChoicePicker.js.map +1 -1
- package/dist/tui/Composer.d.ts +62 -3
- package/dist/tui/Composer.d.ts.map +1 -1
- package/dist/tui/Composer.js +162 -25
- package/dist/tui/Composer.js.map +1 -1
- package/dist/tui/ComposerFrame.d.ts +1 -1
- package/dist/tui/ComposerFrame.js +3 -3
- package/dist/tui/ComposerFrame.js.map +1 -1
- package/dist/tui/EffortSlider.d.ts +8 -9
- package/dist/tui/EffortSlider.d.ts.map +1 -1
- package/dist/tui/EffortSlider.js +15 -12
- package/dist/tui/EffortSlider.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts +13 -1
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +26 -4
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/StatusBar.d.ts +12 -12
- package/dist/tui/StatusBar.d.ts.map +1 -1
- package/dist/tui/StatusBar.js +15 -15
- package/dist/tui/StatusBar.js.map +1 -1
- package/dist/tui/agent.d.ts +73 -12
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +430 -180
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/hypermode.d.ts +37 -0
- package/dist/tui/hypermode.d.ts.map +1 -0
- package/dist/tui/hypermode.js +58 -0
- package/dist/tui/hypermode.js.map +1 -0
- package/dist/tui/open-browser.d.ts +29 -1
- package/dist/tui/open-browser.d.ts.map +1 -1
- package/dist/tui/open-browser.js +83 -11
- package/dist/tui/open-browser.js.map +1 -1
- package/dist/tui/permission-review.d.ts +11 -2
- package/dist/tui/permission-review.d.ts.map +1 -1
- package/dist/tui/permission-review.js +176 -19
- package/dist/tui/permission-review.js.map +1 -1
- package/dist/tui/schedule/host-commands.d.ts.map +1 -1
- package/dist/tui/schedule/host-commands.js +95 -33
- package/dist/tui/schedule/host-commands.js.map +1 -1
- package/dist/tui/schedule/integration.d.ts +6 -2
- package/dist/tui/schedule/integration.d.ts.map +1 -1
- package/dist/tui/schedule/integration.js +3 -1
- package/dist/tui/schedule/integration.js.map +1 -1
- package/dist/tui/schedule/resume.d.ts.map +1 -1
- package/dist/tui/schedule/resume.js +10 -1
- package/dist/tui/schedule/resume.js.map +1 -1
- package/dist/tui/schedule/tool-host.d.ts +18 -1
- package/dist/tui/schedule/tool-host.d.ts.map +1 -1
- package/dist/tui/schedule/tool-host.js +384 -84
- package/dist/tui/schedule/tool-host.js.map +1 -1
- package/dist/tui/slashCommands.d.ts +23 -5
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +80 -31
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/theme.d.ts +10 -4
- package/dist/tui/theme.d.ts.map +1 -1
- package/dist/tui/theme.js +4 -3
- package/dist/tui/theme.js.map +1 -1
- package/dist/tui/triggers/analyze.d.ts +57 -0
- package/dist/tui/triggers/analyze.d.ts.map +1 -0
- package/dist/tui/triggers/analyze.js +221 -0
- package/dist/tui/triggers/analyze.js.map +1 -0
- package/dist/tui/triggers/context-text.d.ts +21 -0
- package/dist/tui/triggers/context-text.d.ts.map +1 -0
- package/dist/tui/triggers/context-text.js +44 -0
- package/dist/tui/triggers/context-text.js.map +1 -0
- package/dist/tui/triggers/copy.d.ts +38 -0
- package/dist/tui/triggers/copy.d.ts.map +1 -0
- package/dist/tui/triggers/copy.js +163 -0
- package/dist/tui/triggers/copy.js.map +1 -0
- package/dist/tui/triggers/detect.d.ts +83 -0
- package/dist/tui/triggers/detect.d.ts.map +1 -0
- package/dist/tui/triggers/detect.js +295 -0
- package/dist/tui/triggers/detect.js.map +1 -0
- package/dist/tui/triggers/fold.d.ts +34 -0
- package/dist/tui/triggers/fold.d.ts.map +1 -0
- package/dist/tui/triggers/fold.js +44 -0
- package/dist/tui/triggers/fold.js.map +1 -0
- package/dist/tui/triggers/pattern.d.ts +66 -0
- package/dist/tui/triggers/pattern.d.ts.map +1 -0
- package/dist/tui/triggers/pattern.js +243 -0
- package/dist/tui/triggers/pattern.js.map +1 -0
- package/dist/tui/triggers/provenance.d.ts +58 -0
- package/dist/tui/triggers/provenance.d.ts.map +1 -0
- package/dist/tui/triggers/provenance.js +98 -0
- package/dist/tui/triggers/provenance.js.map +1 -0
- package/dist/tui/triggers/registry.d.ts +113 -0
- package/dist/tui/triggers/registry.d.ts.map +1 -0
- package/dist/tui/triggers/registry.js +164 -0
- package/dist/tui/triggers/registry.js.map +1 -0
- package/dist/tui/triggers/setting.d.ts +18 -0
- package/dist/tui/triggers/setting.d.ts.map +1 -0
- package/dist/tui/triggers/setting.js +61 -0
- package/dist/tui/triggers/setting.js.map +1 -0
- package/dist/tui/triggers/verbs.d.ts +29 -0
- package/dist/tui/triggers/verbs.d.ts.map +1 -0
- package/dist/tui/triggers/verbs.js +140 -0
- package/dist/tui/triggers/verbs.js.map +1 -0
- package/dist/tui/types.d.ts +3 -1
- package/dist/tui/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/skills/README.md +5 -0
- package/skills/schedule-task/SKILL.md +123 -5
package/dist/schedule/floor.js
CHANGED
|
@@ -51,13 +51,19 @@
|
|
|
51
51
|
* - **What the lexer cannot account for** — an opaque line (a command
|
|
52
52
|
* substitution, `eval`-like constructs, a syntax error, a construct it does
|
|
53
53
|
* not model), a command whose name expands, or a program that runs text as
|
|
54
|
-
* code (`sh` reading its input, `sudo bash -c`, `
|
|
55
|
-
* …) — is denied when the line's text
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
54
|
+
* code (`sh` reading its input, `sudo bash -c`, `powershell`, `python`,
|
|
55
|
+
* `xargs`, `watch` …) — is denied when the line's text holds something that
|
|
56
|
+
* could reach what the floor protects (see {@link Floor.tripwire}): a
|
|
57
|
+
* `schedule` subcommand that changes something with the CLI or an
|
|
58
|
+
* expansion in reach, the scheduler service's name, a service tool with a
|
|
59
|
+
* namzu name, a process killer, `NAMZU_HOME` by name or path, or the
|
|
60
|
+
* Windows browser's profile folder. That is the tripwire, deliberately
|
|
61
|
+
* textual and cheap. The product's name on its own is not in that list: a
|
|
62
|
+
* message box saying "Namzu: scheduled job running" reaches nothing, and a
|
|
63
|
+
* run that showed one was refused for it. Text such a program may run — an
|
|
64
|
+
* argument, a here-string, a here-document's body — is also read as a
|
|
65
|
+
* command line of its own, so an escape only the inner shell undoes does
|
|
66
|
+
* not hide it.
|
|
61
67
|
|
|
62
68
|
* - **The Windows browser's profiles**, on the same words, spellings and
|
|
63
69
|
* directories: a path whose segments run `AppData/Local/namzu` (any user,
|
|
@@ -77,13 +83,22 @@
|
|
|
77
83
|
*
|
|
78
84
|
* Everything here is linear in the input.
|
|
79
85
|
*/
|
|
80
|
-
import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, } from '@namzu/sdk';
|
|
86
|
+
import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, nestedShellCommand, programPositions, } from '@namzu/sdk';
|
|
81
87
|
/** Scheduler verbs a run may use: they read, they change nothing. */
|
|
82
88
|
export const READ_ONLY_VERBS = ['list', 'show', 'status', 'history', 'logs'];
|
|
83
89
|
/** The floor's reason for denying a call, or null: what the rule below decides on. */
|
|
84
90
|
export function scheduledRunFloorVerdict(options) {
|
|
85
91
|
const floor = new Floor(options);
|
|
86
|
-
return (call) => floor.
|
|
92
|
+
return (call) => floor.find(call)?.reason ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** The floor's finding for a call, or null: the reason and what matched. */
|
|
95
|
+
export function scheduledRunFloorFinding(options) {
|
|
96
|
+
const floor = new Floor(options);
|
|
97
|
+
return (call) => floor.find(call);
|
|
98
|
+
}
|
|
99
|
+
/** What a refusal says: the rule that matched, and that another wording will not help. */
|
|
100
|
+
export function floorRefusal(finding) {
|
|
101
|
+
return `the scheduled-run floor refused this call: ${finding.detail}. It holds for every scheduled run, so rewording the call will not help`;
|
|
87
102
|
}
|
|
88
103
|
/**
|
|
89
104
|
* Rules this module made, which decide `deny` or nothing: a rule here never
|
|
@@ -100,7 +115,13 @@ export function scheduledRunFloorRule(options) {
|
|
|
100
115
|
const rule = {
|
|
101
116
|
type: 'predicate',
|
|
102
117
|
description: `the scheduled-run floor: a scheduled run may not stop, disable or remove the scheduler, run a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}, or name NAMZU_HOME (${options.namzuHome}) or the Windows browser's profile folder (%LOCALAPPDATA%\\namzu) in any argument. It holds for every scheduled run, so rewording the call will not help`,
|
|
103
|
-
decide: (call) => (floor.
|
|
118
|
+
decide: (call) => (floor.find(call) === null ? null : 'deny'),
|
|
119
|
+
// Asked only after `decide` denied, so the reading is done twice for a
|
|
120
|
+
// refused call and once for every other.
|
|
121
|
+
describe: (call) => {
|
|
122
|
+
const finding = floor.find(call);
|
|
123
|
+
return finding ? floorRefusal(finding) : null;
|
|
124
|
+
},
|
|
104
125
|
};
|
|
105
126
|
DENY_ONLY.add(rule);
|
|
106
127
|
return rule;
|
|
@@ -164,19 +185,19 @@ const STRING_OPTIONS = {
|
|
|
164
185
|
sudo: /^-(?:[a-z]*[si][a-z]*|-shell|-login)$/,
|
|
165
186
|
env: /^-(?:[a-z]*s[a-z]*|-split-string(?:=.*)?)$/i,
|
|
166
187
|
};
|
|
167
|
-
/**
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
/** PowerShell's two names, wherever the floor treats them as an unread shell. */
|
|
189
|
+
const POWERSHELL_NAMES = new Set(['powershell', 'pwsh']);
|
|
190
|
+
/**
|
|
191
|
+
* `-EncodedCommand` and every unambiguous prefix PowerShell itself accepts
|
|
192
|
+
* (`-e`, `-en`, `-enc`, …, up to the full name), case-insensitive. Unlike
|
|
193
|
+
* `-Command`/`-c`, whose literal text the tripwire can still read, the
|
|
194
|
+
* argument here is base64 of UTF-16LE: nothing in it can be read as text, so
|
|
195
|
+
* a line the tripwire finds nothing in front of proves nothing about what it
|
|
196
|
+
* runs.
|
|
197
|
+
*/
|
|
198
|
+
const ENCODED_COMMAND_FLAG = /^-e(?:n(?:c(?:o(?:d(?:e(?:d(?:c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?)?)?)?)?)?)?)?$/i;
|
|
199
|
+
/** What a refusal says about a `schedule` verb that is not read-only. */
|
|
200
|
+
const NOT_READ_ONLY = `a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}`;
|
|
180
201
|
const NAME_CHAR = /[a-z0-9._-]/;
|
|
181
202
|
function lower(text) {
|
|
182
203
|
return text.toLowerCase();
|
|
@@ -187,6 +208,12 @@ function commandName(value) {
|
|
|
187
208
|
const base = lower(cut < 0 ? value : value.slice(cut + 1));
|
|
188
209
|
return base.endsWith('.exe') ? base.slice(0, -4) : base;
|
|
189
210
|
}
|
|
211
|
+
/** Text as a refusal quotes it: on one line, cut to `max` characters, in backticks. */
|
|
212
|
+
function shown(text, max = 80) {
|
|
213
|
+
const flat = text.replace(/\s*\n\s*/g, ' ⏎ ');
|
|
214
|
+
const cut = [...flat].length > max ? `${[...flat].slice(0, max - 1).join('')}…` : flat;
|
|
215
|
+
return cut.includes('`') ? `\`\` ${cut} \`\`` : `\`${cut}\``;
|
|
216
|
+
}
|
|
190
217
|
/**
|
|
191
218
|
* `$"…"` read as its text. The lexer leaves locale quoting as written and
|
|
192
219
|
* flags the word, because a translation catalogue could change it; the text
|
|
@@ -214,68 +241,106 @@ function wordsOf(command, catalogue) {
|
|
|
214
241
|
// ---------------------------------------------------------------------------
|
|
215
242
|
// Scheduler commands
|
|
216
243
|
/**
|
|
217
|
-
* Whether one command's words, in order, reach the scheduler
|
|
218
|
-
* taken at its first place after the one
|
|
219
|
-
* there such a subsequence" that loses
|
|
244
|
+
* Whether one command's words, in order, reach the scheduler, and if so what
|
|
245
|
+
* they do, in words. Each role is taken at its first place after the one
|
|
246
|
+
* before; for a question of "is there such a subsequence" that loses
|
|
247
|
+
* nothing, and it is one pass.
|
|
220
248
|
*/
|
|
221
|
-
function reachesScheduler(words, daemonCommandLine) {
|
|
249
|
+
function reachesScheduler(command, words, daemonCommandLine, dialect) {
|
|
222
250
|
const n = words.length;
|
|
223
251
|
const find = (from, test) => {
|
|
224
252
|
for (let i = from; i < n; i++)
|
|
225
|
-
if (test(words[i]))
|
|
253
|
+
if (test(words[i], i))
|
|
226
254
|
return i;
|
|
227
255
|
return -1;
|
|
228
256
|
};
|
|
229
|
-
|
|
257
|
+
// Where a program name could stand: `programPositions` — the SDK's one
|
|
258
|
+
// answer to "where does this command actually exec a program" — unwraps
|
|
259
|
+
// a chain of re-exec wrappers (`sudo`, `env`, `nice`, `timeout`, …) with
|
|
260
|
+
// each one's own real option grammar, rather than assuming the program
|
|
261
|
+
// sits right after the wrapper's name (`timeout 5 prog`, `stdbuf -oL
|
|
262
|
+
// prog` and `ionice -c2 -n7 prog` all take at least one word of their
|
|
263
|
+
// own first). `words`/`command.words` share indices, so a resolved
|
|
264
|
+
// position's word maps straight back to one. A position `programPositions`
|
|
265
|
+
// could not resolve because that word ITSELF expands (`sudo
|
|
266
|
+
// $(echo systemctl) stop x`) still carries the word, so it becomes a
|
|
267
|
+
// head here too — same as before, just correctly placed after however
|
|
268
|
+
// many wrapper words came first. A literal `source`/`.` with a literal
|
|
269
|
+
// path, or a literal `eval` whose literal payload lexes cleanly, is read
|
|
270
|
+
// the same way and adds its own resolved position(s) here too. A
|
|
271
|
+
// position it could not resolve for a structural reason with no wild
|
|
272
|
+
// word of its own (an option a wrapper does not recognise, too many
|
|
273
|
+
// wrappers, an opaque `eval` payload) carries no word and adds nothing
|
|
274
|
+
// here; that gap is not this fix's scope — an interpreter or unread
|
|
275
|
+
// construct is the tripwire's and `runsUnreadText`'s job, not this one's.
|
|
276
|
+
const heads = new Set();
|
|
277
|
+
for (const position of programPositions(command, dialect)) {
|
|
278
|
+
if (position.word === undefined)
|
|
279
|
+
continue;
|
|
280
|
+
const idx = command.words.indexOf(position.word);
|
|
281
|
+
if (idx >= 0)
|
|
282
|
+
heads.add(idx);
|
|
283
|
+
}
|
|
284
|
+
// A wild (expanding) word in one of those positions cannot be read as
|
|
285
|
+
// NOT being the tool in question — a substring check on its raw,
|
|
286
|
+
// unevaluated text (`$(echo pk)ill`) is defeated by gluing a literal
|
|
287
|
+
// suffix onto the substitution, which never contains the tool's name as
|
|
288
|
+
// one contiguous run. Unknown never passes: such a word counts as being
|
|
289
|
+
// every name checked here, the same way `namesNamzu`/a verb check
|
|
290
|
+
// already treats a wild argument. A wild word elsewhere (an argument, a
|
|
291
|
+
// path — `find ~/.namzu -exec …`'s `~/.namzu`) is not a program name and
|
|
292
|
+
// is read as what it actually is, an argument, by the checks below.
|
|
293
|
+
const named = (name) => (w, i) => (w.wild && heads.has(i)) || commandName(w.text) === name;
|
|
230
294
|
const namesNamzu = (w) => w.wild || w.text.includes('namzu');
|
|
231
295
|
// systemctl <verb> <unit>
|
|
232
296
|
const systemctl = find(0, named('systemctl'));
|
|
233
297
|
if (systemctl >= 0) {
|
|
234
298
|
if (find(systemctl + 1, (w) => !w.wild && SYSTEMCTL_VERBS_ALONE.has(w.text)) >= 0)
|
|
235
|
-
return
|
|
299
|
+
return 'stops every service, the scheduler with them';
|
|
236
300
|
const verb = find(systemctl + 1, (w) => w.wild || SYSTEMCTL_VERBS_WITH_UNIT.has(w.text));
|
|
237
301
|
// systemctl matches unit names against a glob.
|
|
238
302
|
if (verb >= 0 && find(verb + 1, (u) => namesNamzu(u) || /[*?[]/.test(u.text)) >= 0)
|
|
239
|
-
return
|
|
303
|
+
return "stops or disables the scheduler's service";
|
|
240
304
|
}
|
|
241
305
|
// launchctl <verb> <label or plist>
|
|
242
306
|
const launchctl = find(0, named('launchctl'));
|
|
243
307
|
if (launchctl >= 0) {
|
|
244
308
|
const verb = find(launchctl + 1, (w) => w.wild || LAUNCHCTL_VERBS.has(w.text));
|
|
245
309
|
if (verb >= 0 && find(verb + 1, namesNamzu) >= 0)
|
|
246
|
-
return
|
|
310
|
+
return "removes the scheduler's launchd agent";
|
|
247
311
|
}
|
|
248
312
|
// schtasks, whose switches come in any order.
|
|
249
313
|
const schtasks = find(0, named('schtasks'));
|
|
250
314
|
if (schtasks >= 0) {
|
|
251
315
|
const rest = words.slice(schtasks + 1);
|
|
252
316
|
if (rest.some((w) => w.wild || SCHTASKS_SWITCH.test(w.text)) && rest.some(namesNamzu))
|
|
253
|
-
return
|
|
317
|
+
return "deletes, changes or ends the scheduler's task";
|
|
254
318
|
}
|
|
255
319
|
// pkill / killall: a pattern that could match the daemon's process.
|
|
256
|
-
const killer = find(0, (w) => named('pkill')(w) || named('killall')(w));
|
|
320
|
+
const killer = find(0, (w, i) => named('pkill')(w, i) || named('killall')(w, i));
|
|
257
321
|
if (killer >= 0) {
|
|
322
|
+
const kills = "has a pattern that could match the scheduler's process";
|
|
258
323
|
const full = words
|
|
259
324
|
.slice(killer + 1)
|
|
260
325
|
.some((w) => /^-[a-z]*f/.test(w.text) || w.text === '--full');
|
|
261
326
|
for (let i = killer + 1; i < n; i++) {
|
|
262
327
|
const w = words[i];
|
|
263
328
|
if (w.wild)
|
|
264
|
-
return
|
|
329
|
+
return kills;
|
|
265
330
|
if (w.text.startsWith('-'))
|
|
266
331
|
continue;
|
|
267
332
|
if (/[.*+?^$|()[\]{}\\]/.test(w.text))
|
|
268
|
-
return
|
|
333
|
+
return kills;
|
|
269
334
|
if (w.text.includes('namzu') || w.text.includes('schedul'))
|
|
270
|
-
return
|
|
335
|
+
return kills;
|
|
271
336
|
if ('node'.includes(w.text) || (full && daemonCommandLine.includes(w.text)))
|
|
272
|
-
return
|
|
337
|
+
return kills;
|
|
273
338
|
}
|
|
274
339
|
}
|
|
275
340
|
// A D-Bus call to systemd naming the unit.
|
|
276
|
-
const dbus = find(0, (w) => DBUS_TOOLS.has(commandName(w.text)));
|
|
341
|
+
const dbus = find(0, (w, i) => (w.wild && heads.has(i)) || DBUS_TOOLS.has(commandName(w.text)));
|
|
277
342
|
if (dbus >= 0 && find(dbus + 1, namesNamzu) >= 0)
|
|
278
|
-
return
|
|
343
|
+
return 'calls systemd about a namzu unit';
|
|
279
344
|
// The CLI, by name (`namzu`, a path to it, `npx @namzu/cli`) or through
|
|
280
345
|
// its entry script or a JavaScript runtime: `schedule` and then anything
|
|
281
346
|
// but a read-only verb.
|
|
@@ -283,7 +348,7 @@ function reachesScheduler(words, daemonCommandLine) {
|
|
|
283
348
|
const weak = find(0, (w) => CLI_ENTRY.test(commandName(w.text)) || JS_RUNTIMES.has(commandName(w.text)));
|
|
284
349
|
// After the CLI's own name, a word that expands may be `schedule stop`.
|
|
285
350
|
if (strong >= 0 && find(strong + 1, (w) => w.wild) >= 0)
|
|
286
|
-
return
|
|
351
|
+
return 'passes the CLI a word that expands at runtime, which could be a `schedule` subcommand';
|
|
287
352
|
const cli = strong < 0 ? weak : weak < 0 ? strong : Math.min(strong, weak);
|
|
288
353
|
if (cli >= 0) {
|
|
289
354
|
for (let i = cli + 1; i < n; i++) {
|
|
@@ -292,40 +357,76 @@ function reachesScheduler(words, daemonCommandLine) {
|
|
|
292
357
|
const verb = words[i + 1];
|
|
293
358
|
if (verb === undefined)
|
|
294
359
|
continue;
|
|
295
|
-
if (verb.wild
|
|
296
|
-
return
|
|
360
|
+
if (verb.wild)
|
|
361
|
+
return 'runs a `schedule` subcommand that expands at runtime';
|
|
362
|
+
if (!READ_ONLY_VERBS.includes(verb.text))
|
|
363
|
+
return `runs \`schedule ${verb.text}\`, ${NOT_READ_ONLY}`;
|
|
297
364
|
}
|
|
298
365
|
}
|
|
299
|
-
return
|
|
366
|
+
return null;
|
|
300
367
|
}
|
|
301
368
|
/**
|
|
302
|
-
* Whether a command runs text the lexer did not read as commands
|
|
303
|
-
* the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S
|
|
369
|
+
* Whether a command runs text the lexer did not read as commands — a shell
|
|
370
|
+
* the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S` —
|
|
371
|
+
* and if so which, in words.
|
|
304
372
|
*/
|
|
305
373
|
function runsUnreadText(command) {
|
|
306
374
|
const words = command.words;
|
|
307
375
|
const head = command.assignments;
|
|
308
376
|
const names = words.map((w) => (w.expands ? '' : commandName(w.value)));
|
|
309
|
-
// The
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
const
|
|
377
|
+
// The shell at the head whose `-c` payload the lexer read, and `busybox`
|
|
378
|
+
// in front of it: exactly the lexer's own decision. Any other shell —
|
|
379
|
+
// `powershell -c`, `fish -c`, `bash.exe -c`, one given a script — runs
|
|
380
|
+
// text the lexer never read, whatever its options look like.
|
|
381
|
+
const nested = nestedShellCommand(words.slice(head));
|
|
382
|
+
const readTo = nested !== null && 'payload' in nested
|
|
383
|
+
? head + (commandName(words[head].value) === 'busybox' ? 1 : 0)
|
|
384
|
+
: -1;
|
|
314
385
|
for (let i = 0; i < words.length; i++) {
|
|
315
386
|
const name = names[i];
|
|
316
387
|
if (name === '')
|
|
317
388
|
continue;
|
|
389
|
+
const program = shown(words[i].value);
|
|
318
390
|
if (INTERPRETERS.test(name))
|
|
319
|
-
return
|
|
391
|
+
return `${program} runs text as code`;
|
|
320
392
|
if (i === head && name === '.')
|
|
321
|
-
return
|
|
322
|
-
if (
|
|
323
|
-
|
|
393
|
+
return '`.` runs a file as commands';
|
|
394
|
+
if (i === head && name === 'trap') {
|
|
395
|
+
// `trap [-lp] [action] [sigspec...]`: the action, when given, runs as
|
|
396
|
+
// a command line when the signal fires — read the same way a nested
|
|
397
|
+
// shell's `-c` payload is, just later. `-l`/`-p`/`--` alone print or
|
|
398
|
+
// reset and run nothing.
|
|
399
|
+
const action = words
|
|
400
|
+
.slice(i + 1)
|
|
401
|
+
.find((w) => w.expands || (w.value !== '-l' && w.value !== '-p' && w.value !== '--'));
|
|
402
|
+
if (action !== undefined)
|
|
403
|
+
return `${program} runs its action as a command when the signal fires`;
|
|
404
|
+
}
|
|
405
|
+
if (SHELLS.has(name) && !(i >= head && i <= readTo))
|
|
406
|
+
return `${program} runs commands the floor does not read`;
|
|
324
407
|
}
|
|
325
|
-
for (const [program, option] of Object.entries(STRING_OPTIONS))
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
408
|
+
for (const [program, option] of Object.entries(STRING_OPTIONS)) {
|
|
409
|
+
const flag = words.find((w) => !w.expands && option.test(w.value));
|
|
410
|
+
if (names.includes(program) && flag !== undefined)
|
|
411
|
+
return `\`${program} ${flag.value}\` runs a string as commands`;
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* `powershell`/`pwsh`, anywhere in the command, given `-EncodedCommand` (or
|
|
417
|
+
* an unambiguous prefix of it): the program word and the flag, or null.
|
|
418
|
+
*/
|
|
419
|
+
function encodedCommandCall(command) {
|
|
420
|
+
const words = command.words;
|
|
421
|
+
for (let i = command.assignments; i < words.length; i++) {
|
|
422
|
+
const word = words[i];
|
|
423
|
+
if (word.expands || !POWERSHELL_NAMES.has(commandName(word.value)))
|
|
424
|
+
continue;
|
|
425
|
+
const flag = words.slice(i + 1).find((w) => !w.expands && ENCODED_COMMAND_FLAG.test(w.value));
|
|
426
|
+
if (flag !== undefined)
|
|
427
|
+
return { program: word, flag };
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
329
430
|
}
|
|
330
431
|
// ---------------------------------------------------------------------------
|
|
331
432
|
// Paths
|
|
@@ -459,24 +560,77 @@ function endsShortOfProfile(path) {
|
|
|
459
560
|
last !== 'namzu' &&
|
|
460
561
|
'namzu'.startsWith(last));
|
|
461
562
|
}
|
|
563
|
+
/** `schedule` as a command word: not a path segment, not a longer word (`scheduled`). */
|
|
564
|
+
const SCHEDULE_WORD = /(?:^|[\s,;([{|&=])schedule(?=$|[\s,;)\]}|&])/g;
|
|
565
|
+
/** The CLI, the runtimes it can be started through, or its entry script, as a word. */
|
|
566
|
+
const CLI_IN_TEXT = /namzu|bin\.[cm]?js|(?:^|[^a-z0-9_-])(?:node|nodejs|npx|pnpx|bunx|bun|deno|tsx|ts-node|npm|pnpm|yarn)(?![a-z0-9_-])/;
|
|
567
|
+
/** An expansion that could hold the CLI's name or path: `$X`, a backtick, `%X%`. */
|
|
568
|
+
const EXPANSION_IN_TEXT = /[$`]|%[a-z_][a-z0-9_]*%/;
|
|
569
|
+
/** The scheduler service's names: the unit and task (`namzu-scheduler…`), the launchd label. */
|
|
570
|
+
const SERVICE_NAME_IN_TEXT = /namzu-scheduler[a-z0-9._-]*|com\.namzu\.[a-z0-9._-]*/;
|
|
571
|
+
/** Tools that stop, change or remove a service or a scheduled task. */
|
|
572
|
+
const SERVICE_TOOL_IN_TEXT = /(?:^|[^a-z0-9_-])(systemctl|launchctl|schtasks|busctl|dbus-send|gdbus|(?:stop|disable|unregister|set)-scheduledtask|schedule\.service)(?![a-z0-9_-])/;
|
|
573
|
+
/** `pkill` and `killall` match a process by pattern, and the scheduler is a `node` process. */
|
|
574
|
+
const PROCESS_KILLER_IN_TEXT = /(?:^|[^a-z0-9_-])(pkill|killall)(?![a-z0-9_-])/;
|
|
575
|
+
/** `NAMZU_HOME` by name, in any shell's or language's spelling of a variable. */
|
|
576
|
+
const HOME_VARIABLE_IN_TEXT = /(?:^|[^a-z0-9_])namzu_home(?![a-z0-9_])/;
|
|
577
|
+
/** The Windows profile root's parent, spelled as a variable or a known-folder name. */
|
|
578
|
+
const LOCAL_APP_DATA_IN_TEXT = /localappdata|localapplicationdata|(?:^|[^a-z0-9])appdata(?![a-z0-9])/;
|
|
579
|
+
/** `namzu` as a path segment or a string of its own. */
|
|
580
|
+
const NAMZU_SEGMENT = /(?:^|[^a-z0-9_.-])namzu(?![a-z0-9_.-])/;
|
|
581
|
+
function escapeRegExp(text) {
|
|
582
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
583
|
+
}
|
|
584
|
+
/** A `schedule` subcommand that is not read-only, in one text, or null. */
|
|
585
|
+
function scheduleSubcommand(text) {
|
|
586
|
+
for (const match of text.matchAll(SCHEDULE_WORD)) {
|
|
587
|
+
const after = text.slice((match.index ?? 0) + match[0].length);
|
|
588
|
+
const verb = /^[\s,]*([a-z0-9][a-z0-9_-]*)/.exec(after)?.[1];
|
|
589
|
+
if (verb !== undefined && READ_ONLY_VERBS.includes(verb))
|
|
590
|
+
continue;
|
|
591
|
+
return verb === undefined ? 'schedule' : `schedule ${verb}`;
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
462
595
|
class Floor {
|
|
463
596
|
home;
|
|
597
|
+
/** NAMZU_HOME as the operator wrote it, for a refusal. */
|
|
598
|
+
homeShown;
|
|
464
599
|
homeCore;
|
|
600
|
+
/** NAMZU_HOME's last segment, and the default's (`.namzu`), as path segments in text. */
|
|
601
|
+
homeSegments;
|
|
465
602
|
userHome;
|
|
466
603
|
folders;
|
|
467
604
|
daemonCommandLine;
|
|
468
605
|
builtinCommands;
|
|
469
606
|
constructor(options) {
|
|
470
607
|
this.home = normalize(lower(options.namzuHome));
|
|
608
|
+
this.homeShown = options.namzuHome;
|
|
471
609
|
this.userHome = normalize(lower(options.userHome));
|
|
472
610
|
this.folders = (options.folders ?? []).map((f) => normalize(lower(f)));
|
|
473
611
|
const last = this.home.split('/').at(-1) ?? '';
|
|
474
612
|
this.homeCore = last.replace(/^\.+/, '');
|
|
613
|
+
// A dotted name (`.namzu`) is a path segment wherever it stands; an
|
|
614
|
+
// undotted one only after a slash, or the product's name would be one.
|
|
615
|
+
this.homeSegments = [...new Set(['.namzu', last])]
|
|
616
|
+
.filter((name) => name.replace(/^\.+/, '').length >= 3)
|
|
617
|
+
.map((name) => ({
|
|
618
|
+
name,
|
|
619
|
+
pattern: name.startsWith('.')
|
|
620
|
+
? new RegExp(`(?:^|[^a-z0-9_.-])${escapeRegExp(name)}(?![a-z0-9_.-])`)
|
|
621
|
+
: new RegExp(`[/\\\\]${escapeRegExp(name)}(?![a-z0-9_.-])`),
|
|
622
|
+
}));
|
|
475
623
|
this.builtinCommands = builtinCommandArguments();
|
|
476
624
|
this.daemonCommandLine = lower(options.daemonCommandLine ?? `${process.execPath} ${process.argv[1] ?? ''} schedule daemon`);
|
|
477
625
|
}
|
|
478
|
-
/**
|
|
479
|
-
|
|
626
|
+
/** What the floor protects, as a refusal names it. */
|
|
627
|
+
protectedWords(what) {
|
|
628
|
+
return what === 'NAMZU_HOME'
|
|
629
|
+
? `NAMZU_HOME (${this.homeShown})`
|
|
630
|
+
: "the Windows browser's profile folder (%LOCALAPPDATA%\\namzu)";
|
|
631
|
+
}
|
|
632
|
+
/** Why the floor denies the call and what matched, or null when it does not. */
|
|
633
|
+
find(call) {
|
|
480
634
|
const argument = call.toolDef
|
|
481
635
|
? commandArgumentOf(call.toolDef)
|
|
482
636
|
: this.builtinCommands.get(call.toolName);
|
|
@@ -490,40 +644,55 @@ class Floor {
|
|
|
490
644
|
try {
|
|
491
645
|
const named = this.stringsName(input, line);
|
|
492
646
|
if (named !== null)
|
|
493
|
-
return
|
|
647
|
+
return {
|
|
648
|
+
reason: `names ${named.what}`,
|
|
649
|
+
detail: `${named.where} names ${this.protectedWords(named.what)}`,
|
|
650
|
+
};
|
|
494
651
|
if (line !== undefined)
|
|
495
652
|
return this.lineVerdict(line, call.commandDialect);
|
|
496
653
|
return null;
|
|
497
654
|
}
|
|
498
655
|
catch (error) {
|
|
499
656
|
if (error instanceof TooMany)
|
|
500
|
-
return
|
|
657
|
+
return {
|
|
658
|
+
reason: 'too many spellings',
|
|
659
|
+
detail: `a word in the command can be spelled more than ${MAX_ALTERNATIVES} ways once its variables are filled in, and the floor refuses rather than try them all`,
|
|
660
|
+
};
|
|
501
661
|
throw error;
|
|
502
662
|
}
|
|
503
663
|
}
|
|
504
664
|
// ---- any tool -----------------------------------------------------------
|
|
505
665
|
/** Every string in the input but the command line, searched as text. */
|
|
506
666
|
stringsName(input, line) {
|
|
507
|
-
const stack = [
|
|
667
|
+
const stack = [
|
|
668
|
+
{ value: input, at: '' },
|
|
669
|
+
];
|
|
508
670
|
let skipped = false;
|
|
509
671
|
while (stack.length > 0) {
|
|
510
|
-
const value = stack.pop();
|
|
672
|
+
const { value, at, key } = stack.pop();
|
|
511
673
|
if (typeof value === 'string') {
|
|
512
674
|
if (!skipped && value === line) {
|
|
513
675
|
skipped = true;
|
|
514
676
|
continue;
|
|
515
677
|
}
|
|
678
|
+
const where = key
|
|
679
|
+
? `the argument name ${shown(value)}`
|
|
680
|
+
: at === ''
|
|
681
|
+
? 'the input'
|
|
682
|
+
: `the \`${at}\` argument`;
|
|
516
683
|
if (this.textNamesHome(value))
|
|
517
|
-
return 'NAMZU_HOME';
|
|
684
|
+
return { what: 'NAMZU_HOME', where };
|
|
518
685
|
if (textNamesProfile(value))
|
|
519
|
-
return 'the browser profiles';
|
|
686
|
+
return { what: 'the browser profiles', where };
|
|
520
687
|
}
|
|
521
688
|
else if (Array.isArray(value)) {
|
|
522
|
-
stack.push(
|
|
689
|
+
value.forEach((each, i) => stack.push({ value: each, at: `${at}[${i}]` }));
|
|
523
690
|
}
|
|
524
691
|
else if (value !== null && typeof value === 'object') {
|
|
525
|
-
for (const [
|
|
526
|
-
|
|
692
|
+
for (const [name, each] of Object.entries(value)) {
|
|
693
|
+
const path = at === '' ? name : `${at}.${name}`;
|
|
694
|
+
stack.push({ value: name, at: path, key: true }, { value: each, at: path });
|
|
695
|
+
}
|
|
527
696
|
}
|
|
528
697
|
}
|
|
529
698
|
return null;
|
|
@@ -554,59 +723,164 @@ class Floor {
|
|
|
554
723
|
lineVerdict(line, dialect, depth = 0) {
|
|
555
724
|
// A line for a shell that may be bash or a POSIX shell is read both
|
|
556
725
|
// ways, and denied if either reading denies it.
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
: [
|
|
560
|
-
lexShellCommandLine(line, { dialect: 'sh' }),
|
|
561
|
-
lexShellCommandLine(line, { dialect: 'bash' }),
|
|
562
|
-
];
|
|
726
|
+
const dialects = dialect === 'bash' ? ['bash'] : ['sh', 'bash'];
|
|
727
|
+
const readings = dialects.map((d) => lexShellCommandLine(line, { dialect: d }));
|
|
563
728
|
const catalogue = /textdomain/i.test(line);
|
|
564
729
|
const loops = /\b(?:while|until|for|select)\b/.test(line);
|
|
565
730
|
let unaccounted = null;
|
|
566
|
-
for (
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
731
|
+
for (let i = 0; i < readings.length; i += 1) {
|
|
732
|
+
const reading = readings[i];
|
|
733
|
+
const finding = this.readingVerdict(reading, catalogue, loops, dialects[i]);
|
|
734
|
+
if (finding !== null)
|
|
735
|
+
return finding;
|
|
736
|
+
if (reading.opaque) {
|
|
737
|
+
unaccounted ??= {
|
|
738
|
+
reason: 'opaque line mentions a protected name',
|
|
739
|
+
why: `the floor cannot read the line (${reading.reasons[0] ?? 'a construct it does not model'})`,
|
|
740
|
+
};
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
for (const command of reading.commands) {
|
|
744
|
+
const unread = unreadCommand(command);
|
|
745
|
+
if (unread === null)
|
|
746
|
+
continue;
|
|
747
|
+
unaccounted ??= { reason: 'unread text mentions a protected name', why: unread };
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
574
750
|
}
|
|
575
751
|
if (unaccounted === null)
|
|
576
752
|
return null;
|
|
577
|
-
|
|
578
|
-
|
|
753
|
+
const trip = this.tripwire(line, readings);
|
|
754
|
+
if (trip !== null)
|
|
755
|
+
return {
|
|
756
|
+
reason: unaccounted.reason,
|
|
757
|
+
detail: `${unaccounted.why}, and it holds ${shown(trip.token)} (${trip.what})${whereIn(readings, trip.pattern ?? trip.token)}`,
|
|
758
|
+
};
|
|
579
759
|
// Text a program may run as a command line — an argument (`echo '…' |
|
|
580
760
|
// sh`, `sudo bash -c '…'`), a here-string, a here-document's body —
|
|
581
761
|
// is read as one, decoded, so what the tripwire looks for cannot hide
|
|
582
762
|
// behind escapes that only the inner shell undoes.
|
|
583
763
|
if (depth < MAX_TEXT_DEPTH)
|
|
584
764
|
for (const text of fedTexts(readings)) {
|
|
585
|
-
|
|
586
|
-
|
|
765
|
+
const inner = this.lineVerdict(text, 'bash', depth + 1);
|
|
766
|
+
if (inner !== null)
|
|
767
|
+
return {
|
|
768
|
+
reason: unaccounted.reason,
|
|
769
|
+
detail: `${unaccounted.why}, and read as a command line, ${shown(text)}: ${inner.detail}`,
|
|
770
|
+
};
|
|
587
771
|
}
|
|
588
772
|
return null;
|
|
589
773
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
774
|
+
/**
|
|
775
|
+
* The tripwire, for a line the lexer could not account for: something in
|
|
776
|
+
* its text that can reach what the floor protects, or null. Read in the
|
|
777
|
+
* line as written, with quotes and expansion marks dropped (and once more
|
|
778
|
+
* with backslashes dropped too), and in every word the lexer decoded:
|
|
779
|
+
*
|
|
780
|
+
* - `NAMZU_HOME` by name (`$NAMZU_HOME`, `%NAMZU_HOME%`,
|
|
781
|
+
* `os.environ['NAMZU_HOME']`), its last segment as a path segment
|
|
782
|
+
* (`.namzu`, always), or a path into it;
|
|
783
|
+
* - the Windows browser's profile folder, as a path, or `LOCALAPPDATA` (or
|
|
784
|
+
* `AppData`) with `namzu` as a segment or a string of its own;
|
|
785
|
+
* - the scheduler service's name (`namzu-scheduler…`, `com.namzu.…`);
|
|
786
|
+
* - a service tool (`systemctl`, `launchctl`, `schtasks`, the D-Bus tools,
|
|
787
|
+
* PowerShell's `*-ScheduledTask`) with `namzu` or a glob in the text,
|
|
788
|
+
* `systemctl isolate` or `exit`; `pkill` or `killall` anywhere, because
|
|
789
|
+
* a pattern can match the scheduler's `node` process without naming it;
|
|
790
|
+
* - `schedule` as a command word followed by anything but a read-only
|
|
791
|
+
* verb, when the text also names the CLI (`namzu`, `@namzu/cli`,
|
|
792
|
+
* `bin.js`, a JavaScript runtime) or holds an expansion that could.
|
|
793
|
+
*
|
|
794
|
+
* Nothing else: the product's name in a string (`MessageBox::Show('Namzu:
|
|
795
|
+
* scheduled job running')`) reaches none of them.
|
|
796
|
+
*/
|
|
797
|
+
tripwire(line, readings) {
|
|
798
|
+
const plain = lower(line).replace(/\\\n/g, '');
|
|
799
|
+
const unquoted = plain.replace(/["'$`]/g, '');
|
|
800
|
+
const squashed = unquoted.replace(/\\/g, '');
|
|
595
801
|
const decoded = readings
|
|
596
802
|
.flatMap((r) => r.commands.flatMap((c) => c.words.map((w) => lower(w.value))))
|
|
597
803
|
.join(' ');
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
804
|
+
const texts = [squashed, unquoted, decoded];
|
|
805
|
+
const first = (pattern) => {
|
|
806
|
+
for (const text of texts) {
|
|
807
|
+
const match = pattern.exec(text);
|
|
808
|
+
if (match)
|
|
809
|
+
return match[1] ?? match[0].replace(/^[^a-z0-9%.]+/, '');
|
|
810
|
+
}
|
|
811
|
+
return null;
|
|
812
|
+
};
|
|
813
|
+
// NAMZU_HOME.
|
|
814
|
+
if (HOME_VARIABLE_IN_TEXT.test(plain) || first(HOME_VARIABLE_IN_TEXT) !== null)
|
|
815
|
+
return { token: 'NAMZU_HOME', what: 'the NAMZU_HOME variable' };
|
|
816
|
+
for (const { name, pattern } of this.homeSegments)
|
|
817
|
+
if (texts.some((text) => pattern.test(text)))
|
|
818
|
+
return { token: name, what: "NAMZU_HOME's folder name", pattern };
|
|
819
|
+
if (this.textNamesHome(line) || this.textNamesHome(decoded))
|
|
820
|
+
return { token: this.homeShown, what: 'a path into NAMZU_HOME' };
|
|
821
|
+
// The Windows browser's profiles.
|
|
822
|
+
if (textNamesProfile(line) || textNamesProfile(decoded))
|
|
823
|
+
return {
|
|
824
|
+
token: '%LOCALAPPDATA%\\namzu',
|
|
825
|
+
what: "a path into the Windows browser's profile folder",
|
|
826
|
+
};
|
|
827
|
+
const appData = first(LOCAL_APP_DATA_IN_TEXT);
|
|
828
|
+
if (appData !== null && texts.some((text) => NAMZU_SEGMENT.test(text)))
|
|
829
|
+
return {
|
|
830
|
+
token: `${appData} … namzu`,
|
|
831
|
+
what: "together, the Windows browser's profile folder",
|
|
832
|
+
};
|
|
833
|
+
// The service.
|
|
834
|
+
const service = first(SERVICE_NAME_IN_TEXT);
|
|
835
|
+
if (service !== null)
|
|
836
|
+
return { token: service, what: "the scheduler service's name" };
|
|
837
|
+
const tool = first(SERVICE_TOOL_IN_TEXT);
|
|
838
|
+
if (tool !== null) {
|
|
839
|
+
if (texts.some((text) => text.includes('namzu')))
|
|
840
|
+
return { token: `${tool} … namzu`, what: 'a service tool with a namzu name' };
|
|
841
|
+
if (plain.includes('*'))
|
|
842
|
+
return { token: `${tool} … *`, what: 'a service tool with a pattern that can match it' };
|
|
843
|
+
if (tool === 'systemctl') {
|
|
844
|
+
const all = first(/(?:^|\s)(isolate|exit)(?![a-z0-9_-])/);
|
|
845
|
+
if (all !== null)
|
|
846
|
+
return { token: `systemctl ${all}`, what: 'which stops every service' };
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const killer = first(PROCESS_KILLER_IN_TEXT);
|
|
850
|
+
if (killer !== null)
|
|
851
|
+
return {
|
|
852
|
+
token: killer,
|
|
853
|
+
what: 'which kills by pattern, and the scheduler is a node process a pattern can match',
|
|
854
|
+
};
|
|
855
|
+
// The CLI's `schedule` subcommands.
|
|
856
|
+
// `$'…'` and `$"…"` are quotes, not expansions.
|
|
857
|
+
const cli = texts.some((text) => CLI_IN_TEXT.test(text)) ||
|
|
858
|
+
EXPANSION_IN_TEXT.test(plain.replace(/\$(?=['"])/g, ''));
|
|
859
|
+
if (cli)
|
|
860
|
+
for (const text of [squashed, decoded]) {
|
|
861
|
+
const subcommand = scheduleSubcommand(text);
|
|
862
|
+
if (subcommand !== null)
|
|
863
|
+
return { token: subcommand, what: NOT_READ_ONLY };
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
605
866
|
}
|
|
606
|
-
readingVerdict(reading, catalogue, loops) {
|
|
607
|
-
for (const command of reading.commands)
|
|
608
|
-
|
|
609
|
-
|
|
867
|
+
readingVerdict(reading, catalogue, loops, dialect) {
|
|
868
|
+
for (const command of reading.commands) {
|
|
869
|
+
const encoded = encodedCommandCall(command);
|
|
870
|
+
if (encoded !== null)
|
|
871
|
+
return {
|
|
872
|
+
reason: 'encoded command cannot be read at all',
|
|
873
|
+
detail: `${shown(encoded.program.value)} ${shown(encoded.flag.value)} runs a base64-encoded script the floor cannot read at all, so it is refused outright whatever it decodes to; use -Command '<literal text>' instead`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
for (const command of reading.commands) {
|
|
877
|
+
const does = reachesScheduler(command, wordsOf(command, catalogue), this.daemonCommandLine, dialect);
|
|
878
|
+
if (does !== null)
|
|
879
|
+
return {
|
|
880
|
+
reason: 'scheduler command',
|
|
881
|
+
detail: `${shown(command.words.map((w) => w.text).join(' '))} ${does}`,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
610
884
|
const variables = this.assignments(reading);
|
|
611
885
|
const expanding = (w) => w.expands && !localeOnly(w.value, catalogue);
|
|
612
886
|
const expands = reading.commands.some((c) => c.words.some(expanding)) ||
|
|
@@ -616,6 +890,7 @@ class Floor {
|
|
|
616
890
|
// variable is spelled out with its value. One that is exported, or
|
|
617
891
|
// could be, is checked where it stands.
|
|
618
892
|
const exported = reading.commands.some((c) => c.words.some((w) => !w.expands && EXPORTING.has(commandName(w.value))));
|
|
893
|
+
const names = (what) => `names ${this.protectedWords(what)}`;
|
|
619
894
|
const cwd = new Directories(this.folders);
|
|
620
895
|
// In a loop, a `cd` late in the body applies to the next pass's first
|
|
621
896
|
// command: every command gets every directory the line can reach.
|
|
@@ -623,8 +898,32 @@ class Floor {
|
|
|
623
898
|
for (const command of reading.commands)
|
|
624
899
|
cwd.after(command, (target) => this.spell(target, variables, cwd.current()), this.userHome);
|
|
625
900
|
const seen = new Set();
|
|
901
|
+
/** Files an earlier command in this line wrote, resolved and normalized; content unverifiable. */
|
|
902
|
+
const written = new Set();
|
|
626
903
|
for (const command of reading.commands) {
|
|
627
904
|
const here = cwd.current();
|
|
905
|
+
const findExec = this.findExecFinding(command, variables, here, dialect);
|
|
906
|
+
if (findExec !== null)
|
|
907
|
+
return findExec;
|
|
908
|
+
const executed = executedPath(command);
|
|
909
|
+
if (executed !== null && !executed.expands) {
|
|
910
|
+
for (const base of isAbsolute(executed.value) ? [''] : here.dirs) {
|
|
911
|
+
const path = base === '' ? normalize(executed.value) : join(base, executed.value);
|
|
912
|
+
if (written.has(path)) {
|
|
913
|
+
return {
|
|
914
|
+
reason: 'runs a file the script wrote earlier',
|
|
915
|
+
detail: `${shown(command.text)} runs ${shown(executed.text)}, which an earlier command in this script wrote; its content cannot be verified, so it is refused rather than run unattended`,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
for (const target of writeTargets(command)) {
|
|
921
|
+
if (target.expands)
|
|
922
|
+
continue;
|
|
923
|
+
for (const base of isAbsolute(target.value) ? [''] : here.dirs) {
|
|
924
|
+
written.add(base === '' ? normalize(target.value) : join(base, target.value));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
628
927
|
const check = (word) => this.wordNames(word, variables, here, expands);
|
|
629
928
|
const standalone = command.words.length === command.assignments && !exported;
|
|
630
929
|
for (const [i, word] of command.words.entries()) {
|
|
@@ -632,18 +931,27 @@ class Floor {
|
|
|
632
931
|
continue;
|
|
633
932
|
const named = check(word);
|
|
634
933
|
if (named !== null)
|
|
635
|
-
return
|
|
934
|
+
return {
|
|
935
|
+
reason: `word names ${named}`,
|
|
936
|
+
detail: `the argument ${shown(word.text)} ${names(named)}`,
|
|
937
|
+
};
|
|
636
938
|
// `X=~/.namzu` as an argument, or exported: the value on its own.
|
|
637
939
|
const assigned = assignedValue(word);
|
|
638
940
|
const value = assigned ? check(assigned.word) : null;
|
|
639
941
|
if (value !== null)
|
|
640
|
-
return
|
|
942
|
+
return {
|
|
943
|
+
reason: `assigned value names ${value}`,
|
|
944
|
+
detail: `the value assigned in ${shown(word.text)} ${names(value)}`,
|
|
945
|
+
};
|
|
641
946
|
}
|
|
642
947
|
for (const redirection of command.redirections) {
|
|
643
948
|
seen.add(redirection);
|
|
644
949
|
const named = redirectsTo(redirection) ? check(redirection.target) : null;
|
|
645
950
|
if (named !== null)
|
|
646
|
-
return
|
|
951
|
+
return {
|
|
952
|
+
reason: `redirection names ${named}`,
|
|
953
|
+
detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
|
|
954
|
+
};
|
|
647
955
|
}
|
|
648
956
|
cwd.after(command, (target) => this.spell(target, variables, cwd.current()), this.userHome);
|
|
649
957
|
}
|
|
@@ -656,12 +964,18 @@ class Floor {
|
|
|
656
964
|
continue;
|
|
657
965
|
const named = check(redirection.target);
|
|
658
966
|
if (named !== null)
|
|
659
|
-
return
|
|
967
|
+
return {
|
|
968
|
+
reason: `redirection names ${named}`,
|
|
969
|
+
detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
|
|
970
|
+
};
|
|
660
971
|
}
|
|
661
972
|
for (const word of reading.compoundWords) {
|
|
662
973
|
const named = check(word);
|
|
663
974
|
if (named !== null)
|
|
664
|
-
return
|
|
975
|
+
return {
|
|
976
|
+
reason: `loop or case word names ${named}`,
|
|
977
|
+
detail: `the loop or case word ${shown(word.text)} ${names(named)}`,
|
|
978
|
+
};
|
|
665
979
|
}
|
|
666
980
|
return null;
|
|
667
981
|
}
|
|
@@ -847,6 +1161,18 @@ class Floor {
|
|
|
847
1161
|
}
|
|
848
1162
|
return false;
|
|
849
1163
|
}
|
|
1164
|
+
/** Whether text after an unknown expansion holds NAMZU_HOME's last segment as a path segment. */
|
|
1165
|
+
restNamesHome(rest) {
|
|
1166
|
+
let at = rest.indexOf(this.homeCore);
|
|
1167
|
+
while (at >= 0) {
|
|
1168
|
+
const before = at === 0 ? '' : rest[at - 1];
|
|
1169
|
+
const after = rest[at + this.homeCore.length] ?? '';
|
|
1170
|
+
if (/^[./\\]?$/.test(before) && !NAME_CHAR.test(after))
|
|
1171
|
+
return true;
|
|
1172
|
+
at = rest.indexOf(this.homeCore, at + 1);
|
|
1173
|
+
}
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
850
1176
|
wordNamesHome(word, variables, cwd, lineExpands) {
|
|
851
1177
|
if (word.expands && /\$\{?[!#]?namzu_home(?![a-z0-9_])/i.test(word.value))
|
|
852
1178
|
return true;
|
|
@@ -896,13 +1222,184 @@ class Floor {
|
|
|
896
1222
|
if (next.startsWith(partial))
|
|
897
1223
|
return true;
|
|
898
1224
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1225
|
+
// After the unknown part, NAMZU_HOME's last segment as a path
|
|
1226
|
+
// segment: right after the expansion (`${X}namzu`, where X may end
|
|
1227
|
+
// in `/.`), or after a `.`, `/` or `\` (`$X/.namzu/x`). The name
|
|
1228
|
+
// inside other text (`$(echo namzu)`, `"$USER: namzu done"`) is not
|
|
1229
|
+
// a path.
|
|
1230
|
+
if (spelling.stop !== 'end' && this.homeCore.length >= 3 && this.restNamesHome(spelling.rest))
|
|
902
1231
|
return true;
|
|
903
1232
|
}
|
|
904
1233
|
return false;
|
|
905
1234
|
}
|
|
1235
|
+
/**
|
|
1236
|
+
* `find … -exec|-execdir|-ok|-okdir … ;|+`: the clause runs on every path
|
|
1237
|
+
* `find` matches, substituted for `{}` — a placeholder no static reading
|
|
1238
|
+
* can resolve. Refused when `{}` stands in that clause AND the search
|
|
1239
|
+
* could plausibly reach a protected target: the root is unknown or is
|
|
1240
|
+
* NAMZU_HOME itself or an ancestor of it (so `find` would recurse THROUGH
|
|
1241
|
+
* it), a `-name`/`-iname`/`-path`/`-ipath` pattern could match NAMZU_HOME's
|
|
1242
|
+
* own folder name, or the clause's own program is a tool that can stop or
|
|
1243
|
+
* remove a service (`{}` there could name the scheduler's unit without
|
|
1244
|
+
* ever spelling it in the line the floor can read).
|
|
1245
|
+
*/
|
|
1246
|
+
findExecFinding(command, variables, cwd, dialect) {
|
|
1247
|
+
const words = command.words;
|
|
1248
|
+
const findAt = programPositions(command, dialect)
|
|
1249
|
+
.map((position) => position.word && !position.word.expands && commandName(position.word.value) === 'find'
|
|
1250
|
+
? words.indexOf(position.word)
|
|
1251
|
+
: -1)
|
|
1252
|
+
.find((index) => index >= 0);
|
|
1253
|
+
if (findAt === undefined)
|
|
1254
|
+
return null;
|
|
1255
|
+
const head = words[findAt];
|
|
1256
|
+
const rootWords = [];
|
|
1257
|
+
let i = findAt + 1;
|
|
1258
|
+
while (i < words.length) {
|
|
1259
|
+
const w = words[i];
|
|
1260
|
+
if (!w.expands && (w.value.startsWith('-') || w.value === '(' || w.value === '!'))
|
|
1261
|
+
break;
|
|
1262
|
+
rootWords.push(w);
|
|
1263
|
+
i++;
|
|
1264
|
+
}
|
|
1265
|
+
const roots = rootWords.length > 0
|
|
1266
|
+
? rootWords
|
|
1267
|
+
: [{ text: '.', value: '.', expands: false, quoted: false, substitutes: false }];
|
|
1268
|
+
const reachesHome = roots.some((root) => {
|
|
1269
|
+
if (root.expands)
|
|
1270
|
+
return true;
|
|
1271
|
+
for (const spelling of this.spell(root, variables, cwd)) {
|
|
1272
|
+
if (spelling.stop !== 'end')
|
|
1273
|
+
return true;
|
|
1274
|
+
const bases = isAbsolute(spelling.known) ? [''] : cwd.dirs.length > 0 ? cwd.dirs : [''];
|
|
1275
|
+
for (const base of bases) {
|
|
1276
|
+
const path = base === '' ? normalize(spelling.known) : join(base, spelling.known);
|
|
1277
|
+
if (this.inside(path) || this.belowAncestor(path) !== null)
|
|
1278
|
+
return true;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return false;
|
|
1282
|
+
});
|
|
1283
|
+
let filterMatchesHome = false;
|
|
1284
|
+
for (let j = findAt; j < words.length; j++) {
|
|
1285
|
+
const flag = words[j];
|
|
1286
|
+
if (flag.expands || !/^-i?(?:name|path)$/.test(flag.value))
|
|
1287
|
+
continue;
|
|
1288
|
+
const pattern = words[j + 1];
|
|
1289
|
+
if (!pattern || pattern.expands)
|
|
1290
|
+
continue;
|
|
1291
|
+
if (this.homeSegments.some((seg) => mayBe(lower(pattern.value), seg.name))) {
|
|
1292
|
+
filterMatchesHome = true;
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
for (let j = findAt + 1; j < words.length; j++) {
|
|
1297
|
+
const flag = words[j];
|
|
1298
|
+
if (flag.expands || !/^-(?:exec|execdir|ok|okdir)$/.test(flag.value))
|
|
1299
|
+
continue;
|
|
1300
|
+
let k = j + 1;
|
|
1301
|
+
let hasPlaceholder = false;
|
|
1302
|
+
const clauseStart = k;
|
|
1303
|
+
while (k < words.length) {
|
|
1304
|
+
const w = words[k];
|
|
1305
|
+
if (!w.expands && (w.value === ';' || w.value === '+'))
|
|
1306
|
+
break;
|
|
1307
|
+
if (!w.expands) {
|
|
1308
|
+
if (w.value.includes('{}'))
|
|
1309
|
+
hasPlaceholder = true;
|
|
1310
|
+
}
|
|
1311
|
+
k++;
|
|
1312
|
+
}
|
|
1313
|
+
// Ask the shared resolver what this clause actually runs. The
|
|
1314
|
+
// first word may be `env`/`sudo`/another wrapper, and a shell -c
|
|
1315
|
+
// payload can itself name a service tool. Reusing a one-clause
|
|
1316
|
+
// find command keeps the answer tied to this clause.
|
|
1317
|
+
const clauseCommand = {
|
|
1318
|
+
...command,
|
|
1319
|
+
words: [head, flag, ...words.slice(clauseStart, k)],
|
|
1320
|
+
assignments: 0,
|
|
1321
|
+
};
|
|
1322
|
+
const toolIsSchedulerTool = programPositions(clauseCommand, dialect)
|
|
1323
|
+
.slice(1)
|
|
1324
|
+
.some((position) => position.unknown !== undefined ||
|
|
1325
|
+
(position.word !== undefined &&
|
|
1326
|
+
SCHEDULER_EXEC_TOOLS.has(commandName(position.word.value))));
|
|
1327
|
+
if (hasPlaceholder && (reachesHome || filterMatchesHome || toolIsSchedulerTool)) {
|
|
1328
|
+
const clauseText = words
|
|
1329
|
+
.slice(clauseStart, k)
|
|
1330
|
+
.map((w) => w.text)
|
|
1331
|
+
.join(' ');
|
|
1332
|
+
const why = toolIsSchedulerTool
|
|
1333
|
+
? "its clause's own program can stop or remove a service"
|
|
1334
|
+
: filterMatchesHome
|
|
1335
|
+
? 'a -name/-iname/-path/-ipath pattern could match NAMZU_HOME’s own folder name'
|
|
1336
|
+
: 'its search root is unknown, is NAMZU_HOME, or is an ancestor of it';
|
|
1337
|
+
return {
|
|
1338
|
+
reason: 'find -exec reaches a protected target',
|
|
1339
|
+
detail: `${shown(command.text)} runs \`${flag.value} ${clauseText}\` on whatever it finds, and ${why}; the floor cannot verify what {} will stand for`,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
j = k;
|
|
1343
|
+
}
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/** Tools whose `find -exec`/`-execdir`/`-ok`/`-okdir` clause the floor refuses outright: each can stop or remove a service. */
|
|
1348
|
+
const SCHEDULER_EXEC_TOOLS = new Set([
|
|
1349
|
+
'systemctl',
|
|
1350
|
+
'launchctl',
|
|
1351
|
+
'schtasks',
|
|
1352
|
+
'pkill',
|
|
1353
|
+
'killall',
|
|
1354
|
+
'busctl',
|
|
1355
|
+
'dbus-send',
|
|
1356
|
+
'gdbus',
|
|
1357
|
+
]);
|
|
1358
|
+
/** A write target this command names: `>`/`>>` redirections, `tee`'s arguments, `cp`/`mv`'s destination. */
|
|
1359
|
+
function writeTargets(command) {
|
|
1360
|
+
const targets = [];
|
|
1361
|
+
for (const r of command.redirections) {
|
|
1362
|
+
if (r.operator === '>' || r.operator === '>>')
|
|
1363
|
+
targets.push(r.target);
|
|
1364
|
+
}
|
|
1365
|
+
const words = command.words;
|
|
1366
|
+
const head = words[command.assignments];
|
|
1367
|
+
if (!head || head.expands)
|
|
1368
|
+
return targets;
|
|
1369
|
+
const name = commandName(head.value);
|
|
1370
|
+
const rest = words
|
|
1371
|
+
.slice(command.assignments + 1)
|
|
1372
|
+
.filter((w) => w.expands || !w.value.startsWith('-'));
|
|
1373
|
+
if (name === 'tee')
|
|
1374
|
+
targets.push(...rest);
|
|
1375
|
+
else if (name === 'cp' || name === 'mv') {
|
|
1376
|
+
const last = rest.at(-1);
|
|
1377
|
+
if (last)
|
|
1378
|
+
targets.push(last);
|
|
1379
|
+
}
|
|
1380
|
+
return targets;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* The path this command executes as a FILE — `./x.sh`, `some/dir/x.sh`,
|
|
1384
|
+
* `sh x.sh`, `bash x.sh`, `. x.sh`, `source x.sh` — or null. A bare name on
|
|
1385
|
+
* `PATH` (`x.sh` alone, no `/`) is not: that resolves to whatever `PATH`
|
|
1386
|
+
* finds, not a file this line can be shown to have written.
|
|
1387
|
+
*/
|
|
1388
|
+
function executedPath(command) {
|
|
1389
|
+
const words = command.words;
|
|
1390
|
+
const head = words[command.assignments];
|
|
1391
|
+
if (!head || head.expands)
|
|
1392
|
+
return null;
|
|
1393
|
+
const name = commandName(head.value);
|
|
1394
|
+
if (name === 'sh' || name === 'bash' || name === '.' || name === 'source') {
|
|
1395
|
+
const rest = words
|
|
1396
|
+
.slice(command.assignments + 1)
|
|
1397
|
+
.filter((w) => w.expands || !w.value.startsWith('-'));
|
|
1398
|
+
return rest[0] ?? null;
|
|
1399
|
+
}
|
|
1400
|
+
if (!head.expands && head.value.includes('/'))
|
|
1401
|
+
return head;
|
|
1402
|
+
return null;
|
|
906
1403
|
}
|
|
907
1404
|
/** `NAME=value`, `NAME+=value` or `--opt=value`: the value as a word of its own. */
|
|
908
1405
|
function assignedValue(word) {
|
|
@@ -920,6 +1417,7 @@ function assignedValue(word) {
|
|
|
920
1417
|
value: word.value.slice(match[0].length),
|
|
921
1418
|
expands: word.expands,
|
|
922
1419
|
quoted: word.quoted,
|
|
1420
|
+
substitutes: word.substitutes,
|
|
923
1421
|
},
|
|
924
1422
|
};
|
|
925
1423
|
}
|
|
@@ -1041,11 +1539,23 @@ function fedTexts(readings) {
|
|
|
1041
1539
|
}
|
|
1042
1540
|
return [...out];
|
|
1043
1541
|
}
|
|
1044
|
-
/** A command the lexer listed whose name or payload it could not read. */
|
|
1542
|
+
/** A command the lexer listed whose name or payload it could not read, in words, or null. */
|
|
1045
1543
|
function unreadCommand(command) {
|
|
1046
1544
|
const head = command.words[command.assignments];
|
|
1047
1545
|
if (head?.expands)
|
|
1048
|
-
return
|
|
1546
|
+
return `the command's name ${shown(head.text)} expands at runtime`;
|
|
1049
1547
|
return runsUnreadText(command);
|
|
1050
1548
|
}
|
|
1549
|
+
/** `, in the argument …` for the first word that holds `token`, or nothing. */
|
|
1550
|
+
function whereIn(readings, token) {
|
|
1551
|
+
const holds = typeof token === 'string'
|
|
1552
|
+
? (text) => text.includes(lower(token))
|
|
1553
|
+
: (text) => token.test(text);
|
|
1554
|
+
for (const reading of readings)
|
|
1555
|
+
for (const command of reading.commands)
|
|
1556
|
+
for (const word of command.words)
|
|
1557
|
+
if (holds(lower(word.value)))
|
|
1558
|
+
return `, in the argument ${shown(word.value)}`;
|
|
1559
|
+
return '';
|
|
1560
|
+
}
|
|
1051
1561
|
//# sourceMappingURL=floor.js.map
|