@deno/sandbox をさわってみる

@deno/sandboxという、モジュールの公開がされていました。
こちら、Den Deploy上にサンドボックスを作成し、軽量な Linux microVM でコードを安全に実行できる環境を提供してくれる。

  • シェルスクリプトの実行
  • プロセスの生成
  • JavaScript アプリケーション
  • REPL の実行
  • リモートからのファイル操作

などを安全に行うことができる環境を提供してくれる。

今回はこちらを試してみる。

最終的には、JSを書いて、ゴールにたどり着かせるゲームを作った。

こちらで公開中。
Deno Sandbox API Test

参考

導入

sandbox は、alpha版として提供されていて使用するには、deploy@deno.com に連絡する必要があった。
しかし、0.2.1からはこの記述が無くなっており、今ならDeno Deployを組織の確認か有料アカウントとして使っていれば使えるようである。
私は、していなかったので次のような画面になった。

どうやら、クレカ他の決済情報により本人確認としているようである。

組織の登録が確認できると、トークンが作成できるようになる。

作成にあたっては、強めの警告を示される。
トークンの有効期限は、24時間から、neverまで選べるが、今回は一旦1週間にしてみた。

トークンが出てくるので、控える。

最初の実行

先の画面にあるような、最小の実行を試す。

まず、.env にトークンを設定。

.env
1
DENO_DEPLOY_TOKEN=<先ほど控えたトークン>

次に、sandboxモジュールを追加する。

1
$ deno add @deno/sandbox

指定通り、ソースを用意。

main.ts
1
2
3
4
5
import { Sandbox } from "@deno/sandbox";

await using sandbox = await Sandbox.create();

await sandbox.sh`echo "Hello, world!"`;

そして、実行。最小でもこのくらいのアクセス権が必要になることは認識しておきたい。

1
2
3
4
5
6
7
8
9
$ deno --env .\main.ts
deno --env .\main.ts
✅ Granted env access to "WS_NO_BUFFER_UTIL".
✅ Granted env access to "DENO_DEPLOY_TOKEN".
✅ Granted env access to "DENO_SANDBOX_BASE_DOMAIN".
✅ Granted env access to "DENO_DEPLOY_ENDPOINT".
✅ Granted env access to "DENO_DEPLOY_CONSOLE_ENDPOINT".
✅ Granted net access to "ams.sandbox-api.deno.net:443".
Hello, world!

以上、linuxのサンドボックス上で、echoが実行され結果を取得できる。

ls.ts
1
2
3
4
import { Sandbox } from "@deno/sandbox";

await using sandbox = await Sandbox.create();
await sandbox.sh`ls -la /`;

こちらをすると、本当にVMなのだということがよくわかる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ deno --env .\ls.ts
total 7180
drwxr-xr-x 1 root root 80 Nov 27 14:33 .
drwxr-xr-x 1 root root 80 Nov 27 14:33 ..
-rw-r--r-- 1 root root 0 Nov 17 17:09 .firecracker
drwxr-xr-x 1 daemon daemon 4096 Nov 29 06:37 app
lrwxrwxrwx 1 root root 7 Nov 17 17:09 bin -> usr/bin
drwxrwxrwt 10 root root 200 Nov 29 06:37 data
drwxr-xr-x 4 root root 2460 Nov 29 06:37 dev
drwxr-xr-x 1 root root 100 Nov 17 17:09 etc
-rwxr-xr-x 1 root root 7301040 Nov 27 01:37 fcinit
drwxr-xr-x 3 root root 45 Nov 17 17:09 home
drwxrwxrwt 2 daemon daemon 40 Nov 27 14:33 isolate
lrwxrwxrwx 1 root root 7 Nov 17 17:09 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Nov 17 17:09 lib64 -> usr/lib64
drwx------ 2 daemon daemon 3 Nov 26 20:41 mnt
drwxr-xr-x 2 root root 27 Nov 17 17:09 overlay
dr-xr-xr-x 113 root root 0 Nov 27 14:33 proc
drwxr-xr-x 18 root root 384 Nov 27 01:37 rom
drwxr-xr-x 2 root root 4096 Nov 27 14:33 root
drwxrwxrwt 6 root root 120 Nov 27 14:33 run
lrwxrwxrwx 1 root root 8 Nov 17 17:09 sbin -> usr/sbin
dr-xr-xr-x 12 root root 0 Nov 27 14:33 sys
drwxrwxrwt 9 root root 4096 Nov 29 06:37 tmp
drwxr-xr-x 12 root root 194 Sep 8 00:00 usr
drwxr-xr-x 5 root root 4096 Nov 27 14:33 var

