@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/dist/output.js ADDED
@@ -0,0 +1,98 @@
1
+ import { SshError } from './errors.js';
2
+ // Share the same vocabulary at command admission, worker output and the parent
3
+ // boundary. Values never become diagnostics or persisted scanner state.
4
+ export function secretPatterns(secrets) {
5
+ return [...new Set(secrets.filter(Boolean).flatMap(secret => {
6
+ const values = [secret, ...secret.split(/\r?\n/).filter(line => line.length >= 16)];
7
+ return values.flatMap(value => [value, JSON.stringify(value).slice(1, -1),
8
+ encodeURIComponent(value), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'),
9
+ Buffer.from(value).toString('hex'), Buffer.from(value).toString('hex').toUpperCase(),
10
+ [...value].map(character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`).join('')]);
11
+ }))];
12
+ }
13
+ export function containsSecret(value, patterns) {
14
+ return patterns.some(pattern => value.includes(pattern))
15
+ || /-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:Bearer |gh[pousr]_|github_pat_)[A-Za-z0-9_./+=-]{16,}/i.test(value);
16
+ }
17
+ export function checkCommand(command, secrets) {
18
+ if (command.includes('\0') || Buffer.byteLength(command) > 16 * 1024 || !command.length) {
19
+ throw new SshError('COMMAND_INVALID', 'Commands must contain 1-16384 UTF-8 bytes and no NUL.', 2);
20
+ }
21
+ if (containsSecret(command, secretPatterns(secrets)))
22
+ throw new SshError('SECRET_IN_COMMAND', 'Command contains protected material; nothing was journaled or dispatched.');
23
+ }
24
+ export class Output {
25
+ patterns;
26
+ limit;
27
+ decoder = new TextDecoder('utf-8');
28
+ tail = '';
29
+ text = '';
30
+ terminal = 'text';
31
+ window;
32
+ receivedBytes = 0;
33
+ retainedBytes = 0;
34
+ truncated = false;
35
+ encodingLoss = false;
36
+ withheld = false;
37
+ constructor(patterns, limit) {
38
+ this.patterns = patterns;
39
+ this.limit = limit;
40
+ this.window = Math.max(256, ...patterns.map(pattern => pattern.length));
41
+ }
42
+ push(bytes) {
43
+ this.receivedBytes += bytes.length;
44
+ this.consume(this.decoder.decode(bytes, { stream: true }));
45
+ }
46
+ consume(value) {
47
+ this.encodingLoss ||= value.includes('\uFFFD');
48
+ const scan = this.tail + value;
49
+ this.withheld ||= containsSecret(scan, this.patterns);
50
+ this.tail = scan.slice(-this.window);
51
+ if (this.withheld) {
52
+ this.text = '';
53
+ this.retainedBytes = 0;
54
+ return;
55
+ }
56
+ // Escape sequences can span chunks. Keep only the parser state, never an
57
+ // unbounded pending OSC title or CSI parameter string.
58
+ let safe = '';
59
+ for (const character of value) {
60
+ if (this.terminal === 'osc' || this.terminal === 'oscEscape') {
61
+ if (character === '\x07' || character === '\x9c' || this.terminal === 'oscEscape' && character === '\\')
62
+ this.terminal = 'text';
63
+ else
64
+ this.terminal = character === '\x1b' ? 'oscEscape' : 'osc';
65
+ }
66
+ else if (this.terminal === 'csi') {
67
+ if (character >= '@' && character <= '~')
68
+ this.terminal = 'text';
69
+ }
70
+ else if (this.terminal === 'escape') {
71
+ this.terminal = character === '[' ? 'csi' : character === ']' ? 'osc' : 'text';
72
+ }
73
+ else if (character === '\x1b')
74
+ this.terminal = 'escape';
75
+ else if (character === '\x9b')
76
+ this.terminal = 'csi';
77
+ else if (character === '\x9d')
78
+ this.terminal = 'osc';
79
+ else if (!/[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(character))
80
+ safe += character;
81
+ }
82
+ const bytes = Buffer.from(safe);
83
+ const remaining = this.limit - this.retainedBytes;
84
+ let retained = bytes.subarray(0, remaining).toString('utf8');
85
+ // Do not introduce a replacement character by splitting a retained codepoint.
86
+ if (bytes.length > remaining)
87
+ retained = retained.replace(/\uFFFD$/, '');
88
+ this.text += retained;
89
+ this.retainedBytes += Buffer.byteLength(retained);
90
+ this.truncated ||= bytes.length > remaining;
91
+ }
92
+ finish() {
93
+ this.consume(this.decoder.decode());
94
+ return { text: this.withheld ? '' : this.text, receivedBytes: this.receivedBytes,
95
+ retainedBytes: this.retainedBytes, truncated: this.truncated, encodingLoss: this.encodingLoss,
96
+ withheld: this.withheld, unit: 'bytes' };
97
+ }
98
+ }
@@ -0,0 +1,29 @@
1
+ export type SecretRef = {
2
+ env: string;
3
+ } | {
4
+ file: string;
5
+ };
6
+ export interface Profile {
7
+ address: string;
8
+ port: number;
9
+ username: string;
10
+ approvedHostKeys: string[];
11
+ allowedAddresses: string[];
12
+ privateKey?: SecretRef;
13
+ passphrase?: SecretRef;
14
+ }
15
+ export interface Configuration {
16
+ version: 1;
17
+ defaultProfile?: string;
18
+ profiles: Record<string, Profile>;
19
+ }
20
+ export declare function validateProfile(value: unknown): Profile;
21
+ export declare function configPath(explicit?: string): string;
22
+ export declare function openPrivateFile(file: string, secret: boolean): number;
23
+ export declare function readPrivateFile(file: string, secret: boolean): string;
24
+ export declare function loadConfiguration(file: string): Configuration;
25
+ export declare function selectProfile(config: Configuration, explicit?: string): {
26
+ name: string;
27
+ profile: Profile;
28
+ };
29
+ export declare function resolveSecret(reference: SecretRef | undefined, fallback: string, required: boolean): string | undefined;
@@ -0,0 +1,133 @@
1
+ import { readFileSync, lstatSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
2
+ import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { isIP } from 'node:net';
5
+ import { Ajv } from 'ajv';
6
+ import schema from '../axi-credential.schema.json' with { type: 'json' };
7
+ import { SshError } from './errors.js';
8
+ const validate = new Ajv({ strict: false }).compile(schema);
9
+ const profileName = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
10
+ export function validateProfile(value) {
11
+ if (!validate(value) || value.allowedAddresses.some(address => !isIP(address))) {
12
+ throw new SshError('CONFIG_INVALID', 'Invalid SSH profile; use the packaged credential schema. Secret fields require env or file references.');
13
+ }
14
+ return value;
15
+ }
16
+ export function configPath(explicit) {
17
+ if (explicit || process.env.SSH_AXI_CONFIG)
18
+ return resolve(explicit ?? process.env.SSH_AXI_CONFIG);
19
+ const base = process.platform === 'win32'
20
+ ? process.env.APPDATA
21
+ : process.env.XDG_CONFIG_HOME && isAbsolute(process.env.XDG_CONFIG_HOME) ? process.env.XDG_CONFIG_HOME : join(homedir(), '.config');
22
+ if (!base || !isAbsolute(base))
23
+ throw new SshError('CONFIG_INVALID', 'An absolute configuration directory is required.');
24
+ return join(base, 'ssh-axi', 'config.json');
25
+ }
26
+ export function openPrivateFile(file, secret) {
27
+ if (!isAbsolute(file))
28
+ throw new SshError('CONFIG_INSECURE', 'Private file paths must be absolute.');
29
+ // Until native DACL verification is available, Windows custody fails closed.
30
+ if (process.platform === 'win32')
31
+ throw new SshError('PLATFORM_PENDING', 'Native Windows file custody validation is pending.');
32
+ let parent = dirname(file);
33
+ while (parent !== parse(parent).root) {
34
+ const info = lstatSync(parent);
35
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.uid !== process.getuid() && info.uid !== 0)
36
+ || ((info.mode & 0o022) !== 0 && !(info.uid === 0 && (info.mode & 0o1000) !== 0))) {
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
+ }
39
+ parent = dirname(parent);
40
+ }
41
+ const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
42
+ try {
43
+ const info = fstatSync(descriptor);
44
+ if (!info.isFile() || info.uid !== process.getuid() || (info.mode & (secret ? 0o077 : 0o022)) !== 0) {
45
+ throw new SshError('CONFIG_INSECURE', 'Private files must be regular, owned by this user and protected.');
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 {
57
+ return readFileSync(descriptor, 'utf8').replace(/^\uFEFF/, '').trimEnd();
58
+ }
59
+ finally {
60
+ closeSync(descriptor);
61
+ }
62
+ }
63
+ export function loadConfiguration(file) {
64
+ let value;
65
+ try {
66
+ value = JSON.parse(readPrivateFile(file, false));
67
+ }
68
+ catch (error) {
69
+ if (error.code === 'ENOENT')
70
+ return { version: 1, profiles: {} };
71
+ if (error instanceof SshError)
72
+ throw error;
73
+ throw new SshError('CONFIG_INVALID', 'Cannot read the protected SSH configuration.');
74
+ }
75
+ if (!value || typeof value !== 'object' || Array.isArray(value))
76
+ throw new SshError('CONFIG_INVALID', 'Invalid configuration object.');
77
+ const config = value;
78
+ if (Object.keys(config).some(key => !['version', 'defaultProfile', 'profiles'].includes(key)) || config.version !== 1
79
+ || !config.profiles || typeof config.profiles !== 'object' || Array.isArray(config.profiles)) {
80
+ throw new SshError('CONFIG_INVALID', 'Expected configuration version 1 with named profiles.');
81
+ }
82
+ for (const [name, profile] of Object.entries(config.profiles)) {
83
+ if (!profileName.test(name))
84
+ throw new SshError('CONFIG_INVALID', 'Invalid profile name.');
85
+ validateProfile(profile);
86
+ }
87
+ if (config.defaultProfile !== undefined && (typeof config.defaultProfile !== 'string'
88
+ || !Object.hasOwn(config.profiles, config.defaultProfile)))
89
+ throw new SshError('CONFIG_INVALID', 'Default profile does not exist.');
90
+ return config;
91
+ }
92
+ export function selectProfile(config, explicit) {
93
+ const name = explicit ?? process.env.SSH_AXI_PROFILE;
94
+ if (name) {
95
+ const profile = Object.hasOwn(config.profiles, name) ? config.profiles[name] : undefined;
96
+ if (!profile)
97
+ throw new SshError('NOT_FOUND', 'Host not found.');
98
+ return { name, profile };
99
+ }
100
+ const fields = ['ADDRESS', 'PORT', 'USERNAME', 'APPROVED_HOST_KEYS', 'ALLOWED_ADDRESSES', 'PRIVATE_KEY', 'PASSPHRASE'];
101
+ if (fields.some(field => process.env[`SSH_AXI_${field}`] !== undefined)) {
102
+ let value;
103
+ try {
104
+ value = { address: process.env.SSH_AXI_ADDRESS, port: Number(process.env.SSH_AXI_PORT), username: process.env.SSH_AXI_USERNAME,
105
+ approvedHostKeys: JSON.parse(process.env.SSH_AXI_APPROVED_HOST_KEYS ?? 'null'),
106
+ allowedAddresses: JSON.parse(process.env.SSH_AXI_ALLOWED_ADDRESSES ?? 'null'), privateKey: { env: 'SSH_AXI_PRIVATE_KEY' },
107
+ ...(process.env.SSH_AXI_PASSPHRASE === undefined ? {} : { passphrase: { env: 'SSH_AXI_PASSPHRASE' } }) };
108
+ }
109
+ catch {
110
+ throw new SshError('CONFIG_INVALID', 'A complete environment profile is required.');
111
+ }
112
+ return { name: 'env', profile: validateProfile(value) };
113
+ }
114
+ const selected = config.defaultProfile ?? (Object.keys(config.profiles).length === 1 ? Object.keys(config.profiles)[0] : undefined);
115
+ if (!selected)
116
+ throw new SshError('PROFILE_REQUIRED', 'Select a configured host with --profile <name>.');
117
+ return { name: selected, profile: config.profiles[selected] };
118
+ }
119
+ export function resolveSecret(reference, fallback, required) {
120
+ const selected = reference ?? { env: fallback };
121
+ let value;
122
+ try {
123
+ value = 'env' in selected ? process.env[selected.env] : readPrivateFile(selected.file, true);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof SshError)
127
+ throw error;
128
+ throw new SshError('KEY_UNAVAILABLE', 'The selected protected secret file is unavailable.');
129
+ }
130
+ if (!value && required)
131
+ throw new SshError('KEY_UNAVAILABLE', 'The selected private key is unavailable.');
132
+ return value;
133
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION: string;
@@ -0,0 +1,2 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -0,0 +1 @@
1
+ export {};
package/dist/worker.js ADDED
@@ -0,0 +1,33 @@
1
+ import { authenticate, execute } from './authenticate.js';
2
+ import { validateProfile } from './profile.js';
3
+ import { SshError } from './errors.js';
4
+ // No listener or public argv mode exists. Only the supervising parent's inherited
5
+ // IPC channel can deliver one bounded authentication or execution request.
6
+ if (!process.send || process.argv.length !== 2)
7
+ process.exit(2);
8
+ const cancellation = new AbortController();
9
+ process.on('message', (value) => {
10
+ if (value && typeof value === 'object' && 'cancel' in value && value.cancel === true)
11
+ cancellation.abort();
12
+ });
13
+ process.once('message', async (value) => {
14
+ try {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value))
16
+ throw new SshError('IPC_INVALID', 'Invalid worker request.');
17
+ const request = value;
18
+ if (request.revision !== 1 || !['doctor', 'exec'].includes(String(request.operation))
19
+ || Object.keys(request).some(key => !['revision', 'operation', 'profile', 'settings'].includes(key)))
20
+ throw new SshError('IPC_INVALID', 'Incompatible worker protocol.');
21
+ const profile = validateProfile(request.profile);
22
+ const result = request.operation === 'doctor' ? await authenticate(profile)
23
+ : await execute(profile, request.settings, cancellation.signal);
24
+ process.exitCode = 0;
25
+ process.send({ ok: true, result }, undefined, undefined, () => process.disconnect());
26
+ }
27
+ catch (error) {
28
+ const safe = error instanceof SshError ? error : new SshError('AUTH_FAILED', 'SSH authentication failed.');
29
+ process.send({ ok: false, error: { code: safe.code, message: safe.message } }, undefined, undefined, () => process.disconnect());
30
+ process.exitCode = 1;
31
+ }
32
+ });
33
+ process.on('disconnect', () => process.exit(process.exitCode ?? 1));
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@knowttl/ssh-axi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "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
+ },
9
+ "type": "module",
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
+ },
25
+ "packageManager": "pnpm@10.22.0",
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "inventory:write": "node scripts/inventory.ts --write",
29
+ "lint": "tsc -p tsconfig.check.json && node scripts/inventory.ts",
30
+ "test": "pnpm build && node scripts/test.ts",
31
+ "verify:journey": "pnpm build && node scripts/journey.ts",
32
+ "prepack": "pnpm build"
33
+ },
34
+ "dependencies": {
35
+ "@toon-format/toon": "2.3.1",
36
+ "ssh2": "1.17.0",
37
+ "ajv": "8.17.1",
38
+ "axi-sdk-js": "0.1.13"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "24.10.0",
42
+ "@types/ssh2": "1.15.5",
43
+ "typescript": "5.9.3"
44
+ }
45
+ }