@crewhaus/sandbox-image-ruby 0.1.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/sandbox-image-ruby",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Ruby 3.3 polyglot sandbox image: registry registration + Dockerfile + T2/T7/T8 contract tests (Section 36)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/sandbox": "0.0.0",
17
+ "@crewhaus/sandbox-image-registry": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/sandbox-image-ruby"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/sandbox-image-ruby#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,157 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createSandbox } from "@crewhaus/sandbox";
3
+ import {
4
+ ImageRegistrationError,
5
+ _resetSandboxImageRegistry,
6
+ hasSandboxImage,
7
+ listAllowedImageRefs,
8
+ lookupSandboxImage,
9
+ registerSandboxImage,
10
+ } from "@crewhaus/sandbox-image-registry";
11
+ import {
12
+ RUBY_COLD_START_BUDGET_MS,
13
+ RUBY_DEFAULT_ENTRYPOINT,
14
+ RUBY_HEALTHCHECK_ARGV,
15
+ RUBY_IMAGE_ID,
16
+ RUBY_IMAGE_REF,
17
+ registerRubySandboxImage,
18
+ } from "./index";
19
+
20
+ describe("registerRubySandboxImage (T1 + T2)", () => {
21
+ beforeEach(() => _resetSandboxImageRegistry());
22
+ afterEach(() => _resetSandboxImageRegistry());
23
+
24
+ test("constants match the kickoff prompt's spec", () => {
25
+ expect(RUBY_IMAGE_ID).toBe("ruby");
26
+ expect(RUBY_IMAGE_REF).toBe("ruby:3.3-alpine");
27
+ expect(RUBY_DEFAULT_ENTRYPOINT).toEqual(["ruby", "-e"]);
28
+ expect(RUBY_HEALTHCHECK_ARGV).toEqual(["ruby", "--version"]);
29
+ });
30
+
31
+ test("registerRubySandboxImage() registers an image with the right shape", () => {
32
+ const entry = registerRubySandboxImage();
33
+ expect(entry.id).toBe("ruby");
34
+ expect(entry.image).toBe("ruby:3.3-alpine");
35
+ expect(entry.defaultEntrypoint).toEqual(["ruby", "-e"]);
36
+ expect(entry.healthcheck.command).toEqual(["ruby", "--version"]);
37
+ expect(entry.healthcheck.expectedExitCode).toBe(0);
38
+ expect(entry.healthcheck.timeoutMs).toBe(RUBY_COLD_START_BUDGET_MS);
39
+ expect(entry.description).toMatch(/Ruby 3\.3/);
40
+ });
41
+
42
+ test("lookupSandboxImage('ruby') returns the registered entry", () => {
43
+ registerRubySandboxImage();
44
+ expect(hasSandboxImage("ruby")).toBe(true);
45
+ expect(lookupSandboxImage("ruby").image).toBe("ruby:3.3-alpine");
46
+ });
47
+
48
+ test("listAllowedImageRefs includes ruby:3.3-alpine", () => {
49
+ registerRubySandboxImage();
50
+ const refs = listAllowedImageRefs();
51
+ expect(refs).toContain("ruby:3.3-alpine");
52
+ expect(refs).toContain("python:3.13-slim");
53
+ });
54
+ });
55
+
56
+ describe("Ruby-shape T2 contract — round-trip via noop sandbox", () => {
57
+ beforeEach(() => _resetSandboxImageRegistry());
58
+ afterEach(() => _resetSandboxImageRegistry());
59
+
60
+ test("noop sandbox accepts ruby:3.3-alpine when registered", async () => {
61
+ registerRubySandboxImage();
62
+ const sandbox = createSandbox({
63
+ backend: "noop",
64
+ allowedImages: listAllowedImageRefs(),
65
+ });
66
+ const result = await sandbox.exec({
67
+ image: RUBY_IMAGE_REF,
68
+ argv: ["printf", "hello-from-ruby"],
69
+ });
70
+ expect(result.exitCode).toBe(0);
71
+ expect(result.stdout).toBe("hello-from-ruby");
72
+ await sandbox.close();
73
+ });
74
+
75
+ test("noop sandbox refuses ruby:3.3-alpine when NOT registered", async () => {
76
+ const trioRefs = ["python:3.13-slim", "node:22-alpine", "alpine:3.19"];
77
+ const sandbox = createSandbox({ backend: "noop", allowedImages: trioRefs });
78
+ await expect(sandbox.exec({ image: RUBY_IMAGE_REF, argv: ["printf", "x"] })).rejects.toThrow(
79
+ /not on the allowlist/,
80
+ );
81
+ await sandbox.close();
82
+ });
83
+ });
84
+
85
+ describe("Ruby-shape T7 — cold-start budget", () => {
86
+ beforeEach(() => _resetSandboxImageRegistry());
87
+ afterEach(() => _resetSandboxImageRegistry());
88
+
89
+ test("healthcheck timeoutMs is within the shell-shape interpreter budget (≤500ms)", () => {
90
+ const entry = registerRubySandboxImage();
91
+ expect(entry.healthcheck.timeoutMs).toBeLessThanOrEqual(500);
92
+ expect(entry.healthcheck.timeoutMs).toBeGreaterThan(0);
93
+ });
94
+ });
95
+
96
+ describe("Ruby-shape T8 — escape-attempt suite (reuses §18 corpus shape)", () => {
97
+ beforeEach(() => _resetSandboxImageRegistry());
98
+ afterEach(() => _resetSandboxImageRegistry());
99
+
100
+ test("Ruby image registration is idempotent-then-rejected", () => {
101
+ registerRubySandboxImage();
102
+ expect(() => registerRubySandboxImage()).toThrow(/already registered/);
103
+ });
104
+
105
+ test("Ruby entry's image string passes registry validation", () => {
106
+ expect(RUBY_IMAGE_REF.startsWith("-")).toBe(false);
107
+ expect(/\s/.test(RUBY_IMAGE_REF)).toBe(false);
108
+ expect(RUBY_IMAGE_REF.includes("\n")).toBe(false);
109
+ });
110
+
111
+ test("Ruby defaultEntrypoint contains no shell-meta", () => {
112
+ for (const arg of RUBY_DEFAULT_ENTRYPOINT) {
113
+ expect(/[;&|<>$`(){}]/.test(arg)).toBe(false);
114
+ expect(arg.includes("\n")).toBe(false);
115
+ }
116
+ });
117
+
118
+ test("Ruby healthcheck argv contains no shell-meta", () => {
119
+ for (const arg of RUBY_HEALTHCHECK_ARGV) {
120
+ expect(/[;&|<>$`(){}]/.test(arg)).toBe(false);
121
+ expect(arg.includes("\n")).toBe(false);
122
+ }
123
+ });
124
+
125
+ test("CLI-flag-injection registration via Ruby id is refused", () => {
126
+ expect(() =>
127
+ registerSandboxImage({
128
+ id: "ruby",
129
+ image: "--privileged",
130
+ defaultEntrypoint: ["ruby", "-e"],
131
+ healthcheck: { command: ["ruby", "--version"], expectedExitCode: 0 },
132
+ }),
133
+ ).toThrow(ImageRegistrationError);
134
+ });
135
+
136
+ test("whitespace-tampered Ruby image is refused", () => {
137
+ expect(() =>
138
+ registerSandboxImage({
139
+ id: "ruby",
140
+ image: "ruby:3.3-alpine --privileged",
141
+ defaultEntrypoint: ["ruby", "-e"],
142
+ healthcheck: { command: ["ruby", "--version"], expectedExitCode: 0 },
143
+ }),
144
+ ).toThrow(/whitespace/);
145
+ });
146
+
147
+ test("shell-meta-tagged Ruby image is refused", () => {
148
+ expect(() =>
149
+ registerSandboxImage({
150
+ id: "ruby",
151
+ image: "ruby:$(id)",
152
+ defaultEntrypoint: ["ruby", "-e"],
153
+ healthcheck: { command: ["ruby", "--version"], expectedExitCode: 0 },
154
+ }),
155
+ ).toThrow(/valid registry/);
156
+ });
157
+ });
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type SandboxImageEntry, registerSandboxImage } from "@crewhaus/sandbox-image-registry";
2
+
3
+ /**
4
+ * Catalog R8 `sandbox-image-ruby` — Section 36 polyglot Ruby sandbox image.
5
+ *
6
+ * Snippet mode (default entrypoint): `ruby -e <code>` reads the snippet
7
+ * from the next argv slot. Advanced callers who mount a project can
8
+ * exec `ruby <file>.rb` after a bundler install.
9
+ *
10
+ * Cold-start budget: ≤500ms for the shell-shape interpreter warm pool —
11
+ * Ruby 3.3 with YJIT enabled boots in ~150ms on warm hosts. The kickoff
12
+ * prompt assigns shell-shape interpreters the tighter 500ms budget; Ruby
13
+ * sits in that bucket because each snippet exec is a fresh ruby process,
14
+ * not a recompile-and-link round-trip like Go/Rust/Java.
15
+ *
16
+ * Layer R8.
17
+ */
18
+
19
+ export const RUBY_IMAGE_ID = "ruby";
20
+ export const RUBY_IMAGE_REF = "ruby:3.3-alpine";
21
+ export const RUBY_DEFAULT_ENTRYPOINT: ReadonlyArray<string> = ["ruby", "-e"];
22
+ export const RUBY_HEALTHCHECK_ARGV: ReadonlyArray<string> = ["ruby", "--version"];
23
+
24
+ /** Cold-start budget for the warm pool (ms). T7 layer asserts this. */
25
+ export const RUBY_COLD_START_BUDGET_MS = 500;
26
+
27
+ export function registerRubySandboxImage(): SandboxImageEntry {
28
+ return registerSandboxImage({
29
+ id: RUBY_IMAGE_ID,
30
+ image: RUBY_IMAGE_REF,
31
+ defaultEntrypoint: RUBY_DEFAULT_ENTRYPOINT,
32
+ healthcheck: {
33
+ command: RUBY_HEALTHCHECK_ARGV,
34
+ expectedExitCode: 0,
35
+ timeoutMs: RUBY_COLD_START_BUDGET_MS,
36
+ },
37
+ description:
38
+ "Ruby 3.3 alpine (YJIT enabled) — snippet mode via `ruby -e`; bundler-aware mode for mounted gems.",
39
+ });
40
+ }