@knowttl/ssh-axi 0.1.0 → 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/README.md CHANGED
@@ -1,93 +1,80 @@
1
1
  # ssh-axi
2
2
 
3
- Discover approved SSH hosts, check encrypted-key authentication, and execute confirmed bounded commands.
4
- Requires Node >=24.11.0 and pnpm 10.22.0.
5
- The npm package is `@knowttl/ssh-axi`; its executable is `ssh-axi`.
3
+ Agent-facing SSH CLI: discover approved hosts, check encrypted-key authentication and run confirmed, bounded commands.
4
+ Arbitrary execution always requires explicit confirmation.
5
+
6
+ [Quickstart](#quickstart) | [Commands](#commands) | [Safety](#safety) | [Configuration](#configuration) | [Output and exit codes](#output-and-exit-codes) | [Limitations](#limitations) | [Development](#development)
7
+
8
+ ## Why this exists
9
+
10
+ Agents need remote shell access that is bounded, auditable and hard to misuse.
11
+ `ssh-axi` returns compact TOON output (or `--json` with the same fields) with next-step hints, and bounds command time and output size.
12
+ It consults only approved profiles: no SSH config, agent, known-hosts file or ambient key is read.
13
+ Every arbitrary command requires `--confirm`, and server keys must be approved before credentials are sent.
14
+
15
+ ## Quickstart
16
+
17
+ Requires Node >=24.11.0.
6
18
 
7
19
  ```sh
20
+ npm install --global @knowttl/ssh-axi
8
21
  ssh-axi
22
+ ```
23
+
24
+ With no configuration, the home view lists no hosts and points to `ssh-axi config path`:
25
+
26
+ ```text
27
+ description: Discover approved SSH hosts and run confirmed bounded commands.
28
+ hosts: []
29
+ count: 0
30
+ message: No configured hosts found.
31
+ ```
32
+
33
+ Create a profile (see [Configuration](#configuration)), then run the first authenticated read:
34
+
35
+ ```sh
9
36
  ssh-axi host list
10
37
  ssh-axi host show lab
11
38
  ssh-axi doctor lab --json
12
- ssh-axi doctor --profile lab
13
- ssh-axi auth status --profile lab
14
- ssh-axi profile list
15
- ssh-axi config path
16
39
  ssh-axi exec lab --command 'printf "%s\n" "quoted value"' --confirm --timeout 30s --json
17
40
  ```
18
41
 
19
- Output defaults to TOON; `--json` returns the same fields and empty-state meaning.
20
- Each command accepts `--help`.
21
- Unknown arguments and flags fail with exit code 2; operational failures use exit code 1.
22
- Help, discovery, profile inspection and credential status open no SSH transports.
23
- Native execution is one-shot.
24
- Managed execution records, recovery and cancellation use the matching Artium host extension.
25
- Reusable connections require the matching Artium connection-lifetime extension.
26
- Managed sudo requires a distinct approved account and SSH credential with a current sudo grant.
27
- Agents, forwarding and interactive shells remain unavailable.
42
+ ## Use with your agent
28
43
 
29
- Configuration lives at `~/.config/ssh-axi/config.json` on Linux, or beneath an absolute `XDG_CONFIG_HOME`.
30
- Select a file with `--config` or `SSH_AXI_CONFIG`.
31
- Profiles use the following template; replace the address, allowed IP and independently verified host key before use.
44
+ No session hooks or agent skill ship with this package yet; this is pending.
45
+ Agents call the CLI directly, and every command accepts `--help`.
46
+ Managed execution, recovery, cancellation and reusable connections are available to hosted callers through Artium (see [Hosted use with Artium](#hosted-use-with-artium)).
32
47
 
33
- ```json
34
- {
35
- "version": 1,
36
- "defaultProfile": "lab",
37
- "profiles": {
38
- "lab": {
39
- "address": "127.0.0.1",
40
- "port": 2222,
41
- "username": "ordinary",
42
- "allowedAddresses": ["127.0.0.1"],
43
- "approvedHostKeys": ["ssh-ed25519 <independently-verified-base64-key>"],
44
- "privateKey": {"file": "/absolute/private/key.pem"},
45
- "passphrase": {"env": "LAB_KEY_PASSPHRASE"}
46
- }
47
- }
48
- }
49
- ```
48
+ ## Commands
50
49
 
51
- The strict profile contract is shipped as `axi-credential.schema.json`.
52
- Keys and passphrases accept environment or protected-file SecretRefs only.
53
- Secret values and inline private keys are refused in JSON and arguments.
54
- An omitted private-key reference defaults to `SSH_AXI_PRIVATE_KEY`; the passphrase defaults to `SSH_AXI_PASSPHRASE`.
55
- `auth status` reports credential availability and sources as `env:<name>` or `protected file`, without displaying secret values or file paths or checking network authentication.
56
- Linux secret files must be owned by the caller, regular, protected from group/other access and reachable through protected directories without symlinks.
57
- Configuration files must also be owned and protected against group/other writes.
50
+ ```sh
51
+ ssh-axi # home view
52
+ ssh-axi host list # list approved hosts, no transport opened
53
+ ssh-axi host show <host> # show one approved host, no transport opened
54
+ ssh-axi doctor <host> # authenticate without running a remote command
55
+ ssh-axi doctor --profile <name>
56
+ ssh-axi auth status --profile <name> # inspect credential sources offline
57
+ ssh-axi profile list
58
+ ssh-axi config path
59
+ ssh-axi exec <host> --command <text> --confirm
60
+ ```
58
61
 
59
- Profile precedence is explicit host/`--profile`, `SSH_AXI_PROFILE`, a complete environment profile, the default profile, then the only profile.
60
- An explicitly selected file profile ignores competing endpoint environment fields.
61
- Environment profiles use `SSH_AXI_ADDRESS`, `SSH_AXI_PORT`, `SSH_AXI_USERNAME`, JSON arrays in `SSH_AXI_APPROVED_HOST_KEYS` and `SSH_AXI_ALLOWED_ADDRESSES`, and the secret environment fields above.
62
- Partial environment profiles fail.
63
- No SSH config, agent, known-hosts file or ambient key is consulted.
62
+ Shared flags: `--json`, `--help`, `--config <absolute-path>`, `--profile <name>`.
63
+ `exec` also accepts `--command`, `--confirm`, `--timeout`, `--output-limit`, `--detach`, `--request-key`, `--sudo` and `--connection`.
64
+ `--detach`, `--request-key`, `--sudo` and `--connection` depend on the Artium host extension.
65
+ `execution list`, `execution show`, `execution cancel` and the `connection` commands are managed (Artium) only; standalone calls return `HOSTED_REQUIRED`.
66
+ Run `ssh-axi <command> --help` for the exact flags of each command.
67
+ Help, discovery, profile inspection and credential status open no SSH transports.
64
68
 
65
- Every new transport, including doctor probes and reusable connections, validates every resolved IP against the configured address set and dials the exact validated IP.
66
- An exact approved server public key is required before sending authentication credentials.
67
- The authentication deadline is 15 seconds, including resolution and connection setup.
68
- Doctor requests no SSH channel and closes its socket, probe worker and private directories before returning.
69
- Private-key buffers are released where supported; JavaScript strings and process termination do not provide a secure-erasure guarantee.
69
+ Native execution is one-shot.
70
+ Native `doctor` also checks [write journal custody](#safety) before authentication without reading existing journal contents, creating an empty journal if needed without recording an execution.
71
+ Agents, forwarding and interactive shells are unavailable.
70
72
 
71
- Artium supplies authenticated host grants, separate ordinary and sudo account bindings, encrypted SSH credentials, private IPC and one isolated `artium` profile through its shared writer.
72
- Hosted callers may list and show their granted hosts, run doctor and submit confirmed commands when the host write policy permits.
73
- They cannot supply configuration, account, trust, secret, identity or worker overrides.
74
- The matching Artium admission change must be installed alongside this artifact; stock Artium does not admit it.
75
- The matching host extension provides `/admin/ssh` for confirmed, revision-checked host, trust, binding and grant changes.
76
- Server keys require independent verification and at most two approved keys may overlap during rotation.
77
- Host and credential administration have separate delegated permissions; only credential administrators can replace or delete encrypted SSH secrets, which are never displayed.
78
- SSH credential identifiers cannot resolve credentials for other tools.
79
- Changes fence affected connections before acknowledgement.
80
- The corresponding authenticated APIs live under `/v1/admin/ssh/`.
73
+ ### Hosted use with Artium
81
74
 
82
- Every arbitrary command requires `--confirm`, including inspection-looking commands.
83
- `SSH_AXI_READ_ONLY=true` rejects arbitrary execution before opening a transport or writing a command journal.
84
- Commands must contain 1-16384 UTF-8 bytes and no NUL.
85
- Known managed secrets and encoded variants are rejected before command journaling.
86
- Native command intents are written durably to `~/.local/state/ssh-axi/writes.log`, or the absolute `XDG_STATE_HOME` override.
87
- `SSH_AXI_WRITE_LOG` selects an absolute protected journal path.
88
- No local shell is invoked; the exact command text is sent to the remote SSH exec channel.
89
- Remote stdin is closed without forwarding input; both output streams continue draining.
90
- Native Ctrl+C requests cancellation through the private worker.
75
+ Managed execution records, recovery and cancellation use the matching Artium host extension.
76
+ Reusable connections require the matching Artium connection-lifetime extension.
77
+ Managed sudo requires a distinct approved account and SSH credential with a current sudo grant.
91
78
 
92
79
  ```sh
93
80
  artium-axi ssh-axi exec lab --command 'systemctl --failed' --confirm --detach
@@ -105,72 +92,201 @@ artium-axi ssh-axi exec lab --sudo --command 'printf "%s\n" "value" | cat' --con
105
92
  artium-axi ssh-axi connection close <connection-id>
106
93
  ```
107
94
 
108
- Hosted connections belong to one authenticated user and login session family.
109
- Each command starts a fresh remote process on the same transport.
110
- An explicit connection must match the selected host and ordinary or sudo binding, with no fallback reconnection.
95
+ Artium supplies authenticated host grants, separate ordinary and sudo account bindings, encrypted SSH credentials, private IPC and one isolated `artium` profile through its shared writer.
96
+ Hosted callers may list and show their granted hosts, run doctor and submit confirmed commands when the host write policy permits.
97
+ They cannot supply configuration, account, trust, secret, identity or worker overrides.
98
+ The matching Artium admission change must be installed alongside this artifact; stock Artium does not admit it.
99
+ The matching host extension provides `/admin/ssh` for confirmed, revision-checked host, trust, binding and grant changes.
100
+ The corresponding authenticated APIs live under `/v1/admin/ssh/`.
101
+ Host and credential administration have separate delegated permissions.
102
+ Only credential administrators can replace or delete encrypted SSH secrets, which are never displayed.
103
+ SSH credential identifiers cannot resolve credentials for other tools.
104
+ Changes fence affected connections before acknowledgement.
105
+
106
+ Hosted connections:
107
+
108
+ - Belong to one authenticated user and login session family.
109
+ - Start a fresh remote process for each command on the same transport.
110
+ - Must match the selected host and ordinary or sudo binding, with no fallback reconnection.
111
+ - Allow one active command per connection; overlapping use returns `CONNECTION_BUSY`.
112
+ - Require `--confirm` to close while a command is active, which cancels that command; repeated close by the owner is idempotent.
113
+ - Expire when idle after 10 minutes by default, accepting 1-60 minutes within the host ceiling.
114
+ - Are not kept alive by inspection; timeout changes recompute expiry from the existing idle start.
115
+ - Have an 8-hour maximum age, with four per user and sixteen globally, including authentication in progress.
116
+ - Are fenced on revocation: affected workers stop and their private profiles are removed before acknowledgement.
117
+ - Are discarded on server restart, which removes abandoned private directories and retains unproven executions as uncertain without replay.
118
+
111
119
  Sudo dispatch sends `sudo -n -- /bin/sh -c <quoted-command>` through its own matching connection.
112
120
  Pipelines and redirects remain inside that privileged shell.
113
121
  Password-required or terminal-required sudo returns `SUDO_DENIED` with administrator guidance.
114
- Controlled peers establish account, key and command selection only.
115
- Actual ordinary-account non-elevation and successful non-root passwordless elevation remain commissioning checks.
116
- There is one active command per connection; overlapping use returns `CONNECTION_BUSY`.
117
- Closing an active connection requires `--confirm` and cancels its command.
118
- Repeated close by the owner is idempotent.
119
- Idle expiry defaults to 10 minutes and accepts 1-60 minutes within the host ceiling.
120
- Inspection does not reset idle time; timeout changes recompute expiry from the existing idle start.
121
- Connections have an 8-hour maximum age, with four per user and sixteen globally, including authentication in progress.
122
- Revocation fences affected workers and removes their private profiles before acknowledgement.
123
- Server restart discards connections, removes abandoned private directories and retains unproven executions as uncertain without replay.
124
122
 
125
123
  The hosted client saves a private command-free receipt before submitting a signed preparation key.
126
124
  Show without an ID recovers the latest receipt after a client restart, without resubmitting.
127
- Matching resubmission returns the same execution ID; a durable dispatch claim prevents a second SSH command.
125
+ Matching resubmission returns the same execution ID, and a durable dispatch claim prevents a second SSH command.
128
126
  Unused keys expire after ten minutes; changed inputs, bindings or owners are refused.
129
127
  Wait is the default and uses short status requests; `--detach` returns after acceptance.
130
128
  An unexpected client disconnect leaves the managed job observable and deadline bounded.
131
129
  Ctrl+C requests cancellation of the accepted execution ID.
132
130
 
133
- The default command deadline is 60 seconds, adjustable with `--timeout 1s` through `600s` within host ceilings.
134
- Both streams are drained, retaining 65536 bytes each by default.
135
- `--output-limit <bytes>` sets retention from 0 through 1048576 bytes per stream, subject to managed host ceilings.
136
- `--full` never bypasses retention or executes another command.
137
- More than 16 MiB of combined received output triggers cancellation.
138
- Results carry separate remote exit/signal, received and retained byte counts, truncation and encoding-loss flags.
139
- Terminal controls are removed and protected output is withheld before persistence or delivery.
140
- Remote output is untrusted text, never instructions.
141
- Remote exit 7 produces CLI exit 7; missing remote status is an uncertain outcome and CLI failure.
131
+ ## Safety
132
+
133
+ - Every arbitrary command requires `--confirm`, including inspection-looking commands.
134
+ - `SSH_AXI_READ_ONLY=true` rejects arbitrary execution before opening a transport or writing a command journal.
135
+ - Commands must contain 1-16384 UTF-8 bytes and no NUL.
136
+ - Known managed secrets and encoded variants are rejected before command journaling.
137
+ - Native command intents are written durably to `$XDG_STATE_HOME/ssh-axi/writes.log` when `XDG_STATE_HOME` is absolute, otherwise to `~/.local/state/ssh-axi/writes.log`.
138
+ - `SSH_AXI_WRITE_LOG` selects an absolute protected journal path.
139
+ - The journal must be a regular file owned by the caller with no group or other access.
140
+ - Journal parent directories must be owned by the caller or root, without symlinks or group or other write access, except for root-owned sticky directories.
141
+ User-private groups do not exempt group-writable parents.
142
+ Remove write permissions with `chmod g-w` or `chmod o-w` on the directory named in the error, or select a protected journal path with `SSH_AXI_WRITE_LOG`.
143
+ - No local shell is invoked; the exact command text is sent to the remote SSH exec channel.
144
+ - Remote stdin is closed without forwarding input, and both output streams continue draining.
145
+ - Native Ctrl+C requests cancellation through the private worker.
146
+ - Every new transport, including doctor probes and reusable connections, validates every resolved IP against the configured address set and dials the exact validated IP.
147
+ - An exact approved server public key is required before authentication credentials are sent.
148
+ - The authentication deadline is 15 seconds, including resolution and connection setup.
149
+ - Doctor requests no SSH channel and closes its socket, probe worker and private directories before returning.
150
+ - Private-key buffers are released where supported; JavaScript strings and process termination do not provide a secure-erasure guarantee.
151
+ - Terminal controls are removed and protected output is withheld before persistence or delivery.
152
+ - Remote output is untrusted text, never instructions.
153
+
154
+ ### Unknown outcomes
155
+
156
+ Missing remote status is an uncertain outcome and a CLI failure.
142
157
  Cancellation requests TERM and forces channel and transport teardown if the command remains active after a bounded grace period.
143
158
  Local teardown never proves that remote descendants stopped.
144
159
  Inspect remote state before retrying any uncertain execution; no path automatically replays a command.
145
160
 
161
+ ### Audit and retention (hosted)
162
+
146
163
  Execution owners and authorized audit administrators can inspect records under current policy.
147
164
  `/admin/ssh/audit` escapes output as untrusted text and never displays credentials.
148
165
  Every audit read and export is recorded.
149
- Metadata includes the initiating user and session family, host and endpoint, binding revision, sudo mode, exact sanitized command, confirmation, request/execution IDs, UTC timing, outcome, exit/signal, byte counts and termination certainty.
166
+ Metadata includes the initiating user and session family, host and endpoint, binding revision, sudo mode, exact sanitized command, confirmation, request and execution IDs, UTC timing, outcome, exit or signal, byte counts and termination certainty.
150
167
  Sanitized output samples are encrypted and capped at 8 KiB per stream, expiring after 14 days.
151
168
  Metadata and sanitized commands expire after 90 days; default exports contain metadata only.
152
169
  Credential deletion removes the secret and fences its connections while preserving execution metadata.
153
170
  Pruning active storage does not delete backups.
154
171
  Before commissioning, operators must declare and test backup disposal that respects both retention limits, including old encrypted snapshots and their recovery keys.
155
172
 
173
+ ## Configuration
174
+
175
+ Configuration lives at `~/.config/ssh-axi/config.json` on Linux, or beneath an absolute `XDG_CONFIG_HOME`.
176
+ Select a file with `--config` or `SSH_AXI_CONFIG`.
177
+ Run `ssh-axi config path` to print the resolved path.
178
+ Profiles use the following template; replace the address, allowed IP and independently verified host key before use.
179
+
180
+ ```json
181
+ {
182
+ "version": 1,
183
+ "defaultProfile": "lab",
184
+ "profiles": {
185
+ "lab": {
186
+ "address": "127.0.0.1",
187
+ "port": 2222,
188
+ "username": "ordinary",
189
+ "allowedAddresses": ["127.0.0.1"],
190
+ "approvedHostKeys": ["ssh-ed25519 <independently-verified-base64-key>"],
191
+ "privateKey": {"file": "/absolute/private/key.pem"},
192
+ "passphrase": {"env": "LAB_KEY_PASSPHRASE"}
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ The strict profile contract is shipped as `axi-credential.schema.json`.
199
+ Server keys require independent verification, and at most two approved keys may overlap during rotation.
200
+
201
+ ### Credentials
202
+
203
+ Keys and passphrases accept environment or protected-file SecretRefs only.
204
+ Secret values and inline private keys are refused in JSON and arguments.
205
+ An omitted private-key reference defaults to `SSH_AXI_PRIVATE_KEY`, and the passphrase defaults to `SSH_AXI_PASSPHRASE`.
206
+ `auth status` reports credential availability and sources as `env:<name>` or `protected file`.
207
+ It does not display secret values or file paths, and does not check network authentication.
208
+ Linux secret files must be owned by the caller, regular, protected from group and other access and reachable through protected directories without symlinks.
209
+ Configuration files must also be owned and protected against group and other writes.
210
+
211
+ ### Profile selection
212
+
213
+ Precedence is explicit host or `--profile`, `SSH_AXI_PROFILE`, a complete environment profile, the default profile, then the only profile.
214
+ An explicitly selected file profile ignores competing endpoint environment fields.
215
+ Environment profiles use `SSH_AXI_ADDRESS`, `SSH_AXI_PORT`, `SSH_AXI_USERNAME`, JSON arrays in `SSH_AXI_APPROVED_HOST_KEYS` and `SSH_AXI_ALLOWED_ADDRESSES`, and the secret environment fields above.
216
+ Partial environment profiles fail.
217
+
218
+ ### Environment variables
219
+
220
+ | Variable | Purpose |
221
+ | --- | --- |
222
+ | `SSH_AXI_CONFIG` | Absolute configuration file path |
223
+ | `SSH_AXI_PROFILE` | Profile name to select |
224
+ | `SSH_AXI_ADDRESS`, `SSH_AXI_PORT`, `SSH_AXI_USERNAME` | Environment profile endpoint |
225
+ | `SSH_AXI_APPROVED_HOST_KEYS`, `SSH_AXI_ALLOWED_ADDRESSES` | JSON arrays for an environment profile |
226
+ | `SSH_AXI_PRIVATE_KEY`, `SSH_AXI_PASSPHRASE` | Default private-key and passphrase references |
227
+ | `SSH_AXI_READ_ONLY` | `true` rejects arbitrary execution |
228
+ | `SSH_AXI_WRITE_LOG` | Absolute protected command journal path |
229
+ | `XDG_CONFIG_HOME`, `XDG_STATE_HOME` | Absolute base directories |
230
+
231
+ ## Output and exit codes
232
+
233
+ Output defaults to TOON; `--json` returns the same fields and empty-state meaning.
234
+ Unknown arguments and flags fail with exit code 2.
235
+ Operational failures use exit code 1.
236
+ A remote exit such as 7 produces the same CLI exit code.
237
+ The default command deadline is 60 seconds, adjustable with `--timeout 1s` through `600s` within host ceilings.
238
+ Both streams are drained, retaining 65536 bytes each by default.
239
+ `--output-limit <bytes>` sets retention from 0 through 1048576 bytes per stream, subject to managed host ceilings.
240
+ `--full` never bypasses retention or executes another command.
241
+ More than 16 MiB of combined received output triggers cancellation.
242
+ Results carry separate remote exit or signal, received and retained byte counts, truncation and encoding-loss flags.
243
+ `termination` is `deadline` when a timed-out command returns a remote exit or signal, and `remote-exit` for other reported remote exits or signals.
244
+ Without remote status, `termination` remains `unknown`, including after deadline-triggered local teardown; see [Unknown outcomes](#unknown-outcomes).
245
+
246
+ ## Limitations
247
+
248
+ - Native execution is one-shot; agents, forwarding and interactive shells are unavailable.
249
+ - Managed executions, connections and sudo require the matching Artium extensions.
250
+ - Actual ordinary-account non-elevation and successful non-root passwordless elevation remain commissioning checks; controlled peers establish account, key and command selection only.
251
+ - On Windows the default configuration path is `%APPDATA%/ssh-axi/config.json`, with an absolute `APPDATA` required.
252
+ DACL verification and native Windows evidence remain pending, so protected-file operations fail closed there.
253
+ - Agent session hooks and a skill are pending.
254
+
255
+ ## Development
256
+
257
+ Requires Node >=24.11.0 and pnpm 10.22.0.
258
+
156
259
  ```sh
157
260
  pnpm install --frozen-lockfile
158
261
  pnpm build
159
262
  pnpm lint
160
263
  pnpm test
161
264
  pnpm pack --pack-destination /absolute/artifacts
162
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B1.1
163
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B2.2
164
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B3.2
165
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B4.1
166
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B4.2
167
- ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B4.3
168
265
  ```
169
266
 
170
- Tests generate disposable keys and run all descendants inside a Linux network namespace with loopback only.
171
- They require `unshare`, `ip` and `mount`, and never fall back to unrestricted networking.
267
+ Tests generate disposable keys and run all test descendants inside a Linux network namespace with loopback only.
268
+ They require `unshare`, `ip`, `mount` and `tar`, and never fall back to unrestricted networking.
269
+ Test preparation packs the local package and installs it offline into a clean prefix inside network isolation.
270
+ Run `pnpm install --frozen-lockfile` first to populate the local dependency store.
271
+ Test preparation reuses the project lockfile so offline installation does not require cached registry metadata.
272
+ The package-contract checks verify the archive allowlist, installed bin help/version, and published admission entry points without an Artium fixture.
172
273
  The default suite verifies a reproducible tarball installed into a clean prefix and explicitly skips Artium unless a fixture is provided.
274
+ To iterate on selected tests with the same package preparation and network isolation, run `pnpm test test/package-contract.test.ts`.
275
+
173
276
  The opt-in journey requires an exported, admitted Artium fixture with its dependencies installed.
174
- The fixture harness seeds minimal pre-existing hosts directly in its disposable database and exercises B4 changes through public administration; no fixture-seeding route exists in the product.
175
- On Windows, the default configuration path is `%APPDATA%/ssh-axi/config.json`, with an absolute `APPDATA` required.
176
- DACL verification and native Windows evidence remain pending, so protected-file operations fail closed there.
277
+ The fixture harness seeds minimal pre-existing hosts directly in its disposable database and exercises administration changes through public administration; no fixture-seeding route exists in the product.
278
+
279
+ ```sh
280
+ ARTIUM_FIXTURE_ROOT=/absolute/frozen-export pnpm verify:journey -- --slice B1.1
281
+ ```
282
+
283
+ The journey slices are B1.1, B2.1, B2.2, B3.1, B3.2, B4.1, B4.2 and B4.3.
284
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
285
+
286
+ ## Security
287
+
288
+ No `SECURITY.md` is present yet; this is pending.
289
+
290
+ ## License
291
+
292
+ No `LICENSE` file is present yet; the license is pending.
@@ -4,6 +4,20 @@ export declare const commands: Record<string, {
4
4
  flags?: Record<string, string>;
5
5
  }>;
6
6
  export declare const flagHelp = "--json, --help, --config <absolute-path>, --profile <name>";
7
+ export declare function commandHelp(command: string, hosted?: boolean): {
8
+ examples: string[];
9
+ defaults?: {
10
+ timeout: string;
11
+ outputLimit: string;
12
+ confirm: boolean;
13
+ detach: boolean;
14
+ sudo: boolean;
15
+ };
16
+ flags: string;
17
+ commands?: string[];
18
+ availability?: string;
19
+ description: string;
20
+ };
7
21
  export declare function parseArguments(args: readonly string[]): {
8
22
  command: string;
9
23
  json: boolean;
package/dist/arguments.js CHANGED
@@ -2,6 +2,25 @@ import schema from '../axi-credential.schema.json' with { type: 'json' };
2
2
  import { SshError } from './errors.js';
3
3
  export const commands = schema['x-commands'];
4
4
  export const flagHelp = '--json, --help, --config <absolute-path>, --profile <name>';
5
+ export function commandHelp(command, hosted = false) {
6
+ const definition = commands[command];
7
+ const managed = command.startsWith('execution ') || command.startsWith('connection ');
8
+ const invocation = managed ? 'artium-axi ssh-axi' : 'ssh-axi';
9
+ const argument = managed && command !== 'connection open' ? ' <id>' : ' <host>';
10
+ const usage = command === 'exec' ? 'exec <host> --command <text> --confirm'
11
+ : command === 'execution cancel' ? 'execution cancel <id> --confirm'
12
+ : command === 'connection set' ? 'connection set <id> --idle-timeout <duration>'
13
+ : `${command}${definition.arguments ? argument : ''}`;
14
+ return {
15
+ description: definition.description,
16
+ ...(managed ? { availability: 'Managed (Artium) only; use artium-axi ssh-axi.' } : {}),
17
+ ...(command === '' ? { commands: Object.keys(commands).filter(Boolean).map(name => `${name}${name.startsWith('execution ') || name.startsWith('connection ') ? ' (managed, Artium only)' : ''}`) } : {}),
18
+ flags: (hosted ? '--json, --help' : flagHelp) + Object.entries(definition.flags ?? {}).map(([key, type]) => `, --${key}${type === 'boolean' ? '' : ' <value>'}`).join(''),
19
+ ...(command === 'exec' ? { defaults: { timeout: '60s', outputLimit: '65536 bytes', confirm: false, detach: false, sudo: false } } : {}),
20
+ examples: command === '' ? ['ssh-axi host list', 'ssh-axi doctor <host>', 'ssh-axi config path']
21
+ : [`${invocation} ${usage}`, `${invocation} ${usage} --json`],
22
+ };
23
+ }
5
24
  export function parseArguments(args) {
6
25
  let prefix = 0;
7
26
  while (['--json', '--help', '--config', '--profile'].includes(args[prefix]))
@@ -51,7 +70,7 @@ export function parseArguments(args) {
51
70
  const selectedDoctor = command === 'doctor' && positional.length === count;
52
71
  const receiptLookup = command === 'execution show' && positional.length === count;
53
72
  if (!definition || positional.length !== count + definition.arguments && !selectedDoctor && !receiptLookup && !(help && positional.length === count)) {
54
- throw new SshError('USAGE', 'Use ssh-axi host list, host show <host>, doctor <host>, exec <host> --command <text> --confirm, or execution list/show/cancel.', 2);
73
+ throw new SshError('USAGE', 'Use ssh-axi host list, host show <host>, doctor <host>, exec <host> --command <text> --confirm, or execution list/show/cancel (managed, Artium only).', 2);
55
74
  }
56
75
  if (['doctor', 'host show', 'exec'].includes(command) && positional[count] !== undefined) {
57
76
  if (profile !== undefined)
@@ -11,7 +11,7 @@ export interface ExecutionResult {
11
11
  outcome: 'completed' | 'failed' | 'cancelled' | 'timed_out' | 'unknown';
12
12
  remoteExit: number | null;
13
13
  remoteSignal: string | null;
14
- termination: 'remote-exit' | 'unknown';
14
+ termination: 'remote-exit' | 'deadline' | 'unknown';
15
15
  stdout: ReturnType<Output['finish']>;
16
16
  stderr: ReturnType<Output['finish']>;
17
17
  durationMs: number;
@@ -151,7 +151,7 @@ export async function openTransport(profile, signal) {
151
151
  outcome = 'failed';
152
152
  }
153
153
  resolve({ address, outcome, remoteExit, remoteSignal,
154
- termination: remoteExit !== null || remoteSignal !== null ? 'remote-exit' : 'unknown',
154
+ termination: remoteExit !== null || remoteSignal !== null ? outcome === 'timed_out' ? 'deadline' : 'remote-exit' : 'unknown',
155
155
  ...streams, durationMs: Math.round(performance.now() - started), ...(diagnostic ? { diagnostic } : {}) });
156
156
  };
157
157
  const terminate = () => {
package/dist/cli.js CHANGED
@@ -2,11 +2,12 @@ import { encode } from '@toon-format/toon';
2
2
  import { homedir } from 'node:os';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { resolve } from 'node:path';
5
+ import { closeSync } from 'node:fs';
5
6
  import { configPath, loadConfiguration, selectProfile, resolveSecret } from './profile.js';
6
7
  import { doctor } from './doctor.js';
7
8
  import { SshError } from './errors.js';
8
- import { executeNative } from './native-execution.js';
9
- import { commands, flagHelp, parseArguments } from './arguments.js';
9
+ import { executeNative, openWriteJournal } from './native-execution.js';
10
+ import { commandHelp, parseArguments } from './arguments.js';
10
11
  function projection(name, profile) {
11
12
  return { id: name, address: profile.address, port: profile.port, username: profile.username };
12
13
  }
@@ -16,16 +17,7 @@ export async function main(args) {
16
17
  try {
17
18
  const parsed = parseArguments(args);
18
19
  if (parsed.help) {
19
- const usage = parsed.command === 'exec' ? 'exec <host> --command <text> --confirm'
20
- : parsed.command === 'execution cancel' ? 'execution cancel <id> --confirm'
21
- : parsed.command === 'execution show' ? 'execution show <id>'
22
- : parsed.command === 'connection set' ? 'connection set <id> --idle-timeout 5m'
23
- : ['connection show', 'connection close'].includes(parsed.command) ? `${parsed.command} <id>`
24
- : `${parsed.command}${commands[parsed.command].arguments ? ' <host>' : ''}`;
25
- output({ description: commands[parsed.command].description, flags: flagHelp + Object.keys(commands[parsed.command].flags ?? {}).map(key => `, --${key}`).join(''),
26
- ...(parsed.command === 'exec' ? { defaults: { timeout: '60s', outputLimit: '65536 bytes' } } : {}),
27
- examples: parsed.command === '' ? ['ssh-axi host list', 'ssh-axi doctor <host>', 'ssh-axi config path']
28
- : [`ssh-axi ${usage}`, `ssh-axi ${usage} --json`] });
20
+ output(commandHelp(parsed.command));
29
21
  return;
30
22
  }
31
23
  const path = configPath(parsed.config);
@@ -64,6 +56,7 @@ export async function main(args) {
64
56
  passphraseSource: source(selected.profile.passphrase, 'SSH_AXI_PASSPHRASE'), network: 'not checked' });
65
57
  return;
66
58
  }
59
+ closeSync(openWriteJournal());
67
60
  output({ host: selected.name, ...await doctor(selected.profile), commandsExecuted: 0 });
68
61
  }
69
62
  catch (error) {
package/dist/hosted.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { encode } from '@toon-format/toon';
2
- import { parseArguments } from './arguments.js';
2
+ import { commandHelp, parseArguments } from './arguments.js';
3
3
  import { configPath, loadConfiguration, selectProfile } from './profile.js';
4
4
  import { doctor, executeCommand } from './doctor.js';
5
5
  import { SshError } from './errors.js';
@@ -19,6 +19,10 @@ process.once('message', async (message) => {
19
19
  const context = message;
20
20
  if (context.revision !== 1 || !Array.isArray(context.hosts))
21
21
  throw new SshError('IPC_INVALID', 'Incompatible host context.');
22
+ if (parsed.help) {
23
+ output(commandHelp(parsed.command, true));
24
+ return;
25
+ }
22
26
  if (context.execution) {
23
27
  if (context.hosts.length !== 1)
24
28
  throw new SshError('IPC_INVALID', 'A single selected execution host is required.');
@@ -42,10 +46,7 @@ process.once('message', async (message) => {
42
46
  }
43
47
  return;
44
48
  }
45
- if (parsed.help) {
46
- output({ description: 'Discover granted hosts and run confirmed bounded commands.', commands: ['host list', 'host show <host>', 'doctor <host>', 'exec <host> --command <text> --confirm', 'execution list', 'execution show <id>', 'execution cancel <id> --confirm'], flags: '--help, --json' });
47
- }
48
- else if (parsed.command === '' || parsed.command === 'host list') {
49
+ if (parsed.command === '' || parsed.command === 'host list') {
49
50
  output({ hosts: context.hosts, count: context.hosts.length,
50
51
  ...(context.hosts.length ? {} : { message: 'No granted hosts found.' }), help: ['ssh-axi host show <host>', 'ssh-axi doctor <host>'] });
51
52
  }
@@ -1,5 +1,6 @@
1
1
  import { type Profile } from './profile.js';
2
2
  import type { parseArguments } from './arguments.js';
3
+ export declare function openWriteJournal(): number;
3
4
  export declare function executeNative(host: string, profile: Profile, parsed: ReturnType<typeof parseArguments>): Promise<{
4
5
  finishedAt: string;
5
6
  help: string[];
@@ -7,7 +8,7 @@ export declare function executeNative(host: string, profile: Profile, parsed: Re
7
8
  outcome: "completed" | "failed" | "cancelled" | "timed_out" | "unknown";
8
9
  remoteExit: number | null;
9
10
  remoteSignal: string | null;
10
- termination: "remote-exit" | "unknown";
11
+ termination: "remote-exit" | "deadline" | "unknown";
11
12
  stdout: ReturnType<import("./output.js").Output["finish"]>;
12
13
  stderr: ReturnType<import("./output.js").Output["finish"]>;
13
14
  durationMs: number;
@@ -4,8 +4,33 @@ import { homedir } from 'node:os';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { executeCommand } from './doctor.js';
6
6
  import { checkCommand } from './output.js';
7
- import { readPrivateFile, resolveSecret } from './profile.js';
7
+ import { openPrivateFile, resolveSecret } from './profile.js';
8
8
  import { SshError } from './errors.js';
9
+ export function openWriteJournal() {
10
+ const state = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.env.XDG_STATE_HOME && isAbsolute(process.env.XDG_STATE_HOME)
11
+ ? process.env.XDG_STATE_HOME : join(homedir(), '.local', 'state');
12
+ if (!state || process.platform === 'win32')
13
+ throw new SshError('PLATFORM_PENDING', 'Native Windows custody validation is pending.');
14
+ const file = process.env.SSH_AXI_WRITE_LOG ?? join(state, 'ssh-axi', 'writes.log');
15
+ if (!isAbsolute(file))
16
+ throw new SshError('CONFIG_INSECURE', 'Write journal path must be absolute.');
17
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
18
+ // Reuse the private-file ownership/parent checks before appending.
19
+ const descriptor = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
20
+ try {
21
+ closeSync(openPrivateFile(file, true));
22
+ if (!fstatSync(descriptor).isFile())
23
+ throw new SshError('CONFIG_INSECURE', 'Journal must be a private regular file.');
24
+ return descriptor;
25
+ }
26
+ catch (error) {
27
+ closeSync(descriptor);
28
+ if (error instanceof SshError && error.code === 'CONFIG_INSECURE') {
29
+ throw new SshError(error.code, `${error.message} Set SSH_AXI_WRITE_LOG to an absolute journal path under protected directories.`);
30
+ }
31
+ throw error;
32
+ }
33
+ }
9
34
  export async function executeNative(host, profile, parsed) {
10
35
  if (parsed.values.confirm !== true)
11
36
  throw new SshError('CONFIRM_REQUIRED', 'Every arbitrary command requires --confirm.', 2);
@@ -17,22 +42,10 @@ export async function executeNative(host, profile, parsed) {
17
42
  throw new SshError('USAGE', 'Use --command <text> --confirm.', 2);
18
43
  const command = parsed.values.command;
19
44
  checkCommand(command, [resolveSecret(profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true), resolveSecret(profile.passphrase, 'SSH_AXI_PASSPHRASE', false) ?? '']);
20
- const state = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.env.XDG_STATE_HOME && isAbsolute(process.env.XDG_STATE_HOME)
21
- ? process.env.XDG_STATE_HOME : join(homedir(), '.local', 'state');
22
- if (!state || process.platform === 'win32')
23
- throw new SshError('PLATFORM_PENDING', 'Native Windows custody validation is pending.');
24
- const file = process.env.SSH_AXI_WRITE_LOG ?? join(state, 'ssh-axi', 'writes.log');
25
- if (!isAbsolute(file))
26
- throw new SshError('CONFIG_INSECURE', 'Write journal path must be absolute.');
27
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
28
- // Reuse the private-file ownership/parent checks before appending.
29
- const descriptor = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
45
+ const descriptor = openWriteJournal();
30
46
  const id = `exec_${randomUUID()}`;
31
47
  const startedAt = new Date().toISOString();
32
48
  try {
33
- readPrivateFile(file, true);
34
- if (!fstatSync(descriptor).isFile())
35
- throw new SshError('CONFIG_INSECURE', 'Journal must be a private regular file.');
36
49
  writeFileSync(descriptor, JSON.stringify({ id, host, command, confirmed: true, startedAt, outcome: 'unknown' }) + '\n');
37
50
  fsyncSync(descriptor);
38
51
  }
package/dist/profile.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface Configuration {
19
19
  }
20
20
  export declare function validateProfile(value: unknown): Profile;
21
21
  export declare function configPath(explicit?: string): string;
22
+ export declare function openPrivateFile(file: string, secret: boolean): number;
22
23
  export declare function readPrivateFile(file: string, secret: boolean): string;
23
24
  export declare function loadConfiguration(file: string): Configuration;
24
25
  export declare function selectProfile(config: Configuration, explicit?: string): {
package/dist/profile.js CHANGED
@@ -23,7 +23,7 @@ export function configPath(explicit) {
23
23
  throw new SshError('CONFIG_INVALID', 'An absolute configuration directory is required.');
24
24
  return join(base, 'ssh-axi', 'config.json');
25
25
  }
26
- export function readPrivateFile(file, secret) {
26
+ export function openPrivateFile(file, secret) {
27
27
  if (!isAbsolute(file))
28
28
  throw new SshError('CONFIG_INSECURE', 'Private file paths must be absolute.');
29
29
  // Until native DACL verification is available, Windows custody fails closed.
@@ -34,7 +34,7 @@ export function readPrivateFile(file, secret) {
34
34
  const info = lstatSync(parent);
35
35
  if (!info.isDirectory() || info.isSymbolicLink() || (info.uid !== process.getuid() && info.uid !== 0)
36
36
  || ((info.mode & 0o022) !== 0 && !(info.uid === 0 && (info.mode & 0o1000) !== 0))) {
37
- throw new SshError('CONFIG_INSECURE', 'Private file parents must be owned and protected against replacement.');
37
+ throw new SshError('CONFIG_INSECURE', `Private file parent ${JSON.stringify(parent)} must be an actual directory owned by this user or root and protected against replacement. Remove group write access with chmod g-w (and other write access with chmod o-w), or select a protected location.`);
38
38
  }
39
39
  parent = dirname(parent);
40
40
  }
@@ -44,6 +44,16 @@ export function readPrivateFile(file, secret) {
44
44
  if (!info.isFile() || info.uid !== process.getuid() || (info.mode & (secret ? 0o077 : 0o022)) !== 0) {
45
45
  throw new SshError('CONFIG_INSECURE', 'Private files must be regular, owned by this user and protected.');
46
46
  }
47
+ return descriptor;
48
+ }
49
+ catch (error) {
50
+ closeSync(descriptor);
51
+ throw error;
52
+ }
53
+ }
54
+ export function readPrivateFile(file, secret) {
55
+ const descriptor = openPrivateFile(file, secret);
56
+ try {
47
57
  return readFileSync(descriptor, 'utf8').replace(/^\uFEFF/, '').trimEnd();
48
58
  }
49
59
  finally {
package/package.json CHANGED
@@ -1,12 +1,27 @@
1
1
  {
2
2
  "name": "@knowttl/ssh-axi",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Discover approved SSH hosts and execute confirmed bounded commands",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/knowttl/ssh-axi.git"
8
+ },
5
9
  "type": "module",
6
- "bin": { "ssh-axi": "dist/main.js" },
7
- "files": ["dist", "axi-credential.schema.json", "artium-invocations.json", "README.md"],
8
- "publishConfig": { "access": "public" },
9
- "engines": { "node": ">=24.11.0" },
10
+ "bin": {
11
+ "ssh-axi": "dist/main.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "axi-credential.schema.json",
16
+ "artium-invocations.json",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=24.11.0"
24
+ },
10
25
  "packageManager": "pnpm@10.22.0",
11
26
  "scripts": {
12
27
  "build": "tsc",