sshfc 0.1.0__tar.gz

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.
sshfc-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen T. Jibson
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.
sshfc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,312 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshfc
3
+ Version: 0.1.0
4
+ Summary: Force command validator for SSH.
5
+ Author: Steve Jibson
6
+ Author-email: Steve Jibson <steve@jibson.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.12
16
+ Project-URL: Repository, https://github.com/toadstule/sshfc
17
+ Description-Content-Type: text/markdown
18
+
19
+ # sshfc
20
+
21
+ A command validator for SSH's `ForceCommand`. sshfc lets an SSH user (a person or an agent) run only an explicit
22
+ allowlist of commands, each with an explicit allowlist of arguments. Anything else is rejected with a short message
23
+ explaining why and what is allowed.
24
+
25
+ The allowlist is a **policy**: one or more TOML files in a directory, `/etc/sshfc/commands.d/` by default. sshfc ships
26
+ sample policy files you can drop in as they are or adapt.
27
+
28
+ > **Status: proof of concept.** Try a deployment with `--dry-run` first (see [Deploying](#deploying)).
29
+
30
+ ## Quick start
31
+
32
+ ```console
33
+ $ sshfc --samples # list the shipped sample policy files
34
+ $ sudo install -d -m 755 /etc/sshfc/commands.d
35
+ $ sshfc --samples podman-readonly | sudo tee /etc/sshfc/commands.d/podman-readonly.toml >/dev/null
36
+ $ sudo sshfc --check # check the policy and run its examples
37
+ $ sshfc --dry-run -c 'podman ps' # see what a command would run
38
+ Would run: /usr/bin/podman ps
39
+ ```
40
+
41
+ Then point an SSH key at sshfc (see [Deploying](#deploying)).
42
+
43
+ ## Running sshfc
44
+
45
+ ```
46
+ sshfc [--config PATH] [--dry-run] [-c COMMAND] [--key-id LABEL] [--insecure-config]
47
+ sshfc --check [--config PATH] [--insecure-config]
48
+ sshfc --samples [NAME]
49
+ ```
50
+
51
+ | Invocation | Command comes from | Action |
52
+ |---|---|---|
53
+ | `sshfc` | `$SSH_ORIGINAL_COMMAND` | Run it. This is the production mode. |
54
+ | `sshfc --dry-run` | `$SSH_ORIGINAL_COMMAND` | Print `Would run: ...` and run nothing. |
55
+ | `sshfc -c 'podman ps'` | `-c` | Run it. For local testing. |
56
+ | `sshfc --dry-run -c 'podman ps'` | `-c` | Print `Would run: ...` and run nothing. |
57
+ | `sshfc --check` | | Check the policy, run every example, report problems, and exit 1 if there are any. |
58
+ | `sshfc --samples [NAME]` | | List the sample policy files, or print one. |
59
+
60
+ - **Your options are separate from the SSH user's input.** sshfc's own options come from `command="..."` in
61
+ `authorized_keys` (or `ForceCommand` in `sshd_config`), so the admin controls them, never the SSH user.
62
+ - **`--config PATH`** is a policy directory or a single policy file. Different keys can use different policies.
63
+ - **`-c` takes a single string,** the same form as `SSH_ORIGINAL_COMMAND`, so local tests take the same path as
64
+ production. If both are present, `-c` wins.
65
+ - **A login with no command is rejected.** If neither is present, as with an interactive `ssh host`, sshfc rejects it
66
+ and lists the available commands.
67
+ - **Commands run without a shell,** with stdin, stdout and stderr passed through. sshfc exits with the program's
68
+ status, `128 + N` if it was killed by signal N, or 127/126 if the program can't be found or run.
69
+ - **The environment is minimal.** `PATH` is fixed to `/usr/bin:/bin`. `HOME`, `USER` and `LOGNAME` come from the
70
+ passwd entry. Only `LANG`, `LC_ALL`, `TERM` and `XDG_RUNTIME_DIR` (needed by rootless podman) are passed through.
71
+
72
+ ### Policy security
73
+
74
+ - **Ownership:** the policy directory, every policy file, the target of any symlink, and every directory above them
75
+ must be owned by root and not writable by group or others. A world-writable ancestor is allowed only if it has the
76
+ sticky bit, like `/tmp`. Otherwise the SSH account could change its own policy.
77
+ - **`--insecure-config`** skips these checks so you can try a policy from a checkout. It's accepted only with
78
+ `--dry-run` or `--check`, so nothing can run under an unchecked policy.
79
+ - **Fail closed:** if the policy is missing, insecure or malformed, every command is rejected. The SSH user sees only
80
+ `SSH command rejected: sshfc is misconfigured; contact the administrator`. The details go to the audit log as a
81
+ `config_error` record. Run `sshfc --check` to see them.
82
+
83
+ ### Deploying
84
+
85
+ 1. Create a dedicated, low-privilege account.
86
+ 2. Install a policy in `/etc/sshfc/commands.d/` (see [Quick start](#quick-start)), and run `sudo sshfc --check`.
87
+ 3. In the account's `~/.ssh/authorized_keys`, prefix each key with:
88
+
89
+ ```
90
+ command="/path/to/sshfc --dry-run",restrict ssh-ed25519 AAAA... user@example
91
+ ```
92
+
93
+ 4. Try commands through real SSH (`ssh host podman ps`) and check the `Would run:` output.
94
+ 5. Remove `--dry-run`.
95
+
96
+ `restrict` turns off port, agent and X11 forwarding, PTY allocation, and `~/.ssh/rc`.
97
+
98
+ If several people's keys share the account, give each key its own label so audit records show whose key was used. A
99
+ key can also get its own policy:
100
+
101
+ ```
102
+ command="/path/to/sshfc --key-id alice",restrict ssh-ed25519 AAAA... alice@example
103
+ command="/path/to/sshfc --key-id ci --config /etc/sshfc/ci.d",restrict ssh-ed25519 AAAA... ci@example
104
+ ```
105
+
106
+ ### Audit log
107
+
108
+ Every invocation is logged to syslog with the ident `sshfc`, under facility `authpriv` (`auth` where `authpriv`
109
+ isn't available). That puts the records next to sshd's own: `/var/log/auth.log` or `/var/log/secure`, or
110
+ `journalctl -t sshfc`. Each record is one JSON line:
111
+
112
+ | Event | When | Priority | Fields (besides `user`, `key_id`, `client`) |
113
+ |---|---|---|---|
114
+ | `rejected` | The command isn't allowed. A login with no command has `source` `none`. | warning | `source`, `command`, `reason`, `dry_run` |
115
+ | `accepted` | Before the command runs, so there's a record even if it never finishes. Also logged for dry runs. | info | `source`, `command`, `argv`, `policy`, `dry_run` |
116
+ | `exited` | After the command finishes. | info | `argv`, `status`, `duration_s` |
117
+ | `config_error` | The policy couldn't be loaded, so the command was rejected without being checked. | error | `source`, `command`, `error`, `dry_run` |
118
+
119
+ ```json
120
+ {"event":"accepted","user":"svc","key_id":"alice","client":"192.0.2.7 51234","source":"SSH_ORIGINAL_COMMAND","command":"podman ps --format='{{ .ID }}'","argv":["/usr/bin/podman","ps","--format","{{.ID}}"],"policy":"podman-readonly.toml","dry_run":false}
121
+ ```
122
+
123
+ - **`command` vs `argv`:** `command` is exactly what the SSH user sent, and `argv` is what sshfc ran.
124
+ - **`policy`:** the policy file (or files) that define the matched command.
125
+ - **`client`:** the client address and port from `SSH_CONNECTION`.
126
+ - **Log injection:** records are JSON with every non-ASCII and control character escaped, so a crafted command can't
127
+ split a record or forge a new one.
128
+
129
+ ## How it works
130
+
131
+ sshfc builds a strict parser from the policy and:
132
+
133
+ 1. Splits the requested command line using shell rules (`shlex`). Nothing is passed to a shell.
134
+ 2. Parses it against the allowed commands. Unknown commands, subcommands, options and arguments are rejected.
135
+ Abbreviated options are rejected too, so `--for` is not treated as `--format`.
136
+ 3. Runs every option and positional value through a validator, such as a name pattern or a restricted Go template.
137
+ 4. **Rebuilds** the argv from the parsed result, not from the original string. The argv is the absolute path of the
138
+ program, then the subcommand path, then the options in canonical form, then `--` and the positionals.
139
+
140
+ Because of step 4, sshfc runs exactly what it validated. A positional value can never be read as an option by the
141
+ target program, and a user can't add arguments the policy doesn't allow.
142
+
143
+ sshfc also never adds arguments the user didn't type. What runs is what they asked for, in canonical form, so
144
+ commands behave the way users of the real tool expect. When a flag must always be present, the policy makes it
145
+ **required** and the user types it (see `podman stats --no-stream` below).
146
+
147
+ ## What SSH users see
148
+
149
+ With sshfc deployed with `--dry-run` and the sample policies:
150
+
151
+ ```console
152
+ $ ssh host "podman ps --format '{{ .ID }} {{.Image}}'"
153
+ Would run: /usr/bin/podman ps --format '{{.ID}} {{.Image}}'
154
+
155
+ $ ssh host
156
+ SSH command rejected: the following arguments are required: command (choose from hostname, podman, pwd, whoami)
157
+
158
+ $ ssh host podman pod
159
+ SSH command rejected: podman pod -- the following arguments are required: subcommand (choose from inspect, ps)
160
+
161
+ $ ssh host "podman stop 'web;id'"
162
+ SSH command rejected: podman stop -- argument container: invalid value: 'web;id' (expected a container, image, or pod name or ID)
163
+
164
+ $ ssh host "podman ps --format '{{.Command}}'"
165
+ SSH command rejected: podman ps -- argument --format: field not allowed: 'Command' (choose from CreatedAt, ...)
166
+ ```
167
+
168
+ Without `--dry-run`, an accepted command's output and exit status are the program's own.
169
+
170
+ Every rejection starts with `SSH command rejected:`, followed by the subcommand path where the error happened, and
171
+ exits with status 1. `-h`/`--help` works at every level and shows the available subcommands and options.
172
+
173
+ ## Writing a policy
174
+
175
+ A policy file has one `[commands."<path>"]` table per command, keyed by the full command path, so even deep trees
176
+ stay flat:
177
+
178
+ ```toml
179
+ # podman-readonly.toml (excerpt)
180
+ [commands.podman]
181
+ help = "Manage pods, containers and images."
182
+ run = "/usr/bin/podman"
183
+ reject = ["podman rm web-1"]
184
+
185
+ [commands."podman stop"]
186
+ help = "Stop a container."
187
+ positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
188
+ accept = [{ command = "podman stop web-1", argv = "/usr/bin/podman stop -- web-1" }]
189
+
190
+ [commands."podman stats"]
191
+ help = "Display container resource usage statistics."
192
+ flags."--no-stream" = { help = "Print one snapshot instead of a live stream (required).", required = true }
193
+
194
+ [commands."podman ps".options."--format"]
195
+ help = "Go template using only {{.Field}} placeholders."
196
+ go_template = ["ID", "Image", "Names", "Status"]
197
+ ```
198
+
199
+ Run `sshfc --samples podman-readonly` for the complete file. After any change, run `sshfc --check`.
200
+
201
+ ### Several files in one directory
202
+
203
+ - **Which files are read:** only `*.toml` files whose names don't start with `.`, in sorted order. Editor backups
204
+ (`*~`) and package-manager leftovers (`.rpmnew`, `.dpkg-old`) are ignored.
205
+ - **No overrides:** each command path is defined by one file. The same path may appear in several files only if
206
+ every definition is identical, which is how independent files share a parent such as `podman`. A difference is an
207
+ error naming both files. Because nothing overrides anything, the merged policy doesn't depend on file order, and a
208
+ later file can't quietly change a definition reviewed in another.
209
+ - **Examples are combined:** `accept`/`reject` examples aren't part of the definition. They're combined, and
210
+ `sshfc --check` runs them all against the **merged** policy. If one file allows something another file's `reject`
211
+ examples expect to be rejected, the check fails.
212
+ - **Commands are listed alphabetically** at each level in help and error messages.
213
+
214
+ ### Command fields
215
+
216
+ | Field | Meaning |
217
+ |---|---|
218
+ | `help` | One-line description shown in help output and error messages. Required. |
219
+ | `run` | Absolute path of the program. Subcommands inherit it and their names are added to the argv, so `run = "/usr/bin/podman"` on `podman` makes `podman pod ps` run `/usr/bin/podman pod ps`. Every leaf command needs a `run` on itself or an ancestor. |
220
+ | `options` | Options that take a value: `options."--name" = { help = "...", <validator> }`. Rendered as `--name VALUE`, if given. Add `required = true` to make the user type it. |
221
+ | `flags` | Options without a value: `flags."--all" = { help = "..." }`. Rendered as `--all`, if given. Add `required = true` to make the user type it. |
222
+ | `positionals` | Required positional arguments, in order: `[{ name = "...", help = "...", <validator> }]`. Rendered after `--`. |
223
+ | `end_of_options` | Put `--` before positionals (default `true`). Turn it off only for programs that don't understand `--`. |
224
+ | `accept` | Full example command lines that must be accepted. Use `{ command = "...", argv = "..." }` to also check what would run. |
225
+ | `reject` | Full example command lines that must be rejected. |
226
+
227
+ Only leaf commands (commands without subcommands) can have `options`, `flags` or `positionals`. A command with
228
+ subcommands requires the user to choose one. Unknown keys are errors, so a typo can't be silently ignored.
229
+
230
+ Options and flags are always rendered in the order the policy lists them, whatever order the user typed them in.
231
+
232
+ A **required** option or flag must be typed. If it's missing, the error names it
233
+ (`SSH command rejected: podman stats -- the following arguments are required: --no-stream`), and `-h` shows its help.
234
+ Use it when a command is only safe or useful with a particular argument. For example, without `--no-stream`,
235
+ `podman stats` streams forever and the SSH session never ends. Put the reason in the help text, since that's what the
236
+ user sees.
237
+
238
+ ### Validators
239
+
240
+ Every option and positional needs exactly one validator. There is no "any string" default.
241
+
242
+ | Validator | Accepts |
243
+ |---|---|
244
+ | `validator = "container_name"` | A plain container, image or pod name or ID. Tags, digests, registry paths, and anything starting with `-` or `.` are rejected. |
245
+ | `pattern = "<regex>"`, `description = "..."` | Values that **fully** match the regex. `description` is shown to the user when a value is rejected. |
246
+ | `go_template = ["Field", ...]` | A restricted Go template for `--format`-style options (details below). The fields are added to the help text. |
247
+
248
+ `go_template` accepts:
249
+
250
+ - `{{.Field}}` placeholders, where `Field` must be in the list;
251
+ - literal text made of letters, digits, spaces, tabs and `:|,;=/._-`;
252
+ - an optional leading `table` keyword.
253
+
254
+ It rejects functions (`json`, `printf`), pipelines, nested fields, `range`/`if`, variables, comments and stray braces,
255
+ and enforces limits on length and placeholder count. The template sshfc runs is rebuilt from the parsed pieces, not
256
+ copied from the input.
257
+
258
+ When choosing which fields or values to allow, think about what they reveal. For example, the podman sample omits
259
+ `.Command` because container arguments often contain credentials.
260
+
261
+ ### Python API
262
+
263
+ The policy loader produces `sshfc.core.Command` specs, and `sshfc.core` can also be used directly to build, check
264
+ and parse specs in Python. See `src/sshfc/core/spec.py`.
265
+
266
+ ## Development
267
+
268
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
269
+
270
+ ```console
271
+ make dep # install dependencies
272
+ make check # format, lint (ruff), type-check (mypy --strict), and test (pytest)
273
+ make build # build the wheel
274
+ make install # editable install into the active virtual environment
275
+ make help # list all targets
276
+ ```
277
+
278
+ Try the samples from a checkout (they aren't root-owned there, hence `--insecure-config`):
279
+
280
+ ```console
281
+ uv run sshfc --check --insecure-config --config src/sshfc/samples
282
+ uv run sshfc --dry-run --insecure-config --config src/sshfc/samples -c "podman ps --format '{{.ID}}'"
283
+ ```
284
+
285
+ ### Project layout
286
+
287
+ ```
288
+ src/sshfc/
289
+ cli.py entry point: modes, input source, auditing
290
+ core/ infrastructure (public API: sshfc.core)
291
+ spec.py Command, Option, Flag, Positional
292
+ config.py TOML policy loading, merging, ownership checks
293
+ check.py sshfc --check: load the policy, run every example against it
294
+ parser.py strict parser built from specs; spec checks; argv rendering
295
+ validators.py Pattern, CONTAINER_NAME, GoTemplateFormat, and the named validators
296
+ executor.py runs the rendered argv: no shell, minimal environment
297
+ audit.py JSON audit records to syslog
298
+ samples/ sample policy files, shipped in the package (sshfc --samples)
299
+ tests/
300
+ test_parser.py parser, using made-up specs
301
+ test_config.py policy loading, merge rules, schema errors, ownership checks
302
+ test_check.py example checks, including one file widening another
303
+ test_samples.py every sample, alone and all together
304
+ test_validators.py validators
305
+ test_executor.py executor: environment, exit statuses, failures to start
306
+ test_audit.py audit record format and log-injection safety
307
+ test_cli.py modes, input source, config errors, and what gets audited
308
+ ```
309
+
310
+ ## License
311
+
312
+ MIT
sshfc-0.1.0/README.md ADDED
@@ -0,0 +1,294 @@
1
+ # sshfc
2
+
3
+ A command validator for SSH's `ForceCommand`. sshfc lets an SSH user (a person or an agent) run only an explicit
4
+ allowlist of commands, each with an explicit allowlist of arguments. Anything else is rejected with a short message
5
+ explaining why and what is allowed.
6
+
7
+ The allowlist is a **policy**: one or more TOML files in a directory, `/etc/sshfc/commands.d/` by default. sshfc ships
8
+ sample policy files you can drop in as they are or adapt.
9
+
10
+ > **Status: proof of concept.** Try a deployment with `--dry-run` first (see [Deploying](#deploying)).
11
+
12
+ ## Quick start
13
+
14
+ ```console
15
+ $ sshfc --samples # list the shipped sample policy files
16
+ $ sudo install -d -m 755 /etc/sshfc/commands.d
17
+ $ sshfc --samples podman-readonly | sudo tee /etc/sshfc/commands.d/podman-readonly.toml >/dev/null
18
+ $ sudo sshfc --check # check the policy and run its examples
19
+ $ sshfc --dry-run -c 'podman ps' # see what a command would run
20
+ Would run: /usr/bin/podman ps
21
+ ```
22
+
23
+ Then point an SSH key at sshfc (see [Deploying](#deploying)).
24
+
25
+ ## Running sshfc
26
+
27
+ ```
28
+ sshfc [--config PATH] [--dry-run] [-c COMMAND] [--key-id LABEL] [--insecure-config]
29
+ sshfc --check [--config PATH] [--insecure-config]
30
+ sshfc --samples [NAME]
31
+ ```
32
+
33
+ | Invocation | Command comes from | Action |
34
+ |---|---|---|
35
+ | `sshfc` | `$SSH_ORIGINAL_COMMAND` | Run it. This is the production mode. |
36
+ | `sshfc --dry-run` | `$SSH_ORIGINAL_COMMAND` | Print `Would run: ...` and run nothing. |
37
+ | `sshfc -c 'podman ps'` | `-c` | Run it. For local testing. |
38
+ | `sshfc --dry-run -c 'podman ps'` | `-c` | Print `Would run: ...` and run nothing. |
39
+ | `sshfc --check` | | Check the policy, run every example, report problems, and exit 1 if there are any. |
40
+ | `sshfc --samples [NAME]` | | List the sample policy files, or print one. |
41
+
42
+ - **Your options are separate from the SSH user's input.** sshfc's own options come from `command="..."` in
43
+ `authorized_keys` (or `ForceCommand` in `sshd_config`), so the admin controls them, never the SSH user.
44
+ - **`--config PATH`** is a policy directory or a single policy file. Different keys can use different policies.
45
+ - **`-c` takes a single string,** the same form as `SSH_ORIGINAL_COMMAND`, so local tests take the same path as
46
+ production. If both are present, `-c` wins.
47
+ - **A login with no command is rejected.** If neither is present, as with an interactive `ssh host`, sshfc rejects it
48
+ and lists the available commands.
49
+ - **Commands run without a shell,** with stdin, stdout and stderr passed through. sshfc exits with the program's
50
+ status, `128 + N` if it was killed by signal N, or 127/126 if the program can't be found or run.
51
+ - **The environment is minimal.** `PATH` is fixed to `/usr/bin:/bin`. `HOME`, `USER` and `LOGNAME` come from the
52
+ passwd entry. Only `LANG`, `LC_ALL`, `TERM` and `XDG_RUNTIME_DIR` (needed by rootless podman) are passed through.
53
+
54
+ ### Policy security
55
+
56
+ - **Ownership:** the policy directory, every policy file, the target of any symlink, and every directory above them
57
+ must be owned by root and not writable by group or others. A world-writable ancestor is allowed only if it has the
58
+ sticky bit, like `/tmp`. Otherwise the SSH account could change its own policy.
59
+ - **`--insecure-config`** skips these checks so you can try a policy from a checkout. It's accepted only with
60
+ `--dry-run` or `--check`, so nothing can run under an unchecked policy.
61
+ - **Fail closed:** if the policy is missing, insecure or malformed, every command is rejected. The SSH user sees only
62
+ `SSH command rejected: sshfc is misconfigured; contact the administrator`. The details go to the audit log as a
63
+ `config_error` record. Run `sshfc --check` to see them.
64
+
65
+ ### Deploying
66
+
67
+ 1. Create a dedicated, low-privilege account.
68
+ 2. Install a policy in `/etc/sshfc/commands.d/` (see [Quick start](#quick-start)), and run `sudo sshfc --check`.
69
+ 3. In the account's `~/.ssh/authorized_keys`, prefix each key with:
70
+
71
+ ```
72
+ command="/path/to/sshfc --dry-run",restrict ssh-ed25519 AAAA... user@example
73
+ ```
74
+
75
+ 4. Try commands through real SSH (`ssh host podman ps`) and check the `Would run:` output.
76
+ 5. Remove `--dry-run`.
77
+
78
+ `restrict` turns off port, agent and X11 forwarding, PTY allocation, and `~/.ssh/rc`.
79
+
80
+ If several people's keys share the account, give each key its own label so audit records show whose key was used. A
81
+ key can also get its own policy:
82
+
83
+ ```
84
+ command="/path/to/sshfc --key-id alice",restrict ssh-ed25519 AAAA... alice@example
85
+ command="/path/to/sshfc --key-id ci --config /etc/sshfc/ci.d",restrict ssh-ed25519 AAAA... ci@example
86
+ ```
87
+
88
+ ### Audit log
89
+
90
+ Every invocation is logged to syslog with the ident `sshfc`, under facility `authpriv` (`auth` where `authpriv`
91
+ isn't available). That puts the records next to sshd's own: `/var/log/auth.log` or `/var/log/secure`, or
92
+ `journalctl -t sshfc`. Each record is one JSON line:
93
+
94
+ | Event | When | Priority | Fields (besides `user`, `key_id`, `client`) |
95
+ |---|---|---|---|
96
+ | `rejected` | The command isn't allowed. A login with no command has `source` `none`. | warning | `source`, `command`, `reason`, `dry_run` |
97
+ | `accepted` | Before the command runs, so there's a record even if it never finishes. Also logged for dry runs. | info | `source`, `command`, `argv`, `policy`, `dry_run` |
98
+ | `exited` | After the command finishes. | info | `argv`, `status`, `duration_s` |
99
+ | `config_error` | The policy couldn't be loaded, so the command was rejected without being checked. | error | `source`, `command`, `error`, `dry_run` |
100
+
101
+ ```json
102
+ {"event":"accepted","user":"svc","key_id":"alice","client":"192.0.2.7 51234","source":"SSH_ORIGINAL_COMMAND","command":"podman ps --format='{{ .ID }}'","argv":["/usr/bin/podman","ps","--format","{{.ID}}"],"policy":"podman-readonly.toml","dry_run":false}
103
+ ```
104
+
105
+ - **`command` vs `argv`:** `command` is exactly what the SSH user sent, and `argv` is what sshfc ran.
106
+ - **`policy`:** the policy file (or files) that define the matched command.
107
+ - **`client`:** the client address and port from `SSH_CONNECTION`.
108
+ - **Log injection:** records are JSON with every non-ASCII and control character escaped, so a crafted command can't
109
+ split a record or forge a new one.
110
+
111
+ ## How it works
112
+
113
+ sshfc builds a strict parser from the policy and:
114
+
115
+ 1. Splits the requested command line using shell rules (`shlex`). Nothing is passed to a shell.
116
+ 2. Parses it against the allowed commands. Unknown commands, subcommands, options and arguments are rejected.
117
+ Abbreviated options are rejected too, so `--for` is not treated as `--format`.
118
+ 3. Runs every option and positional value through a validator, such as a name pattern or a restricted Go template.
119
+ 4. **Rebuilds** the argv from the parsed result, not from the original string. The argv is the absolute path of the
120
+ program, then the subcommand path, then the options in canonical form, then `--` and the positionals.
121
+
122
+ Because of step 4, sshfc runs exactly what it validated. A positional value can never be read as an option by the
123
+ target program, and a user can't add arguments the policy doesn't allow.
124
+
125
+ sshfc also never adds arguments the user didn't type. What runs is what they asked for, in canonical form, so
126
+ commands behave the way users of the real tool expect. When a flag must always be present, the policy makes it
127
+ **required** and the user types it (see `podman stats --no-stream` below).
128
+
129
+ ## What SSH users see
130
+
131
+ With sshfc deployed with `--dry-run` and the sample policies:
132
+
133
+ ```console
134
+ $ ssh host "podman ps --format '{{ .ID }} {{.Image}}'"
135
+ Would run: /usr/bin/podman ps --format '{{.ID}} {{.Image}}'
136
+
137
+ $ ssh host
138
+ SSH command rejected: the following arguments are required: command (choose from hostname, podman, pwd, whoami)
139
+
140
+ $ ssh host podman pod
141
+ SSH command rejected: podman pod -- the following arguments are required: subcommand (choose from inspect, ps)
142
+
143
+ $ ssh host "podman stop 'web;id'"
144
+ SSH command rejected: podman stop -- argument container: invalid value: 'web;id' (expected a container, image, or pod name or ID)
145
+
146
+ $ ssh host "podman ps --format '{{.Command}}'"
147
+ SSH command rejected: podman ps -- argument --format: field not allowed: 'Command' (choose from CreatedAt, ...)
148
+ ```
149
+
150
+ Without `--dry-run`, an accepted command's output and exit status are the program's own.
151
+
152
+ Every rejection starts with `SSH command rejected:`, followed by the subcommand path where the error happened, and
153
+ exits with status 1. `-h`/`--help` works at every level and shows the available subcommands and options.
154
+
155
+ ## Writing a policy
156
+
157
+ A policy file has one `[commands."<path>"]` table per command, keyed by the full command path, so even deep trees
158
+ stay flat:
159
+
160
+ ```toml
161
+ # podman-readonly.toml (excerpt)
162
+ [commands.podman]
163
+ help = "Manage pods, containers and images."
164
+ run = "/usr/bin/podman"
165
+ reject = ["podman rm web-1"]
166
+
167
+ [commands."podman stop"]
168
+ help = "Stop a container."
169
+ positionals = [{ name = "container", help = "Container name or ID.", validator = "container_name" }]
170
+ accept = [{ command = "podman stop web-1", argv = "/usr/bin/podman stop -- web-1" }]
171
+
172
+ [commands."podman stats"]
173
+ help = "Display container resource usage statistics."
174
+ flags."--no-stream" = { help = "Print one snapshot instead of a live stream (required).", required = true }
175
+
176
+ [commands."podman ps".options."--format"]
177
+ help = "Go template using only {{.Field}} placeholders."
178
+ go_template = ["ID", "Image", "Names", "Status"]
179
+ ```
180
+
181
+ Run `sshfc --samples podman-readonly` for the complete file. After any change, run `sshfc --check`.
182
+
183
+ ### Several files in one directory
184
+
185
+ - **Which files are read:** only `*.toml` files whose names don't start with `.`, in sorted order. Editor backups
186
+ (`*~`) and package-manager leftovers (`.rpmnew`, `.dpkg-old`) are ignored.
187
+ - **No overrides:** each command path is defined by one file. The same path may appear in several files only if
188
+ every definition is identical, which is how independent files share a parent such as `podman`. A difference is an
189
+ error naming both files. Because nothing overrides anything, the merged policy doesn't depend on file order, and a
190
+ later file can't quietly change a definition reviewed in another.
191
+ - **Examples are combined:** `accept`/`reject` examples aren't part of the definition. They're combined, and
192
+ `sshfc --check` runs them all against the **merged** policy. If one file allows something another file's `reject`
193
+ examples expect to be rejected, the check fails.
194
+ - **Commands are listed alphabetically** at each level in help and error messages.
195
+
196
+ ### Command fields
197
+
198
+ | Field | Meaning |
199
+ |---|---|
200
+ | `help` | One-line description shown in help output and error messages. Required. |
201
+ | `run` | Absolute path of the program. Subcommands inherit it and their names are added to the argv, so `run = "/usr/bin/podman"` on `podman` makes `podman pod ps` run `/usr/bin/podman pod ps`. Every leaf command needs a `run` on itself or an ancestor. |
202
+ | `options` | Options that take a value: `options."--name" = { help = "...", <validator> }`. Rendered as `--name VALUE`, if given. Add `required = true` to make the user type it. |
203
+ | `flags` | Options without a value: `flags."--all" = { help = "..." }`. Rendered as `--all`, if given. Add `required = true` to make the user type it. |
204
+ | `positionals` | Required positional arguments, in order: `[{ name = "...", help = "...", <validator> }]`. Rendered after `--`. |
205
+ | `end_of_options` | Put `--` before positionals (default `true`). Turn it off only for programs that don't understand `--`. |
206
+ | `accept` | Full example command lines that must be accepted. Use `{ command = "...", argv = "..." }` to also check what would run. |
207
+ | `reject` | Full example command lines that must be rejected. |
208
+
209
+ Only leaf commands (commands without subcommands) can have `options`, `flags` or `positionals`. A command with
210
+ subcommands requires the user to choose one. Unknown keys are errors, so a typo can't be silently ignored.
211
+
212
+ Options and flags are always rendered in the order the policy lists them, whatever order the user typed them in.
213
+
214
+ A **required** option or flag must be typed. If it's missing, the error names it
215
+ (`SSH command rejected: podman stats -- the following arguments are required: --no-stream`), and `-h` shows its help.
216
+ Use it when a command is only safe or useful with a particular argument. For example, without `--no-stream`,
217
+ `podman stats` streams forever and the SSH session never ends. Put the reason in the help text, since that's what the
218
+ user sees.
219
+
220
+ ### Validators
221
+
222
+ Every option and positional needs exactly one validator. There is no "any string" default.
223
+
224
+ | Validator | Accepts |
225
+ |---|---|
226
+ | `validator = "container_name"` | A plain container, image or pod name or ID. Tags, digests, registry paths, and anything starting with `-` or `.` are rejected. |
227
+ | `pattern = "<regex>"`, `description = "..."` | Values that **fully** match the regex. `description` is shown to the user when a value is rejected. |
228
+ | `go_template = ["Field", ...]` | A restricted Go template for `--format`-style options (details below). The fields are added to the help text. |
229
+
230
+ `go_template` accepts:
231
+
232
+ - `{{.Field}}` placeholders, where `Field` must be in the list;
233
+ - literal text made of letters, digits, spaces, tabs and `:|,;=/._-`;
234
+ - an optional leading `table` keyword.
235
+
236
+ It rejects functions (`json`, `printf`), pipelines, nested fields, `range`/`if`, variables, comments and stray braces,
237
+ and enforces limits on length and placeholder count. The template sshfc runs is rebuilt from the parsed pieces, not
238
+ copied from the input.
239
+
240
+ When choosing which fields or values to allow, think about what they reveal. For example, the podman sample omits
241
+ `.Command` because container arguments often contain credentials.
242
+
243
+ ### Python API
244
+
245
+ The policy loader produces `sshfc.core.Command` specs, and `sshfc.core` can also be used directly to build, check
246
+ and parse specs in Python. See `src/sshfc/core/spec.py`.
247
+
248
+ ## Development
249
+
250
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
251
+
252
+ ```console
253
+ make dep # install dependencies
254
+ make check # format, lint (ruff), type-check (mypy --strict), and test (pytest)
255
+ make build # build the wheel
256
+ make install # editable install into the active virtual environment
257
+ make help # list all targets
258
+ ```
259
+
260
+ Try the samples from a checkout (they aren't root-owned there, hence `--insecure-config`):
261
+
262
+ ```console
263
+ uv run sshfc --check --insecure-config --config src/sshfc/samples
264
+ uv run sshfc --dry-run --insecure-config --config src/sshfc/samples -c "podman ps --format '{{.ID}}'"
265
+ ```
266
+
267
+ ### Project layout
268
+
269
+ ```
270
+ src/sshfc/
271
+ cli.py entry point: modes, input source, auditing
272
+ core/ infrastructure (public API: sshfc.core)
273
+ spec.py Command, Option, Flag, Positional
274
+ config.py TOML policy loading, merging, ownership checks
275
+ check.py sshfc --check: load the policy, run every example against it
276
+ parser.py strict parser built from specs; spec checks; argv rendering
277
+ validators.py Pattern, CONTAINER_NAME, GoTemplateFormat, and the named validators
278
+ executor.py runs the rendered argv: no shell, minimal environment
279
+ audit.py JSON audit records to syslog
280
+ samples/ sample policy files, shipped in the package (sshfc --samples)
281
+ tests/
282
+ test_parser.py parser, using made-up specs
283
+ test_config.py policy loading, merge rules, schema errors, ownership checks
284
+ test_check.py example checks, including one file widening another
285
+ test_samples.py every sample, alone and all together
286
+ test_validators.py validators
287
+ test_executor.py executor: environment, exit statuses, failures to start
288
+ test_audit.py audit record format and log-injection safety
289
+ test_cli.py modes, input source, config errors, and what gets audited
290
+ ```
291
+
292
+ ## License
293
+
294
+ MIT