ディレクトリビューションはDebianのようである。

os.ts
1
2
3
4
5
import { Sandbox } from "@deno/sandbox";

await using sandbox = await Sandbox.create();

await sandbox.sh`cat /etc/os-release`;
1
2
3
4
5
6
7
8
9
10
$ deno --env -EN os.ts
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

パッケージの一覧をapt list --installed などで取ろうとしたが、空で返ってきた。
おそらくダメと想定しつつ、試しに apt install tree を実行してみた。

apt_install.ts
1
2
3
4
5
import { Sandbox } from "@deno/sandbox";

await using sandbox = await Sandbox.create();

await sandbox.sh`apt install tree`;
1
2
3
4
5
6
7
8
9
$ deno --env -EN .\apt_install.ts

WARNING: apt does not have a stable CLI interface. Use with caution in scripts.

E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?
error: Uncaught (in promise) SandboxCommandError: Command failed with exit code 100
throw new SandboxCommandError(
^

しっかりブロックされている。

これらを実行した後、Deno Deployのコンソールを見に行くと、実行ログが確認できる。

詳細を見ると、実行されたコマンドの中身まで確認ができる。

sandbox 上で、Deno を動かす

続けて、SandBox 上で、Denoを動かしてみる。

サンプルにあるコードは以下の通り。

any-script-runner1.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

await sandbox.writeTextFile("hello.ts", "console.log('Hello, Sandbox!');");

const child = await sandbox.spawn("deno", {
args: ["run", "hello.ts"],
stdout: "piped",
});

// Read the output
for await (const chunk of child.stdout) {
console.log(new TextDecoder().decode(chunk));
}

以下のように実行できる。

1
2
deno --env -EN .\any-script-runner1.ts
Hello, Sandbox!

任意のコードを埋めてみる

外部ファイルに書かれたスクリプトをsandbox上で実行してみる。

any-script-runner2.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptFileText = Deno.readTextFileSync("run-any-script.ts")

await sandbox.writeTextFile(scriptName, scriptFileText);

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
});

if(child.stdout === null) {
throw new Error("stdout is null");
}

for await (const chunk of child.stdout) {
console.log(new TextDecoder().decode(chunk));
}
run-any-script.ts
1
console.log('Hello, Sandbox!!!!!!');
1
2
$ deno --env -ENR .\any-script-runner2.ts
Hello, Sandbox!!!!!!

これができたので、もらった任意のコードをsandbox上で実行することの確認が取れた。

パーミッションが関わる操作を試みる

任意実行されるコードに、パーミッションが関わる操作を入れてみる。

run-any-script.ts
1
2
3
4
5
6
7
const res = await fetch("https://www.ccbaxy.xyz/blog/");

console.log(`Response status: ${res.status}`);

const text = await res.text();

console.log(`Response body: ${text.slice(0, 100)}...`);

これを実行すると、何も出力せず終わる状態になったため、改修し出力を追加する。

any-script-runner3.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptFileText = Deno.readTextFileSync("run-any-script.ts")

await sandbox.writeTextFile(scriptName, scriptFileText);

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
});

console.log(await child.status)

if(child.stdout === null) {
throw new Error("stdout is null");
}

for await (const chunk of child.stdout) {
console.log(new TextDecoder().decode(chunk));
}

for await (const chunk of child.stderr!) {
console.error(new TextDecoder().decode(chunk));
}

修正後実行すると、sandbox上でのエラーが取得できた。
正しく、sandbox上でもパーミッションが働いていることがわかる。

1
2
3
4
5
6
7
8
9
10
$ deno --env -ENR .\run-any-script3.ts
{ success: false, code: 1, signal: null }
error: Uncaught (in promise) NotCapable: Requires net access to "www.ccbaxy.xyz:443", run again with the --allow-net flag
const res = await fetch("https://www.ccbaxy.xyz/blog/");
^
at mainFetch (ext:deno_fetch/26_fetch.js:174:43)
at ext:deno_fetch/26_fetch.js:425:11
at new Promise (<anonymous>)
at fetch (ext:deno_fetch/26_fetch.js:370:20)
at file:///app/d548bdaf-c909-4914-bcbd-6e78f1bdcb0b.ts:2:19

args をargs: ["run", "-N", scriptName] にしてやると、ネットワークアクセスができる。

1
2
3
4
5
6
7
8
9
10
$ deno --env -ENR .\run-any-script3.ts
{ success: true, code: 0, signal: null }
Response status: 200

Response body: <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">

<!-- Global site tag (gtag.js) - Google An...

sandbox 内でhono を立ち上げてみる

READEには、package.jsonとExpressを使った例の記載がある。
ここではdeno.jsonとHonoを使い動作確認を試みる。

create-hono-server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Sandbox } from "@deno/sandbox";
import {delay} from "@std/async"

await using sandbox = await Sandbox.create();

const DENO_JSON = {
"imports": {
"@hono/hono": "jsr:@hono/hono@^4.10.7",
}
}
const SERVER_TS = `import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => {
return c.text("Hello, Hono! from Deno Sandbox");
});

Deno.serve({ port: 3000 }, app.fetch);
`;

sandbox.writeTextFile("deno.json", JSON.stringify(DENO_JSON, null, 2));
await sandbox.sh`deno install`;
sandbox.writeTextFile("server.ts", SERVER_TS);
await sandbox.createJsRuntime({entrypoint: "server.ts"})
const publicUrl = await sandbox.exposeHttp({port:3000})

console.log(`Hono server is running at: ${publicUrl}`);

const resp = await fetch(publicUrl);
console.log(await resp.text());

await delay(30000);

以下のように、実行するとインストールから始まり、動作確認ができる。
サーバーを作ると、URLが発行されるので、そこに対してfetchを行いレスポンスを確認している。
URLをしばらく有効なように30秒delayをかけ、手元ブラウザからアクセスできるようにしている。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ deno --env -ENR .\create-hono-server.ts
Download https://jsr.io/@hono/hono/meta.json
<省略>
Download https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz
Installed 1 package in 684ms
Reused 0 packages from cache
Downloaded 1 package from JSR
+
Downloaded 4 packages from npm
++++

Dependencies:
+ jsr:@hono/hono 4.10.7

Hono server is running at: https://577346cc8c854f34b945bcf442a1b818.sandbox.deno.net
Hello, Hono! from Deno Sandbox

動的に作成・発行されたURLにアクセスして、応答を取ることができた。
パッケージインストールもできる。

vscode

sandbox でVScodeを動かすサンプルがある。

1
2
3
4
5
6
7
8
import { Sandbox } from "@deno/sandbox";

await using sandbox = await Sandbox.create();

const vscode = await sandbox.vscode();

console.log(vscode.url);
await vscode.status;

このようにすると、URL発行されアクセスするとVSCodeが開く。
設置されたファイルを見るのに便利である。
ファイルの作成、編集もできるが、–watch での監視はうまく動作しないようだった。
セキュリティ的にどうなっているのか、コントロールできるのかは不明。
少なくとも、Deno Deployのコンソールをログアウトしてもアクセスできたので、この点でのコントロールは効いていない。

以下は、ファイルの変更を監視しつつ、Denoサーバーを動かし、VSCodeで編集を目指したがうまくいかなかった例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Sandbox } from "@deno/sandbox";
import {delay} from "@std/async"

await using sandbox = await Sandbox.create();

const DENO_JSON = {
"imports": {
"@hono/hono": "jsr:@hono/hono@^4.10.7",
}
}
const SERVER_TS = `import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => {
return c.text("Hello, Hono! from Deno Sandbox");
});

Deno.serve({ port: 3000 }, app.fetch);
`;

sandbox.writeTextFile("deno.json", JSON.stringify(DENO_JSON, null, 2));
await sandbox.sh`deno install`;
sandbox.writeTextFile("server.ts", SERVER_TS);

await sandbox.createJsRuntime({entrypoint: "server.ts"})

const child = await sandbox.spawn("deno", {
args: ["run", "-N", "--watch", "server.ts"],
stdout: "piped",
});

const publicUrl = await sandbox.exposeHttp({port:3000})

console.log(`Hono server is running at: ${publicUrl}`);

const vscode = await sandbox.vscode();

console.log(`VSCode at: ${vscode.url}`);

while (true) {
await delay(10000);
await sandbox.sh`ls -la`;
await sandbox.sh`cat server.ts`;
}

試しにサンプルアプリ

試しに、ユーザーが任意入力したソースコードを組み込み実行結果を反映するしたゲームを作ってみる。

ここで中身の説明は、荒いので要点のみ。
中身が気になる場合Octo8080X/deno-sandbox-api-testを見て欲しい。

リクエストからユーザーコードを受け取り、使用するライブラリともいえる部分の中に組み込む。
組み込んだコードをsandbox上で実行し、結果を取得し返すという流れ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { define } from "../../utils.ts";
import { Sandbox } from "@deno/sandbox";

function createSrcCode(userScript: string) {
return `
const movePlan: string[] = [];
function moveRight() {
movePlan.push("right");
}
function moveLeft() {
movePlan.push("left");
}
function moveUp() {
movePlan.push("up");
}
function moveDown() {
movePlan.push("down");
}

// User Script Start
${userScript}
// User Script End

console.log(JSON.stringify({movePlan}));
`;
}

async function simulateSandbox(userScript: string) {
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`;

console.log(createSrcCode(userScript));

await sandbox.writeTextFile(scriptName, createSrcCode(userScript));

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
signal: AbortSignal.timeout(5000),
});

console.log(await child.status);

if (child.stdout === null) {
throw new Error("stdout is null");
}

// チャンクを文字列にまとめる
const stdoutTexts = [];
for await (const chunk of child.stdout) {
stdoutTexts.push(new TextDecoder().decode(chunk));
}
 const stderrTexts = [];
if (child.stderr) {
for await (const chunk of child.stderr) {
stderrTexts.push(new TextDecoder().decode(chunk));
}
}
if (stderrTexts.length > 0) {
console.error("Sandbox stderr:", stderrTexts.join(""));
}

return {
stdout: stdoutTexts.join(""),
stderr: stderrTexts.join(""),
status: (await child.status).code == 0 ? "success" : "error",
}

}
export const handler = define.handlers({
async POST(ctx) {
const userScript = await ctx.req.json();

console.log(userScript);
const simulateResult = await simulateSandbox(userScript.code);

console.log("Simulation result:", simulateResult);

return Response.json(simulateResult);
},
});

フロントエンドは、monaco-editorを使いコード入力し、結果はbabylon.jsでゲームとして表示した。


Deno sandbox apiを使ってみた。

最近、コードないし疑似コードを書いて遊ぶゲームが流行っていたりと、任意のコードを実行するというニーズがあると感じる。
もちろんAI周りのこともあるわけだが、ハードな利用もライトな利用でも安全にコードを実行するのは、需要がある。
sandboxは有用であると感じた。

とりあえずでは、ゲームを作る感じで使うことになりそうである。

では。