@nimbus-dev/sdk 1.1.2
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/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/audit-logger.d.ts +6 -0
- package/dist/audit-logger.d.ts.map +1 -0
- package/dist/audit-logger.js +18 -0
- package/dist/audit-logger.js.map +1 -0
- package/dist/contract-tests.d.ts +45 -0
- package/dist/contract-tests.d.ts.map +1 -0
- package/dist/contract-tests.js +191 -0
- package/dist/contract-tests.js.map +1 -0
- package/dist/crypto/app-store-connect-jwt.d.ts +19 -0
- package/dist/crypto/app-store-connect-jwt.d.ts.map +1 -0
- package/dist/crypto/app-store-connect-jwt.js +30 -0
- package/dist/crypto/app-store-connect-jwt.js.map +1 -0
- package/dist/crypto/canonical-json.d.ts +36 -0
- package/dist/crypto/canonical-json.d.ts.map +1 -0
- package/dist/crypto/canonical-json.js +75 -0
- package/dist/crypto/canonical-json.js.map +1 -0
- package/dist/crypto/jwt.d.ts +30 -0
- package/dist/crypto/jwt.d.ts.map +1 -0
- package/dist/crypto/jwt.js +30 -0
- package/dist/crypto/jwt.js.map +1 -0
- package/dist/crypto/service-account-token.d.ts +36 -0
- package/dist/crypto/service-account-token.d.ts.map +1 -0
- package/dist/crypto/service-account-token.js +96 -0
- package/dist/crypto/service-account-token.js.map +1 -0
- package/dist/crypto/verify-signature.d.ts +57 -0
- package/dist/crypto/verify-signature.d.ts.map +1 -0
- package/dist/crypto/verify-signature.js +102 -0
- package/dist/crypto/verify-signature.js.map +1 -0
- package/dist/distribution-channel.d.ts +34 -0
- package/dist/distribution-channel.d.ts.map +1 -0
- package/dist/distribution-channel.js +73 -0
- package/dist/distribution-channel.js.map +1 -0
- package/dist/hitl-request.d.ts +7 -0
- package/dist/hitl-request.d.ts.map +1 -0
- package/dist/hitl-request.js +15 -0
- package/dist/hitl-request.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/index.d.ts +2 -0
- package/dist/ipc/index.d.ts.map +1 -0
- package/dist/ipc/index.js +2 -0
- package/dist/ipc/index.js.map +1 -0
- package/dist/ipc/ndjson-line-reader.d.ts +20 -0
- package/dist/ipc/ndjson-line-reader.d.ts.map +1 -0
- package/dist/ipc/ndjson-line-reader.js +56 -0
- package/dist/ipc/ndjson-line-reader.js.map +1 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +23 -0
- package/dist/server.js.map +1 -0
- package/dist/testing/index.d.ts +15 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +17 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/sandbox-contract.d.ts +83 -0
- package/dist/testing/sandbox-contract.d.ts.map +1 -0
- package/dist/testing/sandbox-contract.js +105 -0
- package/dist/testing/sandbox-contract.js.map +1 -0
- package/dist/testing/sandbox-probe.d.ts +23 -0
- package/dist/testing/sandbox-probe.d.ts.map +1 -0
- package/dist/testing/sandbox-probe.js +78 -0
- package/dist/testing/sandbox-probe.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/audit-logger.test.ts +33 -0
- package/src/audit-logger.ts +23 -0
- package/src/contract-tests.test.ts +203 -0
- package/src/contract-tests.ts +220 -0
- package/src/crypto/app-store-connect-jwt.test.ts +80 -0
- package/src/crypto/app-store-connect-jwt.ts +42 -0
- package/src/crypto/canonical-json.test.ts +121 -0
- package/src/crypto/canonical-json.ts +73 -0
- package/src/crypto/jwt.test.ts +62 -0
- package/src/crypto/jwt.ts +45 -0
- package/src/crypto/service-account-token.test.ts +128 -0
- package/src/crypto/service-account-token.ts +116 -0
- package/src/crypto/verify-signature.test.ts +118 -0
- package/src/crypto/verify-signature.ts +138 -0
- package/src/distribution-channel.test.ts +107 -0
- package/src/distribution-channel.ts +105 -0
- package/src/hitl-request.ts +22 -0
- package/src/index.ts +59 -0
- package/src/ipc/index.ts +5 -0
- package/src/ipc/ndjson-line-reader.test.ts +64 -0
- package/src/ipc/ndjson-line-reader.ts +70 -0
- package/src/plugin-api-v1.test.ts +50 -0
- package/src/sdk.test.ts +23 -0
- package/src/server.test.ts +96 -0
- package/src/server.ts +39 -0
- package/src/testing/index.ts +18 -0
- package/src/testing/sandbox-contract.test.ts +146 -0
- package/src/testing/sandbox-contract.ts +155 -0
- package/src/testing/sandbox-probe.ts +87 -0
- package/src/types.ts +42 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
__defaultRunProbe,
|
|
8
|
+
type ProbeResult,
|
|
9
|
+
type ProbeRunner,
|
|
10
|
+
runSandboxContractTests,
|
|
11
|
+
} from "./sandbox-contract.ts";
|
|
12
|
+
|
|
13
|
+
function makeProbeRunner(
|
|
14
|
+
responses: ReadonlyArray<{ probe: string; arg?: string; result: ProbeResult }>,
|
|
15
|
+
): { runner: ProbeRunner; calls: Array<{ probe: string; arg: string }> } {
|
|
16
|
+
const calls: Array<{ probe: string; arg: string }> = [];
|
|
17
|
+
const runner: ProbeRunner = (probe, arg) => {
|
|
18
|
+
calls.push({ probe, arg });
|
|
19
|
+
const match = responses.find(
|
|
20
|
+
(r) => r.probe === probe && (r.arg === undefined || r.arg === arg),
|
|
21
|
+
);
|
|
22
|
+
if (match === undefined) {
|
|
23
|
+
throw new Error(`unexpected probe call: ${probe} arg=${arg}`);
|
|
24
|
+
}
|
|
25
|
+
return match.result;
|
|
26
|
+
};
|
|
27
|
+
return { runner, calls };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeManifest(perms: unknown): string {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "sdk-contract-stub-"));
|
|
32
|
+
const manifestPath = join(dir, "nimbus.extension.json");
|
|
33
|
+
writeFileSync(manifestPath, JSON.stringify({ id: "test", permissions: perms }));
|
|
34
|
+
return manifestPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("runSandboxContractTests", () => {
|
|
38
|
+
it("rejects when the manifest file does not exist", async () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "sdk-contract-missing-"));
|
|
40
|
+
const manifestPath = join(dir, "missing.json");
|
|
41
|
+
await expect(runSandboxContractTests(manifestPath)).rejects.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects when the manifest is not valid JSON", async () => {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), "sdk-contract-bad-"));
|
|
46
|
+
const manifestPath = join(dir, "nimbus.extension.json");
|
|
47
|
+
writeFileSync(manifestPath, "{not-json");
|
|
48
|
+
await expect(runSandboxContractTests(manifestPath)).rejects.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles a manifest with no declared network hosts without crashing", async () => {
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), "sdk-contract-empty-"));
|
|
53
|
+
const manifestPath = join(dir, "nimbus.extension.json");
|
|
54
|
+
writeFileSync(manifestPath, JSON.stringify({ id: "test.empty", permissions: {} }));
|
|
55
|
+
let outcome: "pass" | "fail" = "pass";
|
|
56
|
+
try {
|
|
57
|
+
await runSandboxContractTests(manifestPath);
|
|
58
|
+
} catch {
|
|
59
|
+
outcome = "fail";
|
|
60
|
+
}
|
|
61
|
+
if (process.platform === "win32") {
|
|
62
|
+
expect(outcome).toBe("pass");
|
|
63
|
+
} else {
|
|
64
|
+
expect(outcome).toBe("fail");
|
|
65
|
+
}
|
|
66
|
+
}, 30_000);
|
|
67
|
+
|
|
68
|
+
it("runs all three probes when manifest declares hosts (Linux/macOS)", async () => {
|
|
69
|
+
const manifestPath = writeManifest({ network: ["api.github.com"] });
|
|
70
|
+
const { runner, calls } = makeProbeRunner([
|
|
71
|
+
{
|
|
72
|
+
probe: "network-listed",
|
|
73
|
+
arg: "api.github.com",
|
|
74
|
+
result: { status: 0, stderr: "", stdout: "" },
|
|
75
|
+
},
|
|
76
|
+
{ probe: "network-unlisted", arg: "", result: { status: 11, stderr: "", stdout: "" } },
|
|
77
|
+
{ probe: "fs-denied", arg: "", result: { status: 10, stderr: "", stdout: "" } },
|
|
78
|
+
]);
|
|
79
|
+
await runSandboxContractTests(manifestPath, { runProbe: runner, platform: "linux" });
|
|
80
|
+
expect(calls).toEqual([
|
|
81
|
+
{ probe: "network-listed", arg: "api.github.com" },
|
|
82
|
+
{ probe: "network-unlisted", arg: "" },
|
|
83
|
+
{ probe: "fs-denied", arg: "" },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("skips the network-unlisted probe on Windows", async () => {
|
|
88
|
+
const manifestPath = writeManifest({ network: ["api.github.com"] });
|
|
89
|
+
const { runner, calls } = makeProbeRunner([
|
|
90
|
+
{ probe: "network-listed", result: { status: 0, stderr: "", stdout: "" } },
|
|
91
|
+
{ probe: "fs-denied", result: { status: 10, stderr: "", stdout: "" } },
|
|
92
|
+
]);
|
|
93
|
+
await runSandboxContractTests(manifestPath, { runProbe: runner, platform: "win32" });
|
|
94
|
+
expect(calls.map((c) => c.probe)).toEqual(["network-listed", "fs-denied"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws when the listed-host probe exits non-zero", async () => {
|
|
98
|
+
const manifestPath = writeManifest({ network: ["api.github.com"] });
|
|
99
|
+
const { runner } = makeProbeRunner([
|
|
100
|
+
{ probe: "network-listed", result: { status: 7, stderr: "connect refused", stdout: "" } },
|
|
101
|
+
]);
|
|
102
|
+
await expect(
|
|
103
|
+
runSandboxContractTests(manifestPath, { runProbe: runner, platform: "linux" }),
|
|
104
|
+
).rejects.toThrow(/network-listed probe failed for api\.github\.com.*exit 7.*connect refused/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws when the unlisted-host probe does NOT return exit 11", async () => {
|
|
108
|
+
const manifestPath = writeManifest({ network: ["api.github.com"] });
|
|
109
|
+
const { runner } = makeProbeRunner([
|
|
110
|
+
{ probe: "network-listed", result: { status: 0, stderr: "", stdout: "" } },
|
|
111
|
+
{
|
|
112
|
+
probe: "network-unlisted",
|
|
113
|
+
result: { status: 2, stderr: "unexpected fetch success", stdout: "" },
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
await expect(
|
|
117
|
+
runSandboxContractTests(manifestPath, { runProbe: runner, platform: "linux" }),
|
|
118
|
+
).rejects.toThrow(/network-unlisted probe should have failed.*exit 2.*platform-asymmetry/s);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when fs-denied probe does NOT return exit 10", async () => {
|
|
122
|
+
const manifestPath = writeManifest({});
|
|
123
|
+
const { runner } = makeProbeRunner([
|
|
124
|
+
{ probe: "fs-denied", result: { status: 2, stderr: "unexpected file read", stdout: "" } },
|
|
125
|
+
]);
|
|
126
|
+
await expect(
|
|
127
|
+
runSandboxContractTests(manifestPath, { runProbe: runner, platform: "linux" }),
|
|
128
|
+
).rejects.toThrow(/fs-denied probe should have returned EACCES.*exit 2.*unexpected file read/s);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("tolerates a manifest with `permissions: string[]` (legacy array form)", async () => {
|
|
132
|
+
const manifestPath = writeManifest(["read-files", "trash"]);
|
|
133
|
+
const { runner, calls } = makeProbeRunner([
|
|
134
|
+
{ probe: "fs-denied", result: { status: 10, stderr: "", stdout: "" } },
|
|
135
|
+
]);
|
|
136
|
+
await runSandboxContractTests(manifestPath, { runProbe: runner, platform: "linux" });
|
|
137
|
+
expect(calls).toEqual([{ probe: "fs-denied", arg: "" }]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("`__defaultRunProbe` returns a well-formed envelope on a probe that exits non-zero", () => {
|
|
141
|
+
const r = __defaultRunProbe("definitely-not-a-probe", "");
|
|
142
|
+
expect(typeof r.status).toBe("number");
|
|
143
|
+
expect(typeof r.stderr).toBe("string");
|
|
144
|
+
expect(typeof r.stdout).toBe("string");
|
|
145
|
+
}, 30_000);
|
|
146
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runSandboxContractTests` — verifies the declared sandbox permissions in a
|
|
3
|
+
* manifest match the runtime enforcement seen by a forked probe.
|
|
4
|
+
*
|
|
5
|
+
* Phase 5 T2 PR 1.
|
|
6
|
+
*
|
|
7
|
+
* Three probes run sequentially:
|
|
8
|
+
*
|
|
9
|
+
* 1. **network-listed** — for the first declared host in
|
|
10
|
+
* `permissions.network`, a HEAD fetch must succeed (2xx–4xx).
|
|
11
|
+
* Skipped if no hosts are declared.
|
|
12
|
+
*
|
|
13
|
+
* 2. **network-unlisted** — a fetch to `192.0.2.1` (TEST-NET-1, never
|
|
14
|
+
* routable) must fail with ECONNREFUSED / EPERM / EHOSTUNREACH /
|
|
15
|
+
* ENETUNREACH. Skipped on Windows because AppContainer network
|
|
16
|
+
* filtering is host-allow + deny-by-default at a different layer — the
|
|
17
|
+
* probe sees a generic socket failure that is indistinguishable from
|
|
18
|
+
* the unsandboxed case. See `docs/sandbox.md#platform-asymmetry`.
|
|
19
|
+
* Skipped on every platform if no hosts are declared (the
|
|
20
|
+
* sandbox-runner would deny network entirely in that case, and the
|
|
21
|
+
* probe's expectation collapses to "no network at all", which the
|
|
22
|
+
* first probe doesn't exercise).
|
|
23
|
+
*
|
|
24
|
+
* 3. **fs-denied** — a read of a known-protected path
|
|
25
|
+
* (`/etc/passwd` POSIX, `C:\Windows\System32\config\SAM` Windows)
|
|
26
|
+
* must fail with EACCES / EPERM. This probe always runs.
|
|
27
|
+
*
|
|
28
|
+
* Each probe is invoked via `child_process.spawnSync(process.execPath, …)`
|
|
29
|
+
* directly. **The probe is *not* sandbox-wrapped by the SDK harness alone.**
|
|
30
|
+
* For the SDK to assert real enforcement, a wrapping helper (typically
|
|
31
|
+
* `packages/gateway/test/helpers/sandbox-harness.ts`) needs to fork
|
|
32
|
+
* `runSandboxContractTests` inside a process that is itself sandbox-wrapped,
|
|
33
|
+
* or substitute a sandboxed `execPath`.
|
|
34
|
+
*
|
|
35
|
+
* Used by:
|
|
36
|
+
* - First-party connector contract tests via the gateway test harness.
|
|
37
|
+
* - Third-party extension authors invoking
|
|
38
|
+
* `import { runSandboxContractTests } from "@nimbus-dev/sdk/testing"`.
|
|
39
|
+
*
|
|
40
|
+
* When invoked **outside** a sandboxed harness on Linux/macOS, probes 2 and
|
|
41
|
+
* 3 will either succeed (return exit 2) — which the harness reports as a
|
|
42
|
+
* test failure — or fail with codes other than 10/11. Either way the harness
|
|
43
|
+
* throws. That's the third-party UX: "your contract test failed because the
|
|
44
|
+
* sandbox isn't enforcing the manifest", which is the correct signal even
|
|
45
|
+
* though the UX could be friendlier.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { spawnSync } from "node:child_process";
|
|
49
|
+
import { readFile } from "node:fs/promises";
|
|
50
|
+
import { dirname, resolve } from "node:path";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
|
|
53
|
+
const PROBE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "sandbox-probe.ts");
|
|
54
|
+
|
|
55
|
+
interface ManifestPermissions {
|
|
56
|
+
network?: string[];
|
|
57
|
+
filesystem?: { read?: string[]; write?: string[] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Manifest {
|
|
61
|
+
id?: string;
|
|
62
|
+
permissions?: ManifestPermissions | unknown[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProbeResult {
|
|
66
|
+
status: number;
|
|
67
|
+
stderr: string;
|
|
68
|
+
stdout: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Probe-runner shape — exposed for testability (see `__defaultRunProbe`). */
|
|
72
|
+
export type ProbeRunner = (probe: string, arg: string) => ProbeResult;
|
|
73
|
+
|
|
74
|
+
export interface RunSandboxContractTestsOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Override the probe runner. Default is `__defaultRunProbe` (a
|
|
77
|
+
* `child_process.spawnSync` wrapper around the bundled probe binary).
|
|
78
|
+
* Tests inject a stub here; production callers leave this undefined.
|
|
79
|
+
*/
|
|
80
|
+
runProbe?: ProbeRunner;
|
|
81
|
+
/**
|
|
82
|
+
* Override `process.platform` for testability of the Windows skip
|
|
83
|
+
* branch. Default is the live `process.platform`.
|
|
84
|
+
*/
|
|
85
|
+
platform?: NodeJS.Platform;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the manifest at `manifestPath`, fork the probe binary for each
|
|
90
|
+
* declared capability + the FS-denied negative case, and throw if the
|
|
91
|
+
* observed enforcement does not match.
|
|
92
|
+
*
|
|
93
|
+
* Throws on first failure with a message that names the probe, the
|
|
94
|
+
* observed exit code, and the probe's stderr.
|
|
95
|
+
*/
|
|
96
|
+
export async function runSandboxContractTests(
|
|
97
|
+
manifestPath: string,
|
|
98
|
+
opts: RunSandboxContractTestsOptions = {},
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const runProbe = opts.runProbe ?? __defaultRunProbe;
|
|
101
|
+
const platform = opts.platform ?? process.platform;
|
|
102
|
+
|
|
103
|
+
const raw = await readFile(manifestPath, "utf8");
|
|
104
|
+
const manifest = JSON.parse(raw) as Manifest;
|
|
105
|
+
|
|
106
|
+
const perms = manifest.permissions;
|
|
107
|
+
const objectForm = perms && typeof perms === "object" && !Array.isArray(perms) ? perms : null;
|
|
108
|
+
const hosts = objectForm?.network ?? [];
|
|
109
|
+
|
|
110
|
+
const firstHost = hosts[0];
|
|
111
|
+
if (firstHost !== undefined) {
|
|
112
|
+
const r = runProbe("network-listed", firstHost);
|
|
113
|
+
if (r.status !== 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`network-listed probe failed for ${firstHost}: exit ${r.status}; stderr: ${r.stderr.trim()}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (platform !== "win32" && hosts.length > 0) {
|
|
121
|
+
const r = runProbe("network-unlisted", "");
|
|
122
|
+
if (r.status !== 11) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`network-unlisted probe should have failed with ECONNREFUSED/EPERM/EHOSTUNREACH/ENETUNREACH; ` +
|
|
125
|
+
`got exit ${r.status}; stderr: ${r.stderr.trim()}. ` +
|
|
126
|
+
`See docs/sandbox.md#platform-asymmetry.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const r3 = runProbe("fs-denied", "");
|
|
132
|
+
if (r3.status !== 10) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`fs-denied probe should have returned EACCES (exit 10); got exit ${r3.status}; ` +
|
|
135
|
+
`stderr: ${r3.stderr.trim()}.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Default probe runner — forks the bundled probe binary via
|
|
142
|
+
* `child_process.spawnSync`. Exported for direct use in test scaffolding
|
|
143
|
+
* that wants to assert the production wiring without re-implementing the
|
|
144
|
+
* `spawnSync` envelope.
|
|
145
|
+
*/
|
|
146
|
+
export function __defaultRunProbe(probe: string, arg: string): ProbeResult {
|
|
147
|
+
const result = spawnSync(process.execPath, [PROBE_PATH, `--probe=${probe}`, `--arg=${arg}`], {
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
status: result.status ?? -1,
|
|
152
|
+
stderr: result.stderr ?? "",
|
|
153
|
+
stdout: result.stdout ?? "",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox probe binary — invoked by `runSandboxContractTests`.
|
|
3
|
+
*
|
|
4
|
+
* The probe is a tiny standalone program that the contract harness forks
|
|
5
|
+
* (typically wrapped by the gateway's sandbox runner) to exercise one
|
|
6
|
+
* specific capability and report the outcome via process exit code.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes:
|
|
9
|
+
* 0 — expected pass (network reach to a listed host succeeded)
|
|
10
|
+
* 2 — unexpected outcome (test fails)
|
|
11
|
+
* 10 — expected EACCES/EPERM on filesystem read (sandbox enforced)
|
|
12
|
+
* 11 — expected ECONNREFUSED/EPERM/EHOSTUNREACH/ENETUNREACH on network
|
|
13
|
+
*
|
|
14
|
+
* Invocation:
|
|
15
|
+
* bun sandbox-probe.ts --probe=<name> --arg=<value>
|
|
16
|
+
*
|
|
17
|
+
* Probes:
|
|
18
|
+
* network-listed — HEAD-fetch https://<arg>/ ; succeed if 2xx-4xx
|
|
19
|
+
* network-unlisted — fetch http://192.0.2.1 (TEST-NET-1); succeed if blocked
|
|
20
|
+
* fs-denied — read a known-protected path; succeed if EACCES
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const probe = process.argv.find((a) => a.startsWith("--probe="))?.slice(8);
|
|
24
|
+
const arg = process.argv.find((a) => a.startsWith("--arg="))?.slice(6);
|
|
25
|
+
|
|
26
|
+
function errorCode(e: unknown): string | undefined {
|
|
27
|
+
return (
|
|
28
|
+
(e as { code?: string; cause?: { code?: string } }).code ??
|
|
29
|
+
(e as { cause?: { code?: string } }).cause?.code
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function probeNetworkListed(): Promise<number> {
|
|
34
|
+
const url = `https://${arg ?? ""}/`;
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(url, { method: "HEAD" });
|
|
37
|
+
return res.status >= 200 && res.status < 500 ? 0 : 2;
|
|
38
|
+
} catch {
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function probeNetworkUnlisted(): Promise<number> {
|
|
44
|
+
try {
|
|
45
|
+
await fetch("http://192.0.2.1");
|
|
46
|
+
return 2;
|
|
47
|
+
} catch (e: unknown) {
|
|
48
|
+
const code = errorCode(e);
|
|
49
|
+
if (
|
|
50
|
+
code === "ECONNREFUSED" ||
|
|
51
|
+
code === "EPERM" ||
|
|
52
|
+
code === "EHOSTUNREACH" ||
|
|
53
|
+
code === "ENETUNREACH"
|
|
54
|
+
) {
|
|
55
|
+
return 11;
|
|
56
|
+
}
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function probeFsDenied(): Promise<number> {
|
|
62
|
+
const path =
|
|
63
|
+
process.platform === "win32" ? String.raw`C:\Windows\System32\config\SAM` : "/etc/passwd";
|
|
64
|
+
try {
|
|
65
|
+
await Bun.file(path).text();
|
|
66
|
+
return 2;
|
|
67
|
+
} catch (e: unknown) {
|
|
68
|
+
const code = (e as { code?: string }).code;
|
|
69
|
+
if (code === "EACCES" || code === "EPERM" || code === "EBUSY") return 10;
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main(): Promise<void> {
|
|
75
|
+
if (probe === "network-listed") process.exit(await probeNetworkListed());
|
|
76
|
+
if (probe === "network-unlisted") process.exit(await probeNetworkUnlisted());
|
|
77
|
+
if (probe === "fs-denied") process.exit(await probeFsDenied());
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// This standalone probe has no imports/exports of its own, but the top-level
|
|
82
|
+
// `await main()` below requires the file to be a module. `export {}` is the
|
|
83
|
+
// canonical module marker for that. (Sonar S7787 flags the specifier-less
|
|
84
|
+
// export, but removing it makes the top-level await a compile error — TS1375.)
|
|
85
|
+
export {}; // NOSONAR S7787: specifier-less export is the module marker required for the top-level `await main()` below (removing it is TS1375).
|
|
86
|
+
|
|
87
|
+
await main();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Nimbus extensions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface NimbusItem {
|
|
6
|
+
id: string;
|
|
7
|
+
service: string;
|
|
8
|
+
itemType: "file" | "folder" | "email" | "event" | "photo" | "task";
|
|
9
|
+
name: string;
|
|
10
|
+
mimeType?: string;
|
|
11
|
+
sizeBytes?: number;
|
|
12
|
+
createdAt?: number;
|
|
13
|
+
modifiedAt?: number;
|
|
14
|
+
url?: string;
|
|
15
|
+
parentId?: string;
|
|
16
|
+
rawMeta?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExtensionManifest {
|
|
20
|
+
$schema?: string;
|
|
21
|
+
id: string;
|
|
22
|
+
displayName: string;
|
|
23
|
+
version: string;
|
|
24
|
+
description: string;
|
|
25
|
+
author: string;
|
|
26
|
+
homepage?: string;
|
|
27
|
+
icon?: string;
|
|
28
|
+
entrypoint: string;
|
|
29
|
+
runtime: "bun" | "node";
|
|
30
|
+
permissions: Array<"read" | "write" | "delete">;
|
|
31
|
+
hitlRequired: Array<"write" | "delete">;
|
|
32
|
+
oauth?: {
|
|
33
|
+
provider: string;
|
|
34
|
+
scopes: string[];
|
|
35
|
+
authUrl: string;
|
|
36
|
+
tokenUrl: string;
|
|
37
|
+
pkce: boolean;
|
|
38
|
+
};
|
|
39
|
+
syncInterval?: number;
|
|
40
|
+
tags?: string[];
|
|
41
|
+
minNimbusVersion: string;
|
|
42
|
+
}
|