opencode-polkit 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +34 -11
  2. package/dist/index.js +127 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,8 +6,12 @@ instead of requiring a terminal for password input.
6
6
 
7
7
  Like CachyOS Hello, the plugin does not check for a polkit agent up front:
8
8
  `sudo` is always redirected to `pkexec` at execution time, and polkitd routes
9
- the request to whatever agent is currently registered. If no agent is
10
- available, `pkexec` fails with its own clear error message.
9
+ the request to whatever agent is currently registered. Without an agent,
10
+ `pkexec` fails fast with `Error creating textual authentication agent`
11
+ (no TTY); the plugin reports that as a clear denial. A hang (dialog never
12
+ appears) is bounded by the bash tool's own timeout (default 2 min,
13
+ configurable up to 10 min) and left untranslated — it cannot be told
14
+ apart from a long-running command.
11
15
 
12
16
  ## Install
13
17
 
@@ -33,15 +37,34 @@ For local development, point opencode at the project directory instead:
33
37
 
34
38
  ## Behavior
35
39
 
36
- | Command | result |
37
- |-----------------|--------------------------------------------|
38
- | `sudo xxx` | redirects to `pkexec xxx` |
39
- | `doas xxx` | redirects to `pkexec xxx` |
40
- | `pkexec xxx` | passes through |
41
- | `sudoedit` | blocked |
42
- | `visudo` | blocked |
43
-
44
- When a user denies the polkit authentication dialog, a clear error message is shown.
40
+ | Command | result |
41
+ |-----------------------|-------------------------------------------------------|
42
+ | `sudo xxx` | redirects to `pkexec xxx` |
43
+ | `doas xxx` | redirects to `pkexec xxx` |
44
+ | `cat x \| sudo tee y` | redirects to `cat x \| pkexec tee y` (mid-command) |
45
+ | `pkexec xxx` | passes through |
46
+ | `sudoedit` / `visudo` | blocked |
47
+
48
+ ### Minimal intervention
49
+
50
+ The command is rewritten **only** by replacing `sudo`/`doas` with
51
+ `pkexec` — its shape is otherwise untouched, so the agent always sees a
52
+ command that behaves like the one it wrote. Privilege keywords are found
53
+ by a lexical scanner (quote/escape/heredoc aware): `sudo` inside string
54
+ literals, comments or heredocs is ignored; `sudo` in any executable
55
+ position (leading, after `&&`/`||`/`|`, in `$(...)` or subshells) is
56
+ rewritten. `sudo a && sudo b` rewrites both.
57
+
58
+ ### Failures
59
+
60
+ Unambiguous authentication failures are reported as clear errors and the
61
+ command is remembered so a retry is rejected without prompting again
62
+ (cleared when opencode restarts): `Not authorized`, `Error executing
63
+ command as another user`, `Error creating textual authentication agent`
64
+ (no polkit agent + no TTY). A hang (dialog never appears) is bounded by
65
+ the bash tool's own timeout (default 2 min, configurable up to 10 min)
66
+ and is left untranslated — it cannot be told apart from a long-running
67
+ command.
45
68
 
46
69
  ## i18n
47
70
 
