pi-polkit 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.1] - 2026-11-04
9
+
10
+ ### Added
11
+
12
+ - `polkit-shims.ts`: `su` stand-in. `su -c COMMAND [USER]` (also `-cCOMMAND`,
13
+ `--command COMMAND`, `--command=COMMAND`) becomes
14
+ `pkexec --keep-cwd --user USER sh -c COMMAND`, because a non-login `su`
15
+ keeps the working directory and runs its command through a shell. Every other
16
+ form is refused with status `64` and one line naming the alternative: `su`,
17
+ `su -`, `su -l` (login shell → `pkexec bash`), `-m`/`-p`/`-w` (pkexec
18
+ sanitizes the environment), `-s` (name the shell in the command), `-g`/`-G`
19
+ (pkexec cannot switch groups), `--pty` (no terminal), `-f` and
20
+ `--session-command`.
21
+ - README scope statement: the extension covers elevation → polkit only;
22
+ ssh, gpg/pinentry, keyrings, database clients, `kinit` and disk encryption
23
+ are explicitly out of scope and need an askpass helper or a PTY.
24
+
25
+ ### Changed
26
+
27
+ - README authorisation note: a desktop agent may reuse the password it already
28
+ holds, so a second command can run without a dialog; that cache belongs to
29
+ the agent and must not be relied on. `su -c … USER` authenticates an
30
+ administrator rather than `USER`, and runs the command with `sh -c`.
31
+
32
+ ## [0.1.0] - 2026-11-01
33
+
34
+ ### Added
35
+
36
+ - `polkit-shims.ts`: `sudo`, `doas`, `sudoedit` and `visudo` scripts written to
37
+ a private `0700` directory under `$XDG_RUNTIME_DIR` (falling back to `/tmp`)
38
+ on first use and removed at session shutdown. `sudo` and `doas` forward to
39
+ `pkexec`; `sudoedit` and `visudo` refuse, because polkit has no equivalent
40
+ flow.
41
+ - Deterministic option contract in the shims: `-u USER`, `-uUSER`, `--user USER`
42
+ and `--user=USER` become `pkexec --user USER`, `--` ends option parsing, and
43
+ every other option is refused with exit status `64` and a message naming the
44
+ alternative (`-n` → polkit decides, `-i`/`-s` → `pkexec bash`, `-E`/`-H`/`-P`
45
+ → pkexec sanitizes the environment, `-b` → append `&`). `pkexec` would
46
+ otherwise reject them with `Cannot run program`, which says nothing about what
47
+ to do next.
48
+ - `polkit-redirect.ts`: the Pi extension factory. The `bash` tool is
49
+ replaced by the built-in's own definition with a
50
+ `spawnHook` that prepends the shim directory to `PATH`, and `!` user commands
51
+ run through `createLocalBashOperations` with the same prepend as their first
52
+ command line. Command text is never rewritten.
53
+ - Authentication outcomes are reported from exit status alone (`126` dismissed,
54
+ `127` not authorized, `64` refused invocation); the shims print one line
55
+ explaining status `126`, the only pkexec failure path that is silent.
56
+ - `SHIM_USAGE_EXIT` (64) documents that the refusal status is the shim's own
57
+ and cannot be confused with pkexec's `126`/`127`.
58
+ - Engineering scaffolding: `npm run check` type-checks against the pinned
59
+ `@earendil-works/pi-coding-agent` 1.4.0, and GitHub Actions CI runs it on
60
+ every push and pull request.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-polkit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # pi-polkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Redirects `sudo`, `doas` and `su -c` to `pkexec` inside Pi, so authorisation
4
+ goes through the desktop's polkit agent (KDE, GNOME, …) instead of a terminal
5
+ the shell tools do not have. Applies to the `bash` tool and to `!` user
6
+ commands; `sudo` in other terminals is unchanged.
7
+
8
+ ## Scope
9
+
10
+ This extension covers one category: **elevation → polkit**. It exists because
11
+ the `bash` tool has no terminal, so any program that reads a password from
12
+ `/dev/tty` fails there. `sudo`, `doas` and `su -c` are that category, and they
13
+ are routed to `pkexec`, which asks the desktop's polkit agent instead.
14
+
15
+ Password prompts that are not polkit's business are out of scope: `ssh`,
16
+ `ssh-add`, `gpg`/pinentry, keyrings (`secret-tool`, KWallet), database clients
17
+ (`psql`, `mysql -p`, `redis-cli`), `kinit`, and disk encryption (`cryptsetup`,
18
+ `fscrypt`, `gocryptfs`). They read the process's own terminal, so they need an
19
+ askpass helper or a pseudo-terminal, not polkit; mixing them in would make this
20
+ extension's boundary meaningless.
21
+
22
+ ## Installation
23
+
24
+ Install as a Pi package (published on npm):
25
+
26
+ ```sh
27
+ pi install npm:pi-polkit
28
+ ```
29
+
30
+ Or copy or symlink this directory into Pi's extension location:
31
+
32
+ ```sh
33
+ cp -r pi-polkit ~/.pi/agent/extensions/pi-polkit
34
+ ```
35
+
36
+ For a quick test without installing, pass the directory to Pi:
37
+
38
+ ```sh
39
+ pi -e ./pi-polkit
40
+ ```
41
+
42
+ ## Behavior
43
+
44
+ | Command | Result |
45
+ |------------------------|---------------------------------------------|
46
+ | `sudo apt install x` | polkit dialog, then runs as root |
47
+ | `sudo -u bob …` | as above, as `bob` |
48
+ | `cat x \| sudo tee y` | dialog for `tee` |
49
+ | `sudo -n …` | refused: pkexec has no non-interactive mode |
50
+ | `sudo -i`, `-s`, `-b` | refused, with the equivalent command |
51
+ | `sudoedit`, `visudo` | refused, with the alternative |
52
+ | `su -c 'id'` | dialog, then runs as root |
53
+ | `su -c 'id' bob` | as above, as `bob` (administrator password) |
54
+ | `su`, `su -`, `su -l` | refused: no interactive shell, no terminal |
55
+ | `su -m`, `-s`, `-g`, `-w`, `--pty` | refused, with the alternative |
56
+ | `pkexec …` | passed through |
57
+
58
+ Refused invocations exit with status 64 and one line naming the alternative.
59
+
60
+ ## How it works
61
+
62
+ `sudo`, `doas` and `su` stand-ins are placed first on `PATH`, so the shell
63
+ resolves those instead of the system binaries, and they forward the command to
64
+ `pkexec`. Command text is never rewritten, so quoting, pipes and subshells need
65
+ no special handling.
66
+
67
+ Two wiring paths put the stand-ins on `PATH`, one per execution form:
68
+
69
+ - The `bash` tool is replaced by the built-in's own definition with a
70
+ `spawnHook` that prepends the shim directory to the spawned environment.
71
+ - `!` user commands run through Pi's local bash backend with no prepared
72
+ environment, so their wrapper prepends `export PATH=…` as the first command
73
+ line instead.
74
+
75
+ The shim directory is created lazily in `$XDG_RUNTIME_DIR` (falling back to
76
+ `/tmp`) as `0700` and removed at session shutdown.
77
+
78
+ ## Authorisation
79
+
80
+ `polkitd` decides whether a password is required. These commands always require
81
+ one; other actions may not, according to policy. The extension takes no part in
82
+ that decision.
83
+
84
+ `org.freedesktop.policykit.exec` asks for administrator authentication on every
85
+ call, but the desktop agent may keep the password it just received and answer
86
+ the next challenge itself (KDE's agent does). A second command in the same
87
+ session then runs without a dialog. That cache belongs to the agent: another
88
+ agent, a restarted agent or a dismissed dialog means the next command prompts
89
+ again, so do not rely on it.
90
+
91
+ ## Limitations
92
+
93
+ - Absolute paths (`/usr/bin/sudo`) and relative ones (`./sudo`) bypass `PATH`.
94
+ - Commands that reset `PATH` (`env -i`, `PATH=/usr/bin …`) bypass it as well.
95
+ - Programs that invoke `sudo` with a scrubbed environment bypass it.
96
+ - `su -c … USER` authenticates an administrator (polkit), not `USER`, so an
97
+ account that real `su` would allow can be refused here.
98
+ - `su -c` runs the command with `sh -c`, not with the target user's login shell.
99
+ - PowerShell is not handled; the extension targets Linux only.
100
+
101
+ These bypass the redirect, not polkit, which still decides whether the command
102
+ runs.
103
+
104
+ ## Requirements
105
+
106
+ `polkit` and a registered authentication agent. Desktop environments start one;
107
+ without one, `pkexec` fails instead of prompting.
108
+
109
+ ## Exit status
110
+
111
+ | Status | Meaning |
112
+ |--------|----------------------------------------------|
113
+ | 126 | the polkit dialog was dismissed |
114
+ | 127 | not authorized, or the program could not run |
115
+ | 64 | the extension refused the invocation |
116
+
117
+ Messages are in English.
118
+
119
+ ## Development
120
+
121
+ `npm install && npm run check` type-checks against the pinned
122
+ `@earendil-works/pi-coding-agent` 1.4.0, the API Pi serves to Node
123
+ extensions.
124
+
125
+ ## License
126
+
127
+ MIT
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./polkit-redirect"
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "pi-polkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Redirect sudo, doas and su to pkexec in Pi via PATH shims, so the desktop polkit agent handles authentication",
5
+ "type": "module",
6
+ "pi": {
7
+ "extensions": ["./index.ts"]
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "polkit-redirect.ts",
12
+ "polkit-shims.ts",
13
+ "CHANGELOG.md"
14
+ ],
15
+ "scripts": {
16
+ "check": "tsc -p tsconfig.json --noEmit",
17
+ "prepublishOnly": "npm run check"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-coding-agent": "*"
21
+ },
22
+ "devDependencies": {
23
+ "@earendil-works/pi-coding-agent": "1.4.0",
24
+ "@types/node": "^24.0.0",
25
+ "typescript": "^5.5.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=22.19.0"
29
+ },
30
+ "keywords": ["pi-package", "pi-extension", "sudo", "doas", "su", "pkexec", "polkit", "security"],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/lenitain/pi-polkit.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/lenitain/pi-polkit/issues"
38
+ }
39
+ }
@@ -0,0 +1,45 @@
1
+ import type { BashOperations, ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent"
3
+ import { polkitShimsPathExportLine, removePolkitShims, withPolkitShimsOnPath } from "./polkit-shims"
4
+
5
+ /**
6
+ * Redirects sudo, doas and su -c to pkexec inside Pi, so authorisation goes
7
+ * through the desktop's polkit agent (KDE, GNOME, …) instead of a terminal the
8
+ * shell tools do not have. Applies to the `bash` tool and to `!` user
9
+ * commands; `sudo` in other terminals is unchanged.
10
+ *
11
+ * The redirect is a PATH prepend, not a command rewrite: the shims in
12
+ * polkit-shims.ts stand in for the sudo/doas/su binaries, and the shell's own
13
+ * name resolution picks them up for `sudo`, `$(sudo …)`, `xargs sudo`,
14
+ * `FOO=1 sudo` and nested scripts alike. Nothing parses the command string,
15
+ * and nothing parses pkexec's output: the shims hand pkexec's exit status
16
+ * (126 dismissed, 127 not authorized) back to the caller unchanged.
17
+ */
18
+ export default function polkitRedirect(pi: ExtensionAPI): void {
19
+ // The bash tool spawns with a prepared environment, so the shims reach it as
20
+ // a PATH prepend. Registering the built-in's own definition under its own
21
+ // name replaces it and keeps its prompt metadata and renderers.
22
+ pi.registerTool(
23
+ createBashToolDefinition(process.cwd(), {
24
+ spawnHook: ({ command, cwd, env }) => ({ command, cwd, env: withPolkitShimsOnPath(env) }),
25
+ }),
26
+ )
27
+
28
+ pi.on("user_bash", () => ({ operations: polkitShimsUserBashOperations() }))
29
+
30
+ // The shims are session-scoped; the next session recreates them lazily.
31
+ pi.on("session_shutdown", () => removePolkitShims())
32
+ }
33
+
34
+ /**
35
+ * Local shell operations whose commands resolve sudo and su through the shims. The
36
+ * `!` user commands run through pi's own bash backend with no prepared
37
+ * environment, so the PATH prepend happens as the first command line; the
38
+ * exit status and output of the command itself are unchanged.
39
+ */
40
+ function polkitShimsUserBashOperations(): BashOperations {
41
+ const local = createLocalBashOperations()
42
+ return {
43
+ exec: (command, cwd, options) => local.exec(`${polkitShimsPathExportLine()}\n${command}`, cwd, options),
44
+ }
45
+ }
@@ -0,0 +1,371 @@
1
+ import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { isAbsolute, join } from "node:path"
4
+
5
+ /**
6
+ * Exit status every shim uses for an invocation pkexec cannot express
7
+ * (EX_USAGE). Distinct from pkexec's own 126 (dismissed) and 127 (not
8
+ * authorized or failed) so callers can tell the two apart without reading
9
+ * any output.
10
+ */
11
+ export const SHIM_USAGE_EXIT = 64
12
+
13
+ /**
14
+ * The sudo shim. Lives on PATH ahead of the real sudo, so the shell — not
15
+ * this extension — decides that `sudo` means this file. It forwards to pkexec
16
+ * and never parses what pkexec prints: the exit status is the whole result.
17
+ *
18
+ * Only options with an exact pkexec equivalent are translated. Everything
19
+ * else is refused with an actionable message rather than forwarded, because
20
+ * pkexec would reject it with a message that does not say what to do instead.
21
+ */
22
+ const SUDO_SHIM = `#!/bin/sh
23
+ # pi-polkit: sudo shim. Routes sudo to pkexec so polkit can authorize it.
24
+ # Generated at extension startup; do not edit.
25
+
26
+ fail() {
27
+ printf 'pi-polkit: %s\\n' "$1" >&2
28
+ exit ${SHIM_USAGE_EXIT}
29
+ }
30
+
31
+ command -v pkexec >/dev/null 2>&1 || fail 'pkexec is not installed; install polkit'
32
+
33
+ user=
34
+ while [ $# -gt 0 ]; do
35
+ case $1 in
36
+ --)
37
+ shift
38
+ break
39
+ ;;
40
+ -u)
41
+ [ $# -ge 2 ] || fail 'sudo -u needs a username: -u USER PROGRAM'
42
+ user=$2
43
+ shift 2
44
+ ;;
45
+ --user)
46
+ [ $# -ge 2 ] || fail 'sudo --user needs a username: --user USER PROGRAM'
47
+ user=$2
48
+ shift 2
49
+ ;;
50
+ -u?*)
51
+ user=\${1#-u}
52
+ [ -n "$user" ] || fail 'sudo -u needs a username: -u USER PROGRAM'
53
+ shift
54
+ ;;
55
+ --user=*)
56
+ user=\${1#--user=}
57
+ [ -n "$user" ] || fail 'sudo --user needs a username: --user USER PROGRAM'
58
+ shift
59
+ ;;
60
+ -n | --non-interactive)
61
+ fail 'sudo -n is not supported: pkexec has no non-interactive mode, polkit decides whether authentication is needed. Drop -n.'
62
+ ;;
63
+ -v | --validate | -k | --reset-timestamp | -K | --remove-timestamp)
64
+ fail 'sudo credential caching does not exist under polkit; drop -v/-k.'
65
+ ;;
66
+ -l | --list)
67
+ fail 'sudo -l is not available under polkit; inspect policy with pkaction --verbose.'
68
+ ;;
69
+ -e | --edit)
70
+ fail 'sudoedit is not supported; write a temp file and run pkexec install or pkexec cp on it.'
71
+ ;;
72
+ -i | --login | -s | --shell)
73
+ fail 'sudo -i/-s opens a login shell; run pkexec bash instead.'
74
+ ;;
75
+ -S | --stdin)
76
+ fail 'sudo -S reads the password from stdin; pkexec authenticates through the polkit agent. Drop -S.'
77
+ ;;
78
+ -A | --askpass)
79
+ fail 'SUDO_ASKPASS does not apply to pkexec; drop -A.'
80
+ ;;
81
+ -b | --background)
82
+ fail 'sudo -b backgrounds the command; append & instead.'
83
+ ;;
84
+ -E | --preserve-env | --preserve-env=* | -H | --set-home | -P | --preserve-groups | -N | --no-update)
85
+ fail 'pkexec deliberately sanitizes the environment; drop -E/-H/-P.'
86
+ ;;
87
+ -?*)
88
+ fail "option has no pkexec equivalent: $1 (pkexec accepts --user, --keep-cwd, --disable-internal-agent)"
89
+ ;;
90
+ *)
91
+ break
92
+ ;;
93
+ esac
94
+ done
95
+
96
+ # pkexec is silent when the dialog is dismissed (status 126), so say what the
97
+ # status means. Every other failure path already prints its own reason.
98
+ run_pkexec() {
99
+ pkexec "$@"
100
+ status=$?
101
+ if [ $status -eq 126 ]; then
102
+ printf 'pi-polkit: %s\n' 'the polkit authentication dialog was dismissed' >&2
103
+ fi
104
+ exit $status
105
+ }
106
+
107
+ if [ -n "$user" ]; then
108
+ run_pkexec --user "$user" "$@"
109
+ fi
110
+ run_pkexec "$@"
111
+ `
112
+
113
+ /** The doas shim: same contract, doas option grammar. */
114
+ const DOAS_SHIM = `#!/bin/sh
115
+ # pi-polkit: doas shim. Routes doas to pkexec so polkit can authorize it.
116
+ # Generated at extension startup; do not edit.
117
+
118
+ fail() {
119
+ printf 'pi-polkit: %s\\n' "$1" >&2
120
+ exit ${SHIM_USAGE_EXIT}
121
+ }
122
+
123
+ command -v pkexec >/dev/null 2>&1 || fail 'pkexec is not installed; install polkit'
124
+
125
+ user=
126
+ while [ $# -gt 0 ]; do
127
+ case $1 in
128
+ --)
129
+ shift
130
+ break
131
+ ;;
132
+ -u)
133
+ [ $# -ge 2 ] || fail 'doas -u needs a username: -u USER PROGRAM'
134
+ user=$2
135
+ shift 2
136
+ ;;
137
+ -u?*)
138
+ user=\${1#-u}
139
+ [ -n "$user" ] || fail 'doas -u needs a username: -u USER PROGRAM'
140
+ shift
141
+ ;;
142
+ -n)
143
+ fail 'doas -n is not supported: pkexec has no non-interactive mode. Drop -n.'
144
+ ;;
145
+ -s)
146
+ fail 'doas -s opens a shell; run pkexec bash instead.'
147
+ ;;
148
+ -C | -a | -L)
149
+ fail "option has no pkexec equivalent: $1"
150
+ ;;
151
+ -?*)
152
+ fail "unknown doas option: $1"
153
+ ;;
154
+ *)
155
+ break
156
+ ;;
157
+ esac
158
+ done
159
+
160
+ # pkexec is silent when the dialog is dismissed (status 126), so say what the
161
+ # status means. Every other failure path already prints its own reason.
162
+ run_pkexec() {
163
+ pkexec "$@"
164
+ status=$?
165
+ if [ $status -eq 126 ]; then
166
+ printf 'pi-polkit: %s\n' 'the polkit authentication dialog was dismissed' >&2
167
+ fi
168
+ exit $status
169
+ }
170
+
171
+ if [ -n "$user" ]; then
172
+ run_pkexec --user "$user" "$@"
173
+ fi
174
+ run_pkexec "$@"
175
+ `
176
+
177
+ /**
178
+ * The su shim. `su -c` is the only form pkexec can express exactly: a command
179
+ * run as the target user through a shell, in the current directory. Every
180
+ * other form asks for a login shell, an environment, a shell binary, a group
181
+ * or a terminal, none of which pkexec can honour, so it is refused instead.
182
+ */
183
+ const SU_SHIM = `#!/bin/sh
184
+ # pi-polkit: su shim. Routes su -c to pkexec so polkit can authorize it.
185
+ # Generated at extension startup; do not edit.
186
+
187
+ fail() {
188
+ printf 'pi-polkit: %s\\n' "$1" >&2
189
+ exit ${SHIM_USAGE_EXIT}
190
+ }
191
+
192
+ command -v pkexec >/dev/null 2>&1 || fail 'pkexec is not installed; install polkit'
193
+
194
+ user=
195
+ cmd=
196
+ has_cmd=
197
+ while [ $# -gt 0 ]; do
198
+ case $1 in
199
+ --)
200
+ shift
201
+ break
202
+ ;;
203
+ -c)
204
+ [ $# -ge 2 ] || fail 'su -c needs a command: su -c COMMAND [USER]'
205
+ cmd=$2
206
+ has_cmd=1
207
+ shift 2
208
+ ;;
209
+ -c?*)
210
+ cmd=\${1#-c}
211
+ has_cmd=1
212
+ shift
213
+ ;;
214
+ --command)
215
+ [ $# -ge 2 ] || fail 'su --command needs a command: su --command COMMAND [USER]'
216
+ cmd=$2
217
+ has_cmd=1
218
+ shift 2
219
+ ;;
220
+ --command=*)
221
+ cmd=\${1#--command=}
222
+ has_cmd=1
223
+ shift
224
+ ;;
225
+ - | -l | --login)
226
+ fail 'su starts a login shell; run pkexec bash instead.'
227
+ ;;
228
+ -s | --shell | -s?* | --shell=*)
229
+ fail 'su -s picks the shell; name the shell in the command: pkexec sh -c ...'
230
+ ;;
231
+ -m | -p | --preserve-environment)
232
+ fail 'pkexec deliberately sanitizes the environment; drop -m/-p.'
233
+ ;;
234
+ -g | -G | --group | --supp-group | -g?* | -G?* | --group=* | --supp-group=*)
235
+ fail 'su -g/-G switches groups; pkexec can only switch users.'
236
+ ;;
237
+ -w | --whitelist-environment | -w?* | --whitelist-environment=*)
238
+ fail 'su -w whitelists environment variables; pkexec sanitizes the environment.'
239
+ ;;
240
+ -P | --pty)
241
+ fail 'su --pty asks for a terminal; the bash tool has none.'
242
+ ;;
243
+ -f | --fast)
244
+ fail 'su -f is a shell flag; pass it to the shell in the command instead.'
245
+ ;;
246
+ --session-command | --session-command=*)
247
+ fail 'su --session-command keeps the session; pkexec always starts a new process.'
248
+ ;;
249
+ -h | --help | -V | --version)
250
+ fail "option has no pkexec equivalent: $1"
251
+ ;;
252
+ -?*)
253
+ fail "unknown su option: $1"
254
+ ;;
255
+ *)
256
+ [ -z "$user" ] || fail 'su passes arguments after the user name to the shell; use su -c COMMAND USER.'
257
+ user=$1
258
+ shift
259
+ ;;
260
+ esac
261
+ done
262
+
263
+ # Operands after -- are the same user-then-nothing sequence.
264
+ while [ $# -gt 0 ]; do
265
+ [ -z "$user" ] || fail 'su passes arguments after the user name to the shell; use su -c COMMAND USER.'
266
+ user=$1
267
+ shift
268
+ done
269
+
270
+ [ -n "$user" ] || user=root
271
+ [ -n "$has_cmd" ] || fail 'su without -c opens an interactive shell; run pkexec bash instead.'
272
+
273
+ # su keeps the working directory unless it is a login shell, and --keep-cwd is
274
+ # how pkexec does the same. pkexec is silent when the dialog is dismissed
275
+ # (status 126), so say what the status means.
276
+ pkexec --keep-cwd --user "$user" sh -c "$cmd"
277
+ status=$?
278
+ if [ $status -eq 126 ]; then
279
+ printf 'pi-polkit: %s\\n' 'the polkit authentication dialog was dismissed' >&2
280
+ fi
281
+ exit $status
282
+ `
283
+
284
+ /** sudoedit edits through sudo's own temporary-file flow; polkit has none. */
285
+ const SUDOEDIT_SHIM = `#!/bin/sh
286
+ printf 'pi-polkit: sudoedit is not supported; write a temp file and run pkexec install or pkexec cp on it.\\n' >&2
287
+ exit ${SHIM_USAGE_EXIT}
288
+ `
289
+
290
+ /** visudo needs an interactive editor on a terminal the shell tool does not have. */
291
+ const VISUDO_SHIM = `#!/bin/sh
292
+ printf 'pi-polkit: visudo needs an interactive terminal; validate a candidate file with pkexec visudo -c -f FILE instead.\\n' >&2
293
+ exit ${SHIM_USAGE_EXIT}
294
+ `
295
+
296
+ /** File name → script body. Names must match the commands being redirected. */
297
+ const POLKIT_SHIM_SCRIPTS: Record<string, string> = {
298
+ sudo: SUDO_SHIM,
299
+ doas: DOAS_SHIM,
300
+ su: SU_SHIM,
301
+ sudoedit: SUDOEDIT_SHIM,
302
+ visudo: VISUDO_SHIM,
303
+ }
304
+
305
+ /** Shim directory of the running extension; created on first use, cleared by removePolkitShims. */
306
+ let shimDirectory: string | undefined
307
+
308
+ /**
309
+ * Returns the shim directory, writing the stand-in scripts on first use.
310
+ * Creation is lazy so factory-only invocations (runs that never start a
311
+ * session) leave nothing behind.
312
+ */
313
+ export function ensurePolkitShims(): string {
314
+ shimDirectory ??= installPolkitShims()
315
+ return shimDirectory
316
+ }
317
+
318
+ /**
319
+ * Deletes the shim directory created by ensurePolkitShims. Idempotent, and the
320
+ * next use recreates the directory, so it can run at every session shutdown.
321
+ */
322
+ export function removePolkitShims(): void {
323
+ if (!shimDirectory) return
324
+ rmSync(shimDirectory, { recursive: true, force: true })
325
+ shimDirectory = undefined
326
+ }
327
+
328
+ /**
329
+ * Returns the environment with the shim directory first on PATH, so the shell
330
+ * resolves sudo, doas and su to the shims instead of the system binaries. The
331
+ * prepend is idempotent: an environment that already carries the prefix is
332
+ * returned unchanged.
333
+ */
334
+ export function withPolkitShimsOnPath(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
335
+ const prefix = `${ensurePolkitShims()}:`
336
+ const path = env.PATH ?? ""
337
+ return path.startsWith(prefix) ? env : { ...env, PATH: prefix + path }
338
+ }
339
+
340
+ /**
341
+ * A shell line that puts the shim directory first on PATH, for execution paths
342
+ * that receive no prepared environment (the `!` user commands run through a
343
+ * shell, so the prepend happens as their first command line).
344
+ */
345
+ export function polkitShimsPathExportLine(): string {
346
+ return `export PATH="${ensurePolkitShims()}:$PATH"`
347
+ }
348
+
349
+ /**
350
+ * Writes the shim scripts into a fresh private directory and returns it. The
351
+ * directory is 0700 so no other local user can rewrite the scripts.
352
+ */
353
+ function installPolkitShims(): string {
354
+ const directory = mkdtempSync(join(shimBaseDirectory(), "pi-polkit-"))
355
+ for (const [name, script] of Object.entries(POLKIT_SHIM_SCRIPTS)) {
356
+ const file = join(directory, name)
357
+ writeFileSync(file, script)
358
+ // writeFileSync's mode is filtered by umask, so set it explicitly.
359
+ chmodSync(file, 0o755)
360
+ }
361
+ return directory
362
+ }
363
+
364
+ /**
365
+ * Where shim directories live. Prefers the login session's runtime directory,
366
+ * which the system clears on logout, over /tmp which keeps leftovers.
367
+ */
368
+ function shimBaseDirectory(): string {
369
+ const runtime = process.env.XDG_RUNTIME_DIR
370
+ return runtime && isAbsolute(runtime) && existsSync(runtime) ? runtime : tmpdir()
371
+ }