@knowttl/ssh-axi 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +291 -2
- package/artium-invocations.json +164 -0
- package/axi-credential.schema.json +44 -0
- package/dist/arguments.d.ts +31 -0
- package/dist/arguments.js +90 -0
- package/dist/authenticate.d.ts +30 -0
- package/dist/authenticate.js +209 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +67 -0
- package/dist/connection-worker.d.ts +1 -0
- package/dist/connection-worker.js +73 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +63 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/hosted.d.ts +1 -0
- package/dist/hosted.js +76 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +7 -0
- package/dist/native-execution.d.ts +22 -0
- package/dist/native-execution.js +66 -0
- package/dist/output.d.ts +29 -0
- package/dist/output.js +98 -0
- package/dist/profile.d.ts +29 -0
- package/dist/profile.js +133 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,292 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ssh-axi
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install --global @knowttl/ssh-axi
|
|
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
|
|
36
|
+
ssh-axi host list
|
|
37
|
+
ssh-axi host show lab
|
|
38
|
+
ssh-axi doctor lab --json
|
|
39
|
+
ssh-axi exec lab --command 'printf "%s\n" "quoted value"' --confirm --timeout 30s --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Use with your agent
|
|
43
|
+
|
|
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)).
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
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
|
+
```
|
|
61
|
+
|
|
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.
|
|
68
|
+
|
|
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.
|
|
72
|
+
|
|
73
|
+
### Hosted use with Artium
|
|
74
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
artium-axi ssh-axi exec lab --command 'systemctl --failed' --confirm --detach
|
|
81
|
+
artium-axi ssh-axi execution list --json
|
|
82
|
+
artium-axi ssh-axi execution show <id> --full
|
|
83
|
+
artium-axi ssh-axi execution show --request-key <key>
|
|
84
|
+
artium-axi ssh-axi execution show
|
|
85
|
+
artium-axi ssh-axi execution cancel <id> --confirm
|
|
86
|
+
artium-axi ssh-axi connection open lab --idle-timeout 10m
|
|
87
|
+
artium-axi ssh-axi connection list
|
|
88
|
+
artium-axi ssh-axi connection show <connection-id>
|
|
89
|
+
artium-axi ssh-axi connection set <connection-id> --idle-timeout 5m
|
|
90
|
+
artium-axi ssh-axi exec lab --connection <connection-id> --command 'pwd' --confirm
|
|
91
|
+
artium-axi ssh-axi exec lab --sudo --command 'printf "%s\n" "value" | cat' --confirm
|
|
92
|
+
artium-axi ssh-axi connection close <connection-id>
|
|
93
|
+
```
|
|
94
|
+
|
|
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
|
+
|
|
119
|
+
Sudo dispatch sends `sudo -n -- /bin/sh -c <quoted-command>` through its own matching connection.
|
|
120
|
+
Pipelines and redirects remain inside that privileged shell.
|
|
121
|
+
Password-required or terminal-required sudo returns `SUDO_DENIED` with administrator guidance.
|
|
122
|
+
|
|
123
|
+
The hosted client saves a private command-free receipt before submitting a signed preparation key.
|
|
124
|
+
Show without an ID recovers the latest receipt after a client restart, without resubmitting.
|
|
125
|
+
Matching resubmission returns the same execution ID, and a durable dispatch claim prevents a second SSH command.
|
|
126
|
+
Unused keys expire after ten minutes; changed inputs, bindings or owners are refused.
|
|
127
|
+
Wait is the default and uses short status requests; `--detach` returns after acceptance.
|
|
128
|
+
An unexpected client disconnect leaves the managed job observable and deadline bounded.
|
|
129
|
+
Ctrl+C requests cancellation of the accepted execution ID.
|
|
130
|
+
|
|
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.
|
|
157
|
+
Cancellation requests TERM and forces channel and transport teardown if the command remains active after a bounded grace period.
|
|
158
|
+
Local teardown never proves that remote descendants stopped.
|
|
159
|
+
Inspect remote state before retrying any uncertain execution; no path automatically replays a command.
|
|
160
|
+
|
|
161
|
+
### Audit and retention (hosted)
|
|
162
|
+
|
|
163
|
+
Execution owners and authorized audit administrators can inspect records under current policy.
|
|
164
|
+
`/admin/ssh/audit` escapes output as untrusted text and never displays credentials.
|
|
165
|
+
Every audit read and export is recorded.
|
|
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.
|
|
167
|
+
Sanitized output samples are encrypted and capped at 8 KiB per stream, expiring after 14 days.
|
|
168
|
+
Metadata and sanitized commands expire after 90 days; default exports contain metadata only.
|
|
169
|
+
Credential deletion removes the secret and fences its connections while preserving execution metadata.
|
|
170
|
+
Pruning active storage does not delete backups.
|
|
171
|
+
Before commissioning, operators must declare and test backup disposal that respects both retention limits, including old encrypted snapshots and their recovery keys.
|
|
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
|
+
|
|
259
|
+
```sh
|
|
260
|
+
pnpm install --frozen-lockfile
|
|
261
|
+
pnpm build
|
|
262
|
+
pnpm lint
|
|
263
|
+
pnpm test
|
|
264
|
+
pnpm pack --pack-destination /absolute/artifacts
|
|
265
|
+
```
|
|
266
|
+
|
|
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.
|
|
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
|
+
|
|
276
|
+
The opt-in journey requires an exported, admitted Artium fixture with its dependencies installed.
|
|
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.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": 1,
|
|
3
|
+
"commands": [
|
|
4
|
+
{
|
|
5
|
+
"command": "home",
|
|
6
|
+
"arguments": 0,
|
|
7
|
+
"flags": {
|
|
8
|
+
"help": "boolean",
|
|
9
|
+
"json": "boolean"
|
|
10
|
+
},
|
|
11
|
+
"effect": "read"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"command": "host list",
|
|
15
|
+
"arguments": 0,
|
|
16
|
+
"flags": {
|
|
17
|
+
"help": "boolean",
|
|
18
|
+
"json": "boolean"
|
|
19
|
+
},
|
|
20
|
+
"effect": "read"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"command": "host show",
|
|
24
|
+
"arguments": 1,
|
|
25
|
+
"flags": {
|
|
26
|
+
"help": "boolean",
|
|
27
|
+
"json": "boolean"
|
|
28
|
+
},
|
|
29
|
+
"effect": "read"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"command": "doctor",
|
|
33
|
+
"arguments": 1,
|
|
34
|
+
"flags": {
|
|
35
|
+
"help": "boolean",
|
|
36
|
+
"json": "boolean"
|
|
37
|
+
},
|
|
38
|
+
"effect": "read"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"command": "auth status",
|
|
42
|
+
"arguments": 0,
|
|
43
|
+
"flags": {
|
|
44
|
+
"help": "boolean",
|
|
45
|
+
"json": "boolean"
|
|
46
|
+
},
|
|
47
|
+
"effect": "read"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"command": "profile list",
|
|
51
|
+
"arguments": 0,
|
|
52
|
+
"flags": {
|
|
53
|
+
"help": "boolean",
|
|
54
|
+
"json": "boolean"
|
|
55
|
+
},
|
|
56
|
+
"effect": "read"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"command": "config path",
|
|
60
|
+
"arguments": 0,
|
|
61
|
+
"flags": {
|
|
62
|
+
"help": "boolean",
|
|
63
|
+
"json": "boolean"
|
|
64
|
+
},
|
|
65
|
+
"effect": "read"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"command": "exec",
|
|
69
|
+
"arguments": 1,
|
|
70
|
+
"flags": {
|
|
71
|
+
"help": "boolean",
|
|
72
|
+
"json": "boolean",
|
|
73
|
+
"command": "string",
|
|
74
|
+
"confirm": "boolean",
|
|
75
|
+
"timeout": "string",
|
|
76
|
+
"output-limit": "string",
|
|
77
|
+
"detach": "boolean",
|
|
78
|
+
"request-key": "string",
|
|
79
|
+
"sudo": "boolean",
|
|
80
|
+
"connection": "string"
|
|
81
|
+
},
|
|
82
|
+
"effect": "write"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"command": "connection open",
|
|
86
|
+
"arguments": 1,
|
|
87
|
+
"flags": {
|
|
88
|
+
"help": "boolean",
|
|
89
|
+
"json": "boolean",
|
|
90
|
+
"idle-timeout": "string",
|
|
91
|
+
"sudo": "boolean"
|
|
92
|
+
},
|
|
93
|
+
"effect": "read"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"command": "connection list",
|
|
97
|
+
"arguments": 0,
|
|
98
|
+
"flags": {
|
|
99
|
+
"help": "boolean",
|
|
100
|
+
"json": "boolean"
|
|
101
|
+
},
|
|
102
|
+
"effect": "read"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"command": "connection show",
|
|
106
|
+
"arguments": 1,
|
|
107
|
+
"flags": {
|
|
108
|
+
"help": "boolean",
|
|
109
|
+
"json": "boolean"
|
|
110
|
+
},
|
|
111
|
+
"effect": "read"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"command": "connection set",
|
|
115
|
+
"arguments": 1,
|
|
116
|
+
"flags": {
|
|
117
|
+
"help": "boolean",
|
|
118
|
+
"json": "boolean",
|
|
119
|
+
"idle-timeout": "string"
|
|
120
|
+
},
|
|
121
|
+
"effect": "read"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"command": "connection close",
|
|
125
|
+
"arguments": 1,
|
|
126
|
+
"flags": {
|
|
127
|
+
"help": "boolean",
|
|
128
|
+
"json": "boolean",
|
|
129
|
+
"confirm": "boolean"
|
|
130
|
+
},
|
|
131
|
+
"effect": "write"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"command": "execution list",
|
|
135
|
+
"arguments": 0,
|
|
136
|
+
"flags": {
|
|
137
|
+
"help": "boolean",
|
|
138
|
+
"json": "boolean"
|
|
139
|
+
},
|
|
140
|
+
"effect": "read"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"command": "execution show",
|
|
144
|
+
"arguments": 1,
|
|
145
|
+
"flags": {
|
|
146
|
+
"help": "boolean",
|
|
147
|
+
"json": "boolean",
|
|
148
|
+
"request-key": "string",
|
|
149
|
+
"full": "boolean"
|
|
150
|
+
},
|
|
151
|
+
"effect": "read"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"command": "execution cancel",
|
|
155
|
+
"arguments": 1,
|
|
156
|
+
"flags": {
|
|
157
|
+
"help": "boolean",
|
|
158
|
+
"json": "boolean",
|
|
159
|
+
"confirm": "boolean"
|
|
160
|
+
},
|
|
161
|
+
"effect": "write"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "SSH profile",
|
|
4
|
+
"x-protocol-revision": 1,
|
|
5
|
+
"x-connection-protocol-revision": 2,
|
|
6
|
+
"x-commands": {
|
|
7
|
+
"": { "arguments": 0, "description": "Discover configured SSH hosts" },
|
|
8
|
+
"host list": { "arguments": 0, "description": "List approved hosts without opening a transport" },
|
|
9
|
+
"host show": { "arguments": 1, "description": "Show one approved host without opening a transport" },
|
|
10
|
+
"doctor": { "arguments": 1, "description": "Authenticate to one host without executing a remote command" },
|
|
11
|
+
"auth status": { "arguments": 0, "description": "Inspect selected credential sources offline" },
|
|
12
|
+
"profile list": { "arguments": 0, "description": "List configured profiles offline" },
|
|
13
|
+
"config path": { "arguments": 0, "description": "Show the configuration path offline" },
|
|
14
|
+
"exec": { "arguments": 1, "description": "Execute one confirmed arbitrary command", "flags": { "command": "string", "confirm": "boolean", "timeout": "string", "output-limit": "string", "detach": "boolean", "request-key": "string", "sudo": "boolean", "connection": "string" } },
|
|
15
|
+
"connection open": { "arguments": 1, "description": "Open a private hosted connection", "flags": { "idle-timeout": "string", "sudo": "boolean" } },
|
|
16
|
+
"connection list": { "arguments": 0, "description": "List caller-owned hosted connections" },
|
|
17
|
+
"connection show": { "arguments": 1, "description": "Show a caller-owned hosted connection" },
|
|
18
|
+
"connection set": { "arguments": 1, "description": "Change a hosted connection idle timeout", "flags": { "idle-timeout": "string" } },
|
|
19
|
+
"connection close": { "arguments": 1, "description": "Close a caller-owned hosted connection", "flags": { "confirm": "boolean" } },
|
|
20
|
+
"execution list": { "arguments": 0, "description": "List caller-owned executions" },
|
|
21
|
+
"execution show": { "arguments": 1, "description": "Recover an execution without replay", "flags": { "request-key": "string", "full": "boolean" } },
|
|
22
|
+
"execution cancel": { "arguments": 1, "description": "Cancel one caller-owned execution", "flags": { "confirm": "boolean" } }
|
|
23
|
+
},
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["address", "port", "username", "approvedHostKeys", "allowedAddresses"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"address": { "type": "string", "minLength": 1, "maxLength": 253, "pattern": "^[A-Za-z0-9.:_-]+$", "description": "Approved SSH hostname or IP address; every resolved address must be allowed." },
|
|
29
|
+
"port": { "type": "integer", "minimum": 1, "maximum": 65535, "description": "Approved SSH TCP port." },
|
|
30
|
+
"username": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$", "description": "Ordinary SSH account selected by the host binding." },
|
|
31
|
+
"approvedHostKeys": { "type": "array", "minItems": 1, "maxItems": 2, "uniqueItems": true, "description": "Independently verified server public keys, each as algorithm plus base64 key; at most two overlap during rotation.", "items": { "type": "string", "pattern": "^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)) [A-Za-z0-9+/]+={0,2}$" } },
|
|
32
|
+
"allowedAddresses": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Approved literal IPv4 or IPv6 addresses; DNS cannot expand this set.", "items": { "type": "string", "minLength": 2 } },
|
|
33
|
+
"privateKey": { "$ref": "#/definitions/secretRef", "x-secret": true },
|
|
34
|
+
"passphrase": { "$ref": "#/definitions/secretRef", "x-secret": true }
|
|
35
|
+
},
|
|
36
|
+
"definitions": {
|
|
37
|
+
"secretRef": {
|
|
38
|
+
"oneOf": [
|
|
39
|
+
{ "type": "object", "additionalProperties": false, "required": ["env"], "properties": { "env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } } },
|
|
40
|
+
{ "type": "object", "additionalProperties": false, "required": ["file"], "properties": { "file": { "type": "string", "minLength": 1 } } }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const commands: Record<string, {
|
|
2
|
+
arguments: number;
|
|
3
|
+
description: string;
|
|
4
|
+
flags?: Record<string, string>;
|
|
5
|
+
}>;
|
|
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
|
+
};
|
|
21
|
+
export declare function parseArguments(args: readonly string[]): {
|
|
22
|
+
command: string;
|
|
23
|
+
json: boolean;
|
|
24
|
+
help: boolean;
|
|
25
|
+
config: string | undefined;
|
|
26
|
+
profile: string | undefined;
|
|
27
|
+
values: Record<string, string | boolean>;
|
|
28
|
+
timeout: number;
|
|
29
|
+
outputLimit: number;
|
|
30
|
+
id: string | undefined;
|
|
31
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import schema from '../axi-credential.schema.json' with { type: 'json' };
|
|
2
|
+
import { SshError } from './errors.js';
|
|
3
|
+
export const commands = schema['x-commands'];
|
|
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
|
+
}
|
|
24
|
+
export function parseArguments(args) {
|
|
25
|
+
let prefix = 0;
|
|
26
|
+
while (['--json', '--help', '--config', '--profile'].includes(args[prefix]))
|
|
27
|
+
prefix += ['--config', '--profile'].includes(args[prefix]) ? 2 : 1;
|
|
28
|
+
const commandName = args[prefix] === 'exec' || args[prefix] === 'doctor' ? args[prefix] : args.slice(prefix, prefix + 2).join(' ');
|
|
29
|
+
const extraFlags = commands[commandName]?.flags ?? {};
|
|
30
|
+
const values = {};
|
|
31
|
+
const positional = [];
|
|
32
|
+
let json = false;
|
|
33
|
+
let help = false;
|
|
34
|
+
let config;
|
|
35
|
+
let profile;
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (let index = 0; index < args.length; index++) {
|
|
38
|
+
const arg = args[index];
|
|
39
|
+
if (!arg.startsWith('-')) {
|
|
40
|
+
positional.push(arg);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!['--json', '--help', '--config', '--profile', ...Object.keys(extraFlags).map(key => `--${key}`)].includes(arg)) {
|
|
44
|
+
throw new SshError('USAGE', `Unknown flag. Valid flags: ${flagHelp}${Object.keys(extraFlags).map(key => `, --${key}`).join('')}.`, 2);
|
|
45
|
+
}
|
|
46
|
+
if (seen.has(arg))
|
|
47
|
+
throw new SshError('USAGE', 'Flags may only appear once.', 2);
|
|
48
|
+
seen.add(arg);
|
|
49
|
+
if (arg === '--json')
|
|
50
|
+
json = true;
|
|
51
|
+
else if (arg === '--help')
|
|
52
|
+
help = true;
|
|
53
|
+
else if (extraFlags[arg.slice(2)] === 'boolean')
|
|
54
|
+
values[arg.slice(2)] = true;
|
|
55
|
+
else {
|
|
56
|
+
const value = args[++index];
|
|
57
|
+
if (value === undefined || (arg !== '--command' && (!value || value.startsWith('-'))))
|
|
58
|
+
throw new SshError('USAGE', `A flag value is required. Valid flags: ${flagHelp}.`, 2);
|
|
59
|
+
if (arg === '--config')
|
|
60
|
+
config = value;
|
|
61
|
+
else if (arg === '--profile')
|
|
62
|
+
profile = value;
|
|
63
|
+
else
|
|
64
|
+
values[arg.slice(2)] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const command = ['doctor', 'exec'].includes(positional[0]) ? positional[0] : positional.slice(0, 2).join(' ');
|
|
68
|
+
const definition = commands[command];
|
|
69
|
+
const count = command === '' ? 0 : ['doctor', 'exec'].includes(command) ? 1 : 2;
|
|
70
|
+
const selectedDoctor = command === 'doctor' && positional.length === count;
|
|
71
|
+
const receiptLookup = command === 'execution show' && positional.length === count;
|
|
72
|
+
if (!definition || positional.length !== count + definition.arguments && !selectedDoctor && !receiptLookup && !(help && positional.length === count)) {
|
|
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);
|
|
74
|
+
}
|
|
75
|
+
if (['doctor', 'host show', 'exec'].includes(command) && positional[count] !== undefined) {
|
|
76
|
+
if (profile !== undefined)
|
|
77
|
+
throw new SshError('USAGE', 'Use the host argument to select the profile for this command.', 2);
|
|
78
|
+
profile = positional[count];
|
|
79
|
+
}
|
|
80
|
+
if (command === 'execution show' && positional[count] && values['request-key'])
|
|
81
|
+
throw new SshError('USAGE', 'Use an execution ID or --request-key, not both.', 2);
|
|
82
|
+
const timeoutValue = values.timeout ?? '60s';
|
|
83
|
+
const timeout = typeof timeoutValue === 'string' && /^\d+s?$/.test(timeoutValue) ? Number(timeoutValue.replace(/s$/, '')) : NaN;
|
|
84
|
+
const outputValue = values['output-limit'] ?? '65536';
|
|
85
|
+
const outputLimit = typeof outputValue === 'string' && /^\d+$/.test(outputValue) ? Number(outputValue) : NaN;
|
|
86
|
+
if (command === 'exec' && (!Number.isInteger(timeout) || timeout < 1 || timeout > 600 || !Number.isInteger(outputLimit) || outputLimit < 0 || outputLimit > 1024 * 1024)) {
|
|
87
|
+
throw new SshError('USAGE', 'Use --timeout 1s-600s and --output-limit 0-1048576 (bytes).', 2);
|
|
88
|
+
}
|
|
89
|
+
return { command, json, help, config, profile, values, timeout, outputLimit, id: positional[count] };
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Profile } from './profile.js';
|
|
2
|
+
import { Output } from './output.js';
|
|
3
|
+
export declare const AUTHENTICATION_DEADLINE_MS = 15000;
|
|
4
|
+
export interface ExecutionSettings {
|
|
5
|
+
command: string;
|
|
6
|
+
timeout: number;
|
|
7
|
+
outputLimit: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ExecutionResult {
|
|
10
|
+
address: string;
|
|
11
|
+
outcome: 'completed' | 'failed' | 'cancelled' | 'timed_out' | 'unknown';
|
|
12
|
+
remoteExit: number | null;
|
|
13
|
+
remoteSignal: string | null;
|
|
14
|
+
termination: 'remote-exit' | 'deadline' | 'unknown';
|
|
15
|
+
stdout: ReturnType<Output['finish']>;
|
|
16
|
+
stderr: ReturnType<Output['finish']>;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
diagnostic?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function authenticate(profile: Profile): Promise<{
|
|
21
|
+
authenticated: true;
|
|
22
|
+
address: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function execute(profile: Profile, settings: ExecutionSettings, signal?: AbortSignal): Promise<ExecutionResult>;
|
|
25
|
+
export declare function openTransport(profile: Profile, signal?: AbortSignal): Promise<{
|
|
26
|
+
address: string;
|
|
27
|
+
disconnected: Promise<void>;
|
|
28
|
+
close: () => Promise<void>;
|
|
29
|
+
execute(settings: ExecutionSettings, signal?: AbortSignal): Promise<ExecutionResult>;
|
|
30
|
+
}>;
|