package/dist/index.js CHANGED
@@ -1,4 +1,95 @@
1
- const MATCH = /(?<![-\/.\w])(sudo|pkexec|doas|sudoedit|visudo)(?![-\/.\w])/i;
1
+ /**
2
+ * Lexical scan for privilege-escalation keywords OUTSIDE quotes.
3
+ *
4
+ * Shell commands may contain `sudo` anywhere a command starts (pipes,
5
+ * `&&`/`||`, subshells, command substitution), and string literals /
6
+ * comments may contain the same words spuriously. A plain regex cannot
7
+ * tell those apart; this scanner tracks quote state (' " and backslash
8
+ * escapes) and only reports keywords in executable position.
9
+ *
10
+ * - `node -e "…sudo…"` → no hit (inside quotes)
11
+ * - `cat x | sudo tee y` → hit at index 8 (command boundary)
12
+ * - `cd /tmp && sudo ./x` → hit (after `&&`)
13
+ */
14
+ function scanPrivileged(command) {
15
+ const hits = [];
16
+ let inSingle = false;
17
+ let inDouble = false;
18
+ let escaped = false;
19
+ // Heredoc delimiters waiting to close (stack; multiple << are legal).
20
+ const heredocs = [];
21
+ for (let i = 0; i < command.length; i++) {
22
+ const ch = command[i];
23
+ // Inside a heredoc body: only a line consisting of the delimiter ends
24
+ // it. Content there is a string literal, never a command.
25
+ if (heredocs.length > 0) {
26
+ const lineEnd = command.indexOf("\n", i);
27
+ const line = lineEnd === -1 ? command.slice(i) : command.slice(i, lineEnd);
28
+ if (heredocs.some((d) => line === d)) {
29
+ heredocs.pop();
30
+ }
31
+ i = lineEnd === -1 ? command.length : lineEnd;
32
+ continue;
33
+ }
34
+ if (escaped) {
35
+ escaped = false;
36
+ continue;
37
+ }
38
+ if (ch === "\\" && !inSingle) {
39
+ escaped = true;
40
+ continue;
41
+ }
42
+ if (ch === "'" && !inDouble) {
43
+ inSingle = !inSingle;
44
+ continue;
45
+ }
46
+ if (ch === '"' && !inSingle) {
47
+ inDouble = !inDouble;
48
+ continue;
49
+ }
50
+ if (inSingle || inDouble)
51
+ continue;
52
+ // Heredoc start: `<<[-]DELIM` (delim may be quoted or unquoted).
53
+ if (ch === "<" && command[i + 1] === "<") {
54
+ let j = i + 2;
55
+ if (command[j] === "-")
56
+ j++;
57
+ while (j < command.length && /\s/.test(command[j]))
58
+ j++;
59
+ if (j < command.length && (command[j] === "'" || command[j] === '"')) {
60
+ const q = command[j];
61
+ const end = command.indexOf(q, j + 1);
62
+ if (end !== -1) {
63
+ heredocs.push(command.slice(j + 1, end));
64
+ i = end;
65
+ continue;
66
+ }
67
+ }
68
+ else {
69
+ const m = /^[^\s|;&<>()]+/.exec(command.slice(j));
70
+ if (m) {
71
+ heredocs.push(m[0]);
72
+ i = j + m[0].length - 1;
73
+ continue;
74
+ }
75
+ }
76
+ }
77
+ const prev = i > 0 ? command[i - 1] : "";
78
+ if (/[-\/.\w]/.test(prev))
79
+ continue;
80
+ for (const kw of ["sudoedit", "visudo", "sudo", "doas", "pkexec"]) {
81
+ if (command.startsWith(kw, i)) {
82
+ const next = command[i + kw.length] ?? "";
83
+ if (!/[-\/.\w]/.test(next)) {
84
+ hits.push({ index: i, kw });
85
+ i += kw.length - 1;
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return hits;
92
+ }
2
93
  const translations = {
3
94
  en: {
4
95
  blocked: "Privilege escalation command blocked by plugin",
@@ -62,29 +153,54 @@ export const PolkitPlugin = async (_input) => {
62
153
  if (hookInput.tool !== "bash")
63
154
  return;
64
155
  const command = hookOutput?.args?.command ?? "";
65
- if (!MATCH.test(command))
156
+ const hits = scanPrivileged(command);
157
+ if (hits.length === 0)
66
158
  return;
67
159
  if (deniedCommands.has(command)) {
68
160
  throw new Error(MSG.polkitDenied);
69
161
  }
70
- if (/^pkexec(?![-\/.\w])/.test(command.trim())) {
71
- return;
72
- }
73
- if (/(?<![-\/.\w])(sudoedit|visudo)(?![-\/.\w])/.test(command)) {
162
+ if (hits.some((h) => h.kw === "sudoedit" || h.kw === "visudo")) {
74
163
  throw new Error(MSG.blocked);
75
164
  }
76
- hookOutput.args.command = command.replace(/(?<![-\/.\w])(sudo|doas)(?![-\/.\w])/, "pkexec");
165
+ // Already pkexec (leading or mid-command): pass through untouched.
166
+ if (hits.every((h) => h.kw === "pkexec"))
167
+ return;
168
+ // Rewrite every in-command sudo/doas to pkexec (back to front so
169
+ // indices stay valid): `cat x | sudo tee y` -> `cat x | pkexec tee y`.
170
+ // No other rewriting: the command keeps its exact shape so the agent
171
+ // sees only the minimal change (sudo -> pkexec).
172
+ let rewritten = command;
173
+ for (const h of [...hits].reverse()) {
174
+ if (h.kw === "sudo" || h.kw === "doas") {
175
+ rewritten =
176
+ rewritten.slice(0, h.index) + "pkexec" + rewritten.slice(h.index + h.kw.length);
177
+ }
178
+ }
179
+ hookOutput.args.command = rewritten;
77
180
  },
78
181
  "tool.execute.after": async (hookInput, hookOutput) => {
79
182
  if (hookInput.tool !== "bash")
80
183
  return;
81
184
  const command = hookInput.args?.command ?? "";
82
- if (!MATCH.test(command))
185
+ const hits = scanPrivileged(command);
186
+ if (hits.length === 0)
83
187
  return;
84
- const originalCommand = command.replace(/(?<![-\/.\w])pkexec(?![-\/.\w])/, "sudo");
188
+ // Reconstruct the user's original command for the deny list: map
189
+ // pkexec back to sudo.
190
+ let original = command;
191
+ for (const h of [...scanPrivileged(original)].reverse()) {
192
+ if (h.kw === "pkexec") {
193
+ original = original.slice(0, h.index) + "sudo" + original.slice(h.index + 6);
194
+ }
195
+ }
196
+ // Only unambiguous authentication failures are reported and deny
197
+ // listed. A hang (dialog never appears) is bounded by the bash tool's
198
+ // own timeout (default 2 min) and is NOT translated here: it cannot
199
+ // be told apart from a long-running command.
85
200
  if (/\bNot authorized\b/.test(hookOutput.output) ||
86
- /\bError executing command as another user\b/.test(hookOutput.output)) {
87
- deniedCommands.add(originalCommand);
201
+ /\bError executing command as another user\b/.test(hookOutput.output) ||
202
+ /\bError creating textual authentication agent\b/.test(hookOutput.output)) {
203
+ deniedCommands.add(original);
88
204
  throw new Error(MSG.polkitDenied);
89
205
  }
90
206
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-polkit",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Redirect sudo/doas to pkexec in OpenCode — uses system polkit agent for native auth dialogs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",