@suiflex/safehell 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/bin/safehell.js +27 -0
- package/install.js +171 -0
- package/package.json +25 -3
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# @suiflex/safehell
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
npm cannot attach a trusted publisher to a package that does not exist, so this
|
|
6
|
-
version exists only to create the package name. Install `0.2.0` or later:
|
|
3
|
+
Approval-gated SSH broker for AI coding agents.
|
|
7
4
|
|
|
8
5
|
```sh
|
|
9
6
|
npm install -g @suiflex/safehell
|
|
7
|
+
safehell setup
|
|
10
8
|
```
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
Installing downloads the release binary for your platform from
|
|
11
|
+
[suiflex/SafeHell](https://github.com/suiflex/SafeHell) and verifies it against
|
|
12
|
+
the release `SHA256SUMS` before use.
|
|
13
|
+
|
|
14
|
+
Full documentation lives in the
|
|
15
|
+
[repository README](https://github.com/suiflex/SafeHell#readme).
|
package/bin/safehell.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher: hands every argument to the vendored binary and propagates its exit
|
|
3
|
+
// code, so `npx @suiflex/safehell ...` behaves like the real CLI.
|
|
4
|
+
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const binary = path.join(__dirname, process.platform === "win32" ? "safehell.exe" : "safehell");
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(binary)) {
|
|
14
|
+
process.stderr.write(
|
|
15
|
+
"safehell: the binary is missing. The postinstall download may have been " +
|
|
16
|
+
"skipped or blocked.\nReinstall with: npm rebuild @suiflex/safehell\n"
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
22
|
+
if (result.error) {
|
|
23
|
+
process.stderr.write(`safehell: ${result.error.message}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
// A signalled child has a null status; report it the way a shell would.
|
|
27
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/install.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Downloads the SafeHell binary matching this machine from the GitHub release
|
|
3
|
+
// whose tag corresponds to this package's version, and vendors it next to the
|
|
4
|
+
// launcher in bin/.
|
|
5
|
+
//
|
|
6
|
+
// The binaries are far too large to ship six copies of in one npm tarball, and
|
|
7
|
+
// splitting them across per-platform optional dependencies means six more
|
|
8
|
+
// packages to publish and keep in step. Fetching at install time keeps this
|
|
9
|
+
// package to a few kilobytes.
|
|
10
|
+
//
|
|
11
|
+
// Run `node install.js --selftest` to check the platform mapping without
|
|
12
|
+
// touching the network.
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const os = require("os");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const https = require("https");
|
|
20
|
+
const crypto = require("crypto");
|
|
21
|
+
const { execFileSync } = require("child_process");
|
|
22
|
+
|
|
23
|
+
const REPO = "suiflex/SafeHell";
|
|
24
|
+
|
|
25
|
+
// node platform:arch -> [release asset stem, archive extension, binary name]
|
|
26
|
+
const TARGETS = {
|
|
27
|
+
"darwin:arm64": ["safehell-macos-aarch64", "tar.gz", "safehell"],
|
|
28
|
+
"darwin:x64": ["safehell-macos-x86_64", "tar.gz", "safehell"],
|
|
29
|
+
"linux:x64": ["safehell-linux-x86_64", "tar.gz", "safehell"],
|
|
30
|
+
"linux:arm64": ["safehell-linux-aarch64", "tar.gz", "safehell"],
|
|
31
|
+
"win32:x64": ["safehell-windows-x86_64", "zip", "safehell.exe"],
|
|
32
|
+
"win32:arm64": ["safehell-windows-aarch64", "zip", "safehell.exe"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function resolveTarget(platform, arch) {
|
|
36
|
+
const target = TARGETS[`${platform}:${arch}`];
|
|
37
|
+
if (!target) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`SafeHell has no prebuilt binary for ${platform}/${arch}. ` +
|
|
40
|
+
`Install it from source with: cargo install safehell`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { stem: target[0], ext: target[1], binary: target[2] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Follows redirects by hand: GitHub serves release assets from a signed
|
|
47
|
+
// object-storage URL, and adding a redirect-following HTTP client would be the
|
|
48
|
+
// only dependency this package has.
|
|
49
|
+
function download(url, hops = 5) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
if (hops < 0) {
|
|
52
|
+
reject(new Error("too many redirects"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
https
|
|
56
|
+
.get(url, { headers: { "user-agent": "safehell-npm-installer" } }, (response) => {
|
|
57
|
+
const { statusCode, headers } = response;
|
|
58
|
+
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
59
|
+
response.resume();
|
|
60
|
+
resolve(download(new URL(headers.location, url).toString(), hops - 1));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (statusCode !== 200) {
|
|
64
|
+
response.resume();
|
|
65
|
+
reject(new Error(`GET ${url} failed with HTTP ${statusCode}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const chunks = [];
|
|
69
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
70
|
+
response.on("end", () => resolve(Buffer.concat(chunks)));
|
|
71
|
+
response.on("error", reject);
|
|
72
|
+
})
|
|
73
|
+
.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The download is unpacked and then executed, so it is verified first. The
|
|
78
|
+
// shell installer refuses to install without SHA256SUMS and this must not be
|
|
79
|
+
// the weaker path to the same binary.
|
|
80
|
+
function verify(sums, asset, archive) {
|
|
81
|
+
const line = sums
|
|
82
|
+
.split("\n")
|
|
83
|
+
.find((entry) => entry.trim().endsWith(` ${asset}`) || entry.trim().endsWith(`*${asset}`));
|
|
84
|
+
if (!line) {
|
|
85
|
+
throw new Error(`SHA256SUMS has no entry for ${asset}`);
|
|
86
|
+
}
|
|
87
|
+
const expected = line.trim().split(/\s+/)[0];
|
|
88
|
+
const actual = crypto.createHash("sha256").update(archive).digest("hex");
|
|
89
|
+
if (actual !== expected) {
|
|
90
|
+
throw new Error(`checksum mismatch for ${asset} (expected ${expected}, got ${actual})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
const { version } = require("./package.json");
|
|
96
|
+
const { stem, ext, binary } = resolveTarget(process.platform, process.arch);
|
|
97
|
+
const asset = `${stem}.${ext}`;
|
|
98
|
+
const base = `https://github.com/${REPO}/releases/download/v${version}`;
|
|
99
|
+
|
|
100
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "safehell-"));
|
|
101
|
+
try {
|
|
102
|
+
process.stderr.write(`Downloading ${asset} v${version}\n`);
|
|
103
|
+
const [archive, sums] = await Promise.all([
|
|
104
|
+
download(`${base}/${asset}`),
|
|
105
|
+
download(`${base}/SHA256SUMS`),
|
|
106
|
+
]);
|
|
107
|
+
verify(sums.toString("utf8"), asset, archive);
|
|
108
|
+
|
|
109
|
+
const archivePath = path.join(scratch, asset);
|
|
110
|
+
fs.writeFileSync(archivePath, archive);
|
|
111
|
+
// bsdtar reads zip as well as tar.gz, and ships with macOS, modern Windows,
|
|
112
|
+
// and every Linux image these binaries target.
|
|
113
|
+
execFileSync("tar", ["-xf", archivePath, "-C", scratch], { stdio: "inherit" });
|
|
114
|
+
|
|
115
|
+
const vendor = path.join(__dirname, "bin");
|
|
116
|
+
fs.mkdirSync(vendor, { recursive: true });
|
|
117
|
+
const destination = path.join(vendor, binary);
|
|
118
|
+
fs.copyFileSync(path.join(scratch, binary), destination);
|
|
119
|
+
fs.chmodSync(destination, 0o755);
|
|
120
|
+
process.stderr.write(`Installed safehell v${version}\n`);
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function selftest() {
|
|
127
|
+
const assert = require("assert");
|
|
128
|
+
assert.deepStrictEqual(resolveTarget("darwin", "arm64"), {
|
|
129
|
+
stem: "safehell-macos-aarch64",
|
|
130
|
+
ext: "tar.gz",
|
|
131
|
+
binary: "safehell",
|
|
132
|
+
});
|
|
133
|
+
assert.deepStrictEqual(resolveTarget("win32", "x64"), {
|
|
134
|
+
stem: "safehell-windows-x86_64",
|
|
135
|
+
ext: "zip",
|
|
136
|
+
binary: "safehell.exe",
|
|
137
|
+
});
|
|
138
|
+
assert.throws(() => resolveTarget("sunos", "sparc"), /no prebuilt binary/);
|
|
139
|
+
|
|
140
|
+
// Every mapped asset must be one the release workflow actually produces.
|
|
141
|
+
const built = new Set([
|
|
142
|
+
"safehell-linux-x86_64",
|
|
143
|
+
"safehell-linux-aarch64",
|
|
144
|
+
"safehell-macos-x86_64",
|
|
145
|
+
"safehell-macos-aarch64",
|
|
146
|
+
"safehell-windows-x86_64",
|
|
147
|
+
"safehell-windows-aarch64",
|
|
148
|
+
]);
|
|
149
|
+
for (const [stem] of Object.values(TARGETS)) {
|
|
150
|
+
assert.ok(built.has(stem), `${stem} is not built by release-build.yml`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sums = "aa\ncafe" + "0".repeat(60) + " safehell-linux-x86_64.tar.gz\n";
|
|
154
|
+
const body = Buffer.from("x");
|
|
155
|
+
assert.throws(() => verify(sums, "safehell-linux-x86_64.tar.gz", body), /checksum mismatch/);
|
|
156
|
+
assert.throws(() => verify(sums, "safehell-macos-arm64.tar.gz", body), /no entry/);
|
|
157
|
+
const good = crypto.createHash("sha256").update(body).digest("hex");
|
|
158
|
+
verify(`${good} safehell-linux-x86_64.tar.gz\n`, "safehell-linux-x86_64.tar.gz", body);
|
|
159
|
+
verify(`${good} *safehell-linux-x86_64.tar.gz\n`, "safehell-linux-x86_64.tar.gz", body);
|
|
160
|
+
|
|
161
|
+
console.log("selftest ok");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (process.argv.includes("--selftest")) {
|
|
165
|
+
selftest();
|
|
166
|
+
} else {
|
|
167
|
+
main().catch((error) => {
|
|
168
|
+
process.stderr.write(`safehell: ${error.message}\n`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suiflex/safehell",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "SafeHell — approval-gated SSH broker for AI coding agents",
|
|
5
5
|
"homepage": "https://github.com/suiflex/SafeHell",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/suiflex/SafeHell.git"
|
|
9
9
|
},
|
|
10
|
-
"license": "MIT"
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"ssh",
|
|
13
|
+
"mcp",
|
|
14
|
+
"agent",
|
|
15
|
+
"security",
|
|
16
|
+
"broker",
|
|
17
|
+
"safehell"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"safehell": "bin/safehell.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/safehell.js",
|
|
24
|
+
"install.js"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"postinstall": "node install.js",
|
|
28
|
+
"test": "node install.js --selftest"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
11
33
|
}
|