ssh-mcp 2.2.0 → 2.2.2
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/README.md +13 -0
- package/build/guard/elicitation.js +52 -7
- package/build/index.js +26 -5
- package/build/policy/classifier.js +106 -12
- package/build/policy/engine.js +30 -9
- package/build/tools/pipeline.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -378,6 +378,19 @@ distinguishable from a fresh human answer.
|
|
|
378
378
|
**Off by default** (`0` = always prompt). Auto-approval weakens the gate that
|
|
379
379
|
makes destructive commands safe, so turning it on should be a decision.
|
|
380
380
|
|
|
381
|
+
#### Answering the prompt
|
|
382
|
+
|
|
383
|
+
Approval goes through the MCP elicitation request, so what you see is your
|
|
384
|
+
client's dialog. Accepting it approves the command — there is no second field to
|
|
385
|
+
fill in.
|
|
386
|
+
|
|
387
|
+
You have **10 minutes** to answer. Past that the request expires and the command
|
|
388
|
+
is refused rather than left pending, and the refusal says so; the prompt may
|
|
389
|
+
still be open in your client, in which case run the command again once you are
|
|
390
|
+
ready. If your client does not support elicitation at all, every destructive and
|
|
391
|
+
privileged command is refused with `APPROVAL_UNAVAILABLE` naming that cause —
|
|
392
|
+
approval fails closed by design.
|
|
393
|
+
|
|
381
394
|
### Command Quota
|
|
382
395
|
|
|
383
396
|
`commandQuotaPerDay` bounds how many commands a profile may run in a rolling
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How long a person has to read a destructive command and decide.
|
|
4
|
+
*
|
|
5
|
+
* Explicit because the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 seconds, and
|
|
6
|
+
* inheriting it here set a human's reading time from a default chosen for
|
|
7
|
+
* machine round trips. An operator who stepped away, or who was working out an
|
|
8
|
+
* unfamiliar dialog, had the request expire underneath them and the command
|
|
9
|
+
* refused (#91).
|
|
10
|
+
*
|
|
11
|
+
* Ten minutes is long enough to be interrupted and come back, and still bounded
|
|
12
|
+
* so a client that never answers cannot pin a tool call open forever.
|
|
13
|
+
*/
|
|
14
|
+
export const APPROVAL_TIMEOUT_MS = 600_000;
|
|
1
15
|
/**
|
|
2
16
|
* Ask the MCP client to confirm a destructive or privileged command.
|
|
3
17
|
*
|
|
@@ -6,8 +20,9 @@
|
|
|
6
20
|
* of silently disabling the gate that guards every destructive command.
|
|
7
21
|
*
|
|
8
22
|
* Fails closed: any error (client without elicitation support, transport
|
|
9
|
-
* failure, malformed reply) denies the command. The failure is logged,
|
|
10
|
-
* a gate that always denies for an unnoticed reason is its own kind of
|
|
23
|
+
* failure, timeout, malformed reply) denies the command. The failure is logged,
|
|
24
|
+
* because a gate that always denies for an unnoticed reason is its own kind of
|
|
25
|
+
* outage.
|
|
11
26
|
*/
|
|
12
27
|
export async function requestApproval(server, command, profileName, evaluation) {
|
|
13
28
|
const message = `Confirm ${evaluation.commandClass} command on "${profileName}":\n\n${command}`;
|
|
@@ -22,17 +37,47 @@ export async function requestApproval(server, command, profileName, evaluation)
|
|
|
22
37
|
title: 'Approve this command?',
|
|
23
38
|
},
|
|
24
39
|
},
|
|
25
|
-
required
|
|
40
|
+
// `confirm` is deliberately not required. The protocol already carries
|
|
41
|
+
// the decision in `action`, where accept and decline are distinct
|
|
42
|
+
// results, so requiring a boolean on top asked the user to say the
|
|
43
|
+
// same thing twice — and clients render a required boolean as a
|
|
44
|
+
// checkbox beside the accept/decline row. Choosing Accept without
|
|
45
|
+
// ticking it submits a form missing a required field, so the client
|
|
46
|
+
// can produce no valid result and sends `cancel`, which arrived here
|
|
47
|
+
// as "User did not approve this command". The user picked Approve and
|
|
48
|
+
// was told they had declined (#91).
|
|
26
49
|
},
|
|
27
|
-
});
|
|
28
|
-
|
|
50
|
+
}, { timeout: APPROVAL_TIMEOUT_MS });
|
|
51
|
+
// `action` decides. An explicit `confirm: false` from a client that still
|
|
52
|
+
// sends the field is honoured, because refusing to run on an explicit "no"
|
|
53
|
+
// costs nothing and reading it as approval would be indefensible.
|
|
54
|
+
if (result.action === 'accept' && result.content?.confirm !== false) {
|
|
29
55
|
return { approved: true, approver: 'mcp-client' };
|
|
30
56
|
}
|
|
31
57
|
return { approved: false };
|
|
32
58
|
}
|
|
33
59
|
catch (err) {
|
|
60
|
+
const timedOut = err instanceof McpError && err.code === ErrorCode.RequestTimeout;
|
|
61
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
62
|
+
// stderr still gets it for the operator's logs, but it cannot be the only
|
|
63
|
+
// copy: for a stdio MCP server that is a client log file nobody reads, so
|
|
64
|
+
// the caller gets the diagnosis too and can put it in front of the user.
|
|
34
65
|
console.error(`Approval request failed for ${evaluation.commandClass} command on "${profileName}" — denying. ` +
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
`${timedOut ? `No answer within ${APPROVAL_TIMEOUT_MS / 1000}s.` : 'Does this client support elicitation?'} ` +
|
|
67
|
+
`Cause: ${cause}`);
|
|
68
|
+
// Naming the cause rather than guessing at it. This message was introduced
|
|
69
|
+
// to stop APPROVAL_DENIED covering two different failures, and then led with
|
|
70
|
+
// "a client without elicitation support" for every error the catch saw,
|
|
71
|
+
// timeouts included — the same defect, inside its own fix.
|
|
72
|
+
return {
|
|
73
|
+
approved: false,
|
|
74
|
+
unavailable: timedOut
|
|
75
|
+
? `no answer arrived within ${APPROVAL_TIMEOUT_MS / 1000}s, so it was refused rather than left ` +
|
|
76
|
+
`pending. The prompt may still be open in your client; approve the command again once you are ` +
|
|
77
|
+
`ready to answer it.`
|
|
78
|
+
: `this MCP client could not be asked to approve it. The likely cause is a client without ` +
|
|
79
|
+
`elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
|
|
80
|
+
`closed, so the command was refused rather than run. Underlying error: ${cause}`,
|
|
81
|
+
};
|
|
37
82
|
}
|
|
38
83
|
}
|
package/build/index.js
CHANGED
|
@@ -24,6 +24,27 @@ function parseArgv() {
|
|
|
24
24
|
}
|
|
25
25
|
return config;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether a boolean flag was passed at all.
|
|
29
|
+
*
|
|
30
|
+
* `parseArgv` stores `null` for a flag written without `=`, which is how every
|
|
31
|
+
* documented boolean flag is written. Testing that value for truthiness makes
|
|
32
|
+
* the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
|
|
33
|
+
* `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
|
|
34
|
+
* nothing documents (#91). The two call sites that got it right already used
|
|
35
|
+
* `!== undefined`; this gives the check a name so the next one cannot miss it.
|
|
36
|
+
*
|
|
37
|
+
* `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
|
|
38
|
+
* turned off once written into a wrapper script is its own annoyance.
|
|
39
|
+
*/
|
|
40
|
+
function flagEnabled(argv, name) {
|
|
41
|
+
if (!(name in argv))
|
|
42
|
+
return false;
|
|
43
|
+
const value = argv[name];
|
|
44
|
+
if (value === null || value === '')
|
|
45
|
+
return true;
|
|
46
|
+
return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
|
|
47
|
+
}
|
|
27
48
|
/**
|
|
28
49
|
* v1 flags that v2 removed. Silently ignoring them means the user only finds
|
|
29
50
|
* out at the first command, as an auth failure with no hint about the cause.
|
|
@@ -62,7 +83,7 @@ function parseMaxChars(raw) {
|
|
|
62
83
|
* existed. `--insecureHostKey` is kept as an alias for the documented flag.
|
|
63
84
|
*/
|
|
64
85
|
function resolveHostKeyMode(argv) {
|
|
65
|
-
if (argv
|
|
86
|
+
if (flagEnabled(argv, 'insecureHostKey'))
|
|
66
87
|
return 'insecure';
|
|
67
88
|
const mode = argv.hostKeyMode;
|
|
68
89
|
if (mode === null || mode === undefined)
|
|
@@ -134,7 +155,7 @@ async function buildAppConfig(argv) {
|
|
|
134
155
|
role: 'admin',
|
|
135
156
|
group: resolveHostGroup(argv),
|
|
136
157
|
readOnly: false,
|
|
137
|
-
approvalPolicy: argv
|
|
158
|
+
approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
|
|
138
159
|
cert: false,
|
|
139
160
|
sessionMaxPerConnection: defaults.sessionMaxPerConnection,
|
|
140
161
|
sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
|
|
@@ -152,8 +173,8 @@ async function main() {
|
|
|
152
173
|
}
|
|
153
174
|
const config = await buildAppConfig(argv);
|
|
154
175
|
const hostKeyMode = resolveHostKeyMode(argv);
|
|
155
|
-
const entropyScan =
|
|
156
|
-
const tamperEvident =
|
|
176
|
+
const entropyScan = flagEnabled(argv, 'auditEntropyScan');
|
|
177
|
+
const tamperEvident = flagEnabled(argv, 'auditTamperEvident');
|
|
157
178
|
await initKeychain();
|
|
158
179
|
if (argv.otelEndpoint) {
|
|
159
180
|
const { initTracing } = await import('./observability/tracer.js');
|
|
@@ -242,4 +263,4 @@ if (isCliEnabled || isTestMode) {
|
|
|
242
263
|
process.exit(1);
|
|
243
264
|
});
|
|
244
265
|
}
|
|
245
|
-
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars };
|
|
266
|
+
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
|
|
@@ -60,14 +60,9 @@ const FORBIDDEN_PATTERNS = [
|
|
|
60
60
|
/mkfs\./,
|
|
61
61
|
/dd\s.*\bof=\/dev\//,
|
|
62
62
|
/>\s*\/dev\/sd/,
|
|
63
|
-
/\bshutdown\b/,
|
|
64
|
-
/\breboot\b/,
|
|
65
|
-
/\bhalt\b/,
|
|
66
|
-
/\bpoweroff\b/,
|
|
67
63
|
/:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
|
|
68
64
|
/curl\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
69
65
|
/wget\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
70
|
-
/\beval\b/,
|
|
71
66
|
/>\s*\/etc\/cron/,
|
|
72
67
|
/>\s*\/etc\/systemd/,
|
|
73
68
|
/>\s*~\/.ssh\/authorized_keys/,
|
|
@@ -75,6 +70,98 @@ const FORBIDDEN_PATTERNS = [
|
|
|
75
70
|
/\bchmod\s+-R\s+777\s+\//,
|
|
76
71
|
/\bchown\s+-R\s.*\s\/\s*$/,
|
|
77
72
|
];
|
|
73
|
+
/**
|
|
74
|
+
* Words that are forbidden when *invoked*, which the patterns above cannot
|
|
75
|
+
* express.
|
|
76
|
+
*
|
|
77
|
+
* These used to be `/\breboot\b/` and friends, matching the word anywhere in
|
|
78
|
+
* the string. `last reboot` reads a log and was refused for containing the
|
|
79
|
+
* word; so were `grep -r reboot /etc/`, `cat /var/run/reboot-required` and
|
|
80
|
+
* `journalctl | grep shutdown`. On a NAS an agent checking boot history trips
|
|
81
|
+
* this on its first command, which is how it was reported (#91).
|
|
82
|
+
*
|
|
83
|
+
* Matching an invocation rather than a mention needs to know where a command
|
|
84
|
+
* word can start, and that is a tokenizer's job, not a regex's — the regex
|
|
85
|
+
* forms that come close all reintroduce the `\S*` backtracking the block above
|
|
86
|
+
* exists to avoid.
|
|
87
|
+
*/
|
|
88
|
+
const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff', 'eval']);
|
|
89
|
+
/** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
|
|
90
|
+
const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
91
|
+
const PRIVILEGE_PREFIXES = new Set(['sudo', 'su', 'doas', 'pkexec']);
|
|
92
|
+
/**
|
|
93
|
+
* Privilege-prefix flags that consume the next argument, so `sudo -u root
|
|
94
|
+
* reboot` is not read as invoking `root`. Enumerable because it is one tool's
|
|
95
|
+
* option set, unlike "every flag of every binary".
|
|
96
|
+
*/
|
|
97
|
+
const PREFIX_VALUE_FLAGS = new Set([
|
|
98
|
+
'-u', '-g', '-p', '-C', '-h', '-r', '-t', '-U', '-c',
|
|
99
|
+
'--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
|
|
100
|
+
'--other-user', '--command',
|
|
101
|
+
]);
|
|
102
|
+
/** `/sbin/reboot` and `reboot` are the same invocation. */
|
|
103
|
+
function stripPath(word) {
|
|
104
|
+
const slash = word.lastIndexOf('/');
|
|
105
|
+
return slash === -1 ? word : word.slice(slash + 1);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The command words a shell would actually execute — the head of every
|
|
109
|
+
* `;`/`&&`/`||`/`|`/newline-separated segment, past any privilege prefix, plus
|
|
110
|
+
* the arguments of a multiplexer like `systemctl`.
|
|
111
|
+
*
|
|
112
|
+
* Pure string work: split, trim, set lookups. No quantifiers, so nothing here
|
|
113
|
+
* can backtrack.
|
|
114
|
+
*/
|
|
115
|
+
function invokedWords(command) {
|
|
116
|
+
const invoked = [];
|
|
117
|
+
for (const segment of command.split(/[;&|\n]/)) {
|
|
118
|
+
const words = segment.trim().split(/\s+/).filter(Boolean).map(stripPath);
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < words.length && PRIVILEGE_PREFIXES.has(words[i])) {
|
|
121
|
+
i++;
|
|
122
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
123
|
+
const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
|
|
124
|
+
i++;
|
|
125
|
+
if (consumesValue)
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const head = words[i];
|
|
130
|
+
if (!head)
|
|
131
|
+
continue;
|
|
132
|
+
invoked.push(head);
|
|
133
|
+
// `systemctl reboot` restarts the host. Reading a unit that happens to be
|
|
134
|
+
// named after a power action is rare enough that erring towards refusal
|
|
135
|
+
// here costs little.
|
|
136
|
+
if (ACTION_MULTIPLEXERS.has(head)) {
|
|
137
|
+
invoked.push(...words.slice(i + 1).filter((w) => !w.startsWith('-')));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return invoked;
|
|
141
|
+
}
|
|
142
|
+
const FORBIDDEN_RULES = [
|
|
143
|
+
...FORBIDDEN_PATTERNS.map((re) => ({ label: String(re), test: (c) => re.test(c) })),
|
|
144
|
+
{
|
|
145
|
+
label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
|
|
146
|
+
test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
/**
|
|
150
|
+
* Which never-allowed rule this command trips, or null. The single entry point:
|
|
151
|
+
* FORBIDDEN_PATTERNS is deliberately not exported, because half of the list
|
|
152
|
+
* lives in FORBIDDEN_RULES and a caller checking only the regexes would quietly
|
|
153
|
+
* permit `sudo reboot`.
|
|
154
|
+
*/
|
|
155
|
+
export function findForbiddenMatch(command) {
|
|
156
|
+
for (const rule of FORBIDDEN_RULES) {
|
|
157
|
+
if (rule.test(command))
|
|
158
|
+
return rule.label;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
export function isForbidden(command) {
|
|
163
|
+
return findForbiddenMatch(command) !== null;
|
|
164
|
+
}
|
|
78
165
|
/**
|
|
79
166
|
* Commands that are destructive but legitimate under approval — e.g.
|
|
80
167
|
* `rm -rf /tmp/build`, which must NOT be confused with `rm -rf /`.
|
|
@@ -82,8 +169,17 @@ const FORBIDDEN_PATTERNS = [
|
|
|
82
169
|
const DESTRUCTIVE_PATTERNS = [
|
|
83
170
|
/rm\s+-rf?\s+\//,
|
|
84
171
|
];
|
|
85
|
-
/**
|
|
86
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Everything that classifies as destructive: forbidden commands included.
|
|
174
|
+
*
|
|
175
|
+
* Goes through isForbidden() rather than the regex list, so a command caught by
|
|
176
|
+
* an invocation rule is classified destructive too — and, just as importantly,
|
|
177
|
+
* reading a log that mentions `reboot` is no longer classified destructive
|
|
178
|
+
* either.
|
|
179
|
+
*/
|
|
180
|
+
function isDestructive(command) {
|
|
181
|
+
return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
|
|
182
|
+
}
|
|
87
183
|
const PRIVILEGED_INDICATORS = [
|
|
88
184
|
/^\s*sudo\b/,
|
|
89
185
|
/^\s*su\b/,
|
|
@@ -109,10 +205,8 @@ export function classifyCommand(command) {
|
|
|
109
205
|
if (isPrivileged) {
|
|
110
206
|
return { binary, fullCommand, class: 'privileged' };
|
|
111
207
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return { binary, fullCommand, class: 'destructive' };
|
|
115
|
-
}
|
|
208
|
+
if (isDestructive(trimmed)) {
|
|
209
|
+
return { binary, fullCommand, class: 'destructive' };
|
|
116
210
|
}
|
|
117
211
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
|
118
212
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
@@ -123,4 +217,4 @@ export function classifyCommand(command) {
|
|
|
123
217
|
}
|
|
124
218
|
return { binary, fullCommand, class: 'safe' };
|
|
125
219
|
}
|
|
126
|
-
export { READ_ONLY_ALLOWLIST,
|
|
220
|
+
export { READ_ONLY_ALLOWLIST, isDestructive };
|
package/build/policy/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifyCommand,
|
|
1
|
+
import { classifyCommand, findForbiddenMatch } from './classifier.js';
|
|
2
2
|
/** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
|
|
3
3
|
export const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
4
4
|
export const DEFAULT_RULES = {
|
|
@@ -163,14 +163,14 @@ export class PolicyEngine {
|
|
|
163
163
|
opaUrl = null;
|
|
164
164
|
/** Rate-limits the fail-open warning so one outage can't flood stderr. */
|
|
165
165
|
lastOpaWarning = 0;
|
|
166
|
-
/**
|
|
167
|
-
|
|
166
|
+
/** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
|
|
167
|
+
userPatterns;
|
|
168
168
|
constructor(rules = DEFAULT_RULES) {
|
|
169
169
|
this.rules = rules;
|
|
170
170
|
// Compile eagerly: a deny rule that silently degrades (the old code fell
|
|
171
171
|
// back to substring-matching the command against the regex *source*) is
|
|
172
172
|
// worse than a startup failure, because nothing surfaces the degradation.
|
|
173
|
-
|
|
173
|
+
this.userPatterns = (rules.denylist ?? []).map((pattern) => {
|
|
174
174
|
try {
|
|
175
175
|
return new RegExp(pattern);
|
|
176
176
|
}
|
|
@@ -178,7 +178,6 @@ export class PolicyEngine {
|
|
|
178
178
|
throw new Error(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
179
|
}
|
|
180
180
|
});
|
|
181
|
-
this.denyPatterns = [...FORBIDDEN_PATTERNS, ...userPatterns];
|
|
182
181
|
}
|
|
183
182
|
setOpaUrl(url) {
|
|
184
183
|
this.opaUrl = url;
|
|
@@ -187,13 +186,14 @@ export class PolicyEngine {
|
|
|
187
186
|
const parsed = classifyCommand(command);
|
|
188
187
|
const allowedClasses = this.getAllowedClasses(profile);
|
|
189
188
|
const classAllowed = allowedClasses.includes(parsed.class);
|
|
190
|
-
|
|
189
|
+
const denied = this.findDenyMatch(command);
|
|
190
|
+
if (denied) {
|
|
191
191
|
return {
|
|
192
192
|
decision: 'deny',
|
|
193
193
|
commandClass: parsed.class,
|
|
194
194
|
binary: parsed.binary,
|
|
195
195
|
ruleId: 'denylist',
|
|
196
|
-
reason:
|
|
196
|
+
reason: denied,
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
199
|
const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
|
|
@@ -363,7 +363,28 @@ export class PolicyEngine {
|
|
|
363
363
|
// ask-destructive and deny both gate on destructive/privileged commands.
|
|
364
364
|
return needsApproval;
|
|
365
365
|
}
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Which deny rule refused this command, worded so the reader can act on it.
|
|
368
|
+
*
|
|
369
|
+
* The old message was `Command matches denylist pattern` and named nothing —
|
|
370
|
+
* not the rule, not where it came from, not whether the operator could change
|
|
371
|
+
* it. A user who hit it had no way to tell a built-in refusal from one they
|
|
372
|
+
* had written themselves (#91). explainRoleDenial already names the role, the
|
|
373
|
+
* group, the class and the allowed set; this path never got the same
|
|
374
|
+
* treatment.
|
|
375
|
+
*/
|
|
376
|
+
findDenyMatch(command) {
|
|
377
|
+
const builtIn = findForbiddenMatch(command);
|
|
378
|
+
if (builtIn) {
|
|
379
|
+
return `Command matches a built-in never-allowed rule: ${builtIn}. ` +
|
|
380
|
+
`This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.`;
|
|
381
|
+
}
|
|
382
|
+
for (const pattern of this.userPatterns) {
|
|
383
|
+
if (pattern.test(command)) {
|
|
384
|
+
return `Command matches /${pattern.source}/, a pattern from [policy].denylist in your config file. ` +
|
|
385
|
+
`Remove or narrow it there to allow this command.`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
368
389
|
}
|
|
369
390
|
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -55,7 +55,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
55
55
|
}
|
|
56
56
|
const approval = await requestApproval(server, command, conn.profile.name, evaluation);
|
|
57
57
|
if (!approval.approved) {
|
|
58
|
-
|
|
58
|
+
// Two different failures used to share one message. "User did not
|
|
59
|
+
// approve" is true when the user declined and a lie when the client
|
|
60
|
+
// could not be asked, and the reader cannot tell which they got (#91).
|
|
61
|
+
throw new PolicyRefusedError(approval.unavailable
|
|
62
|
+
? `APPROVAL_UNAVAILABLE: ${approval.unavailable}`
|
|
63
|
+
: 'APPROVAL_DENIED: User did not approve this command', evaluation);
|
|
59
64
|
}
|
|
60
65
|
grants.record(conn.profile.name, command, evaluation.commandClass);
|
|
61
66
|
return { conn, evaluation, approver: approval.approver };
|
package/package.json
CHANGED