pi-polkit 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,36 @@
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.0] - 2026-11-01
9
+
10
+ ### Added
11
+
12
+ - `polkit-shims.ts`: `sudo`, `doas`, `sudoedit` and `visudo` scripts written to
13
+ a private `0700` directory under `$XDG_RUNTIME_DIR` (falling back to `/tmp`)
14
+ on first use and removed at session shutdown. `sudo` and `doas` forward to
15
+ `pkexec`; `sudoedit` and `visudo` refuse, because polkit has no equivalent
16
+ flow.
17
+ - Deterministic option contract in the shims: `-u USER`, `-uUSER`, `--user USER`
18
+ and `--user=USER` become `pkexec --user USER`, `--` ends option parsing, and
19
+ every other option is refused with exit status `64` and a message naming the
20
+ alternative (`-n` → polkit decides, `-i`/`-s` → `pkexec bash`, `-E`/`-H`/`-P`
21
+ → pkexec sanitizes the environment, `-b` → append `&`). `pkexec` would
22
+ otherwise reject them with `Cannot run program`, which says nothing about what
23
+ to do next.
24
+ - `polkit-redirect.ts`: the Pi extension factory. The `bash` tool is
25
+ replaced by the built-in's own definition with a
26
+ `spawnHook` that prepends the shim directory to `PATH`, and `!` user commands
27
+ run through `createLocalBashOperations` with the same prepend as their first
28
+ command line. Command text is never rewritten.
29
+ - Authentication outcomes are reported from exit status alone (`126` dismissed,
30
+ `127` not authorized, `64` refused invocation); the shims print one line
31
+ explaining status `126`, the only pkexec failure path that is silent.
32
+ - `SHIM_USAGE_EXIT` (64) documents that the refusal status is the shim's own
33
+ and cannot be confused with pkexec's `126`/`127`.
34
+ - Engineering scaffolding: `npm run check` type-checks against the pinned
35
+ `@earendil-works/pi-coding-agent` 1.4.0, and GitHub Actions CI runs it on
36
+ 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,99 @@
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` and `doas` 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
+ ## Installation
9
+
10
+ Install as a Pi package (published on npm):
11
+
12
+ ```sh
13
+ pi install npm:pi-polkit
14
+ ```
15
+
16
+ Or copy or symlink this directory into Pi's extension location:
17
+
18
+ ```sh
19
+ cp -r pi-polkit ~/.pi/agent/extensions/pi-polkit
20
+ ```
21
+
22
+ For a quick test without installing, pass the directory to Pi:
23
+
24
+ ```sh
25
+ pi -e ./pi-polkit
26
+ ```
27
+
28
+ ## Behavior
29
+
30
+ | Command | Result |
31
+ |------------------------|---------------------------------------------|
32
+ | `sudo apt install x` | polkit dialog, then runs as root |
33
+ | `sudo -u bob …` | as above, as `bob` |
34
+ | `cat x \| sudo tee y` | dialog for `tee` |
35
+ | `sudo -n …` | refused: pkexec has no non-interactive mode |
36
+ | `sudo -i`, `-s`, `-b` | refused, with the equivalent command |
37
+ | `sudoedit`, `visudo` | refused, with the alternative |
38
+ | `pkexec …` | passed through |
39
+
40
+ Refused invocations exit with status 64 and one line naming the alternative.
41
+
42
+ ## How it works
43
+
44
+ `sudo` and `doas` stand-ins are placed first on `PATH`, so the shell resolves
45
+ those instead of the system binaries, and they forward the command to
46
+ `pkexec`. Command text is never rewritten, so quoting, pipes and subshells need
47
+ no special handling.
48
+
49
+ Two wiring paths put the stand-ins on `PATH`, one per execution form:
50
+
51
+ - The `bash` tool is replaced by the built-in's own definition with a
52
+ `spawnHook` that prepends the shim directory to the spawned environment.
53
+ - `!` user commands run through Pi's local bash backend with no prepared
54
+ environment, so their wrapper prepends `export PATH=…` as the first command
55
+ line instead.
56
+
57
+ The shim directory is created lazily in `$XDG_RUNTIME_DIR` (falling back to
58
+ `/tmp`) as `0700` and removed at session shutdown.
59
+
60
+ ## Authorisation
61
+
62
+ `polkitd` decides whether a password is required. These commands always require
63
+ one; other actions may not, according to policy. The extension takes no part in
64
+ that decision.
65
+
66
+ ## Limitations
67
+
68
+ - Absolute paths (`/usr/bin/sudo`) and relative ones (`./sudo`) bypass `PATH`.
69
+ - Commands that reset `PATH` (`env -i`, `PATH=/usr/bin …`) bypass it as well.
70
+ - Programs that invoke `sudo` with a scrubbed environment bypass it.
71
+ - PowerShell is not handled; the extension targets Linux only.
72
+
73
+ These bypass the redirect, not polkit, which still decides whether the command
74
+ runs.
75
+
76
+ ## Requirements
77
+
78
+ `polkit` and a registered authentication agent. Desktop environments start one;
79
+ without one, `pkexec` fails instead of prompting.
80
+
81
+ ## Exit status
82
+
83
+ | Status | Meaning |
84
+ |--------|----------------------------------------------|
85
+ | 126 | the polkit dialog was dismissed |
86
+ | 127 | not authorized, or the program could not run |
87
+ | 64 | the extension refused the invocation |
88
+
89
+ Messages are in English.
90
+
91
+ ## Development
92
+
93
+ `npm install && npm run check` type-checks against the pinned
94
+ `@earendil-works/pi-coding-agent` 1.4.0, the API Pi serves to Node
95
+ extensions.
96
+
97
+ ## License
98
+
99
+ 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.0",
4
+ "description": "Redirect sudo/doas 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", "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 and doas 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 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 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,263 @@
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
+ /** sudoedit edits through sudo's own temporary-file flow; polkit has none. */
178
+ const SUDOEDIT_SHIM = `#!/bin/sh
179
+ printf 'pi-polkit: sudoedit is not supported; write a temp file and run pkexec install or pkexec cp on it.\\n' >&2
180
+ exit ${SHIM_USAGE_EXIT}
181
+ `
182
+
183
+ /** visudo needs an interactive editor on a terminal the shell tool does not have. */
184
+ const VISUDO_SHIM = `#!/bin/sh
185
+ printf 'pi-polkit: visudo needs an interactive terminal; validate a candidate file with pkexec visudo -c -f FILE instead.\\n' >&2
186
+ exit ${SHIM_USAGE_EXIT}
187
+ `
188
+
189
+ /** File name → script body. Names must match the commands being redirected. */
190
+ const POLKIT_SHIM_SCRIPTS: Record<string, string> = {
191
+ sudo: SUDO_SHIM,
192
+ doas: DOAS_SHIM,
193
+ sudoedit: SUDOEDIT_SHIM,
194
+ visudo: VISUDO_SHIM,
195
+ }
196
+
197
+ /** Shim directory of the running extension; created on first use, cleared by removePolkitShims. */
198
+ let shimDirectory: string | undefined
199
+
200
+ /**
201
+ * Returns the shim directory, writing the sudo/doas stand-in scripts on first
202
+ * use. Creation is lazy so factory-only invocations (runs that never start a
203
+ * session) leave nothing behind.
204
+ */
205
+ export function ensurePolkitShims(): string {
206
+ shimDirectory ??= installPolkitShims()
207
+ return shimDirectory
208
+ }
209
+
210
+ /**
211
+ * Deletes the shim directory created by ensurePolkitShims. Idempotent, and the
212
+ * next use recreates the directory, so it can run at every session shutdown.
213
+ */
214
+ export function removePolkitShims(): void {
215
+ if (!shimDirectory) return
216
+ rmSync(shimDirectory, { recursive: true, force: true })
217
+ shimDirectory = undefined
218
+ }
219
+
220
+ /**
221
+ * Returns the environment with the shim directory first on PATH, so the shell
222
+ * resolves sudo and doas to the shims instead of the system binaries. The
223
+ * prepend is idempotent: an environment that already carries the prefix is
224
+ * returned unchanged.
225
+ */
226
+ export function withPolkitShimsOnPath(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
227
+ const prefix = `${ensurePolkitShims()}:`
228
+ const path = env.PATH ?? ""
229
+ return path.startsWith(prefix) ? env : { ...env, PATH: prefix + path }
230
+ }
231
+
232
+ /**
233
+ * A shell line that puts the shim directory first on PATH, for execution paths
234
+ * that receive no prepared environment (the `!` user commands run through a
235
+ * shell, so the prepend happens as their first command line).
236
+ */
237
+ export function polkitShimsPathExportLine(): string {
238
+ return `export PATH="${ensurePolkitShims()}:$PATH"`
239
+ }
240
+
241
+ /**
242
+ * Writes the shim scripts into a fresh private directory and returns it. The
243
+ * directory is 0700 so no other local user can rewrite the scripts.
244
+ */
245
+ function installPolkitShims(): string {
246
+ const directory = mkdtempSync(join(shimBaseDirectory(), "pi-polkit-"))
247
+ for (const [name, script] of Object.entries(POLKIT_SHIM_SCRIPTS)) {
248
+ const file = join(directory, name)
249
+ writeFileSync(file, script)
250
+ // writeFileSync's mode is filtered by umask, so set it explicitly.
251
+ chmodSync(file, 0o755)
252
+ }
253
+ return directory
254
+ }
255
+
256
+ /**
257
+ * Where shim directories live. Prefers the login session's runtime directory,
258
+ * which the system clears on logout, over /tmp which keeps leftovers.
259
+ */
260
+ function shimBaseDirectory(): string {
261
+ const runtime = process.env.XDG_RUNTIME_DIR
262
+ return runtime && isAbsolute(runtime) && existsSync(runtime) ? runtime : tmpdir()
263
+ }