クリーム色の背景、色付き表示、高コントラスト、大きな文字のほうが読みやすいですか?配色と文字を選ぶと、すべてのページに一度で反映されます。

無料レッスン

モデルの SQL を読み取り専用で動かす

このレッスンのあとで: モデルのクエリを、読むことしかできず、少しずつしか返さない形で動かすことができる。

70分のレッスンコマンド10件AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

モデルの SQL が読むだけでよい理由

モデルの SQL を安全に動かすとは、読むことはできても、何も変えられないようにすることです。モデルは、間違えて、あるいは利用者の質問にだまされて、DELETE や DROP を書くことがあります。

モジュール 4 は、わざとデータを変える方法を教えました。このレッスンは、モデルのクエリの前に三つの鍵を置きます。ガード、上限、読み取り専用の接続です。

このレッスンのゴール

メモ

モデルのクエリを、読むことしかできず、少しずつしか返さない形で動かすことができる。

キーワード

ことばいみれい
ガード動かす前にクエリを拒むコード
文一つのまとまった SQL の命令SELECT title FROM books
行数の上限クエリが返してよい行の最大
タイムアウトクエリが動いてよい最長の時間
読み取り専用行を読めるが、決して変えられない

学生が入力するセルは、このプレビューでは空白です。

SELECT 一つだけを通す

ガードはクエリを整え、最後のセミコロンを一つ取ります。それでもセミコロンが残っていれば、二つ目の文があるので拒みます。

最初の語は、SELECT か WITH でなければなりません。DELETE、DROP、UPDATE のような書き込みの語は、どこにあっても拒みます。コメントも拒みます。コメントは文字を隠せるからです。

例: ガードの判定

クエリ判定理由
SELECT title FROM books WHERE year > 2020通すSELECT が一つ
SELECT title FROM books; DROP TABLE loans拒否文が二つ以上
DELETE FROM loans WHERE returned = 1拒否SELECT ではない
SELECT title FROM books -- メモ拒否コメント

学生が入力するセルは、このプレビューでは空白です。

行数の上限と時間の上限

正しいクエリでも、百万行を返したり、いつまでも終わらなかったりします。SELECT * FROM (クエリ) LIMIT 5 で包めば、返る行は五行までです。

クエリは、タイムアウトをつけた別のプロセスで動かします。時間が切れるとプロセスは止められ、ページは固まらずに答えます。

読み取り専用が最後の鍵

ガードは語を調べますが、語はすり抜けることがあります。だから、データベースを読み取り専用で開くか、SELECT しかできない利用者でログインします。

モデルの SQL を、書き込みのできる本番のデータで動かしてはいけません。読み取り専用のコピーを使えば、悪いクエリにできる最悪のことは、遅くなることだけです。

よくある間違い

注意

語を調べるガードがあれば十分だ、と考える人が多いです。SQL には同じことを言う方法がたくさんあるので、ガードにはすき間があります。読み取り専用の接続も残しておけば、見逃したクエリでも書き込めません。

助っ人 model.js を書く、その 1

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// 応答のトークン数。なければ null。' 'function count(data, name) {' '  return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' '// 応答がクエリを囲みで包んでいたら、内側だけを残す。' 'function sqlOf(text) {' '  const parts = text.split(String.fromCharCode(96).repeat(3));' '  const inner = (parts.length > 2 ? parts[1] : text).trim();' '  return inner.toLowerCase().startsWith(\"sql\") ? inner.slice(3).trim() : inner;' '}' > model.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

助っ人 model.js を書く、その 2

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function ask(messages, sample, max) {' '  try {' '    const res = await fetch(base + \"/v1/chat/completions\", {' '      method: \"POST\",' '      headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' '      body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' '      signal: AbortSignal.timeout(30000),' '    });' '    if (res.ok === false) throw new Error(\"status \" + res.status);' '    const data = await res.json();' '    const text = data.choices[0].message.content;' '    return { text, inTokens: count(data, \"prompt_tokens\"), outTokens: count(data, \"completion_tokens\"), recorded: false };' '  } catch (error) {' '    console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' '    return { text: sample, inTokens: null, outTokens: null, recorded: true };' '  }' '}' 'module.exports = { ask, sqlOf };' >> model.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

小さな図書館のデータを書く、その 1

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// 作り話の図書館: 本が六冊、会員が四人、貸し出しが九件。' 'const BOOKS = [[1, \"塩の道\", 2019], [2, \"ガラスの港\", 2021], [3, \"雨の地図\", 2015],' '  [4, \"静かなエンジン\", 2023], [5, \"七つのやかん\", 2018], [6, \"正午の北\", 2022]];' 'const MEMBERS = [[1, \"上田\"], [2, \"佐藤\"], [3, \"高橋\"], [4, \"中村\"]];' '// id, book_id, member_id, month, returned(1 は返却済み、0 は貸し出し中)' 'const LOANS = [[1, 1, 1, 3, 1], [2, 2, 1, 3, 1], [3, 3, 2, 3, 0], [4, 1, 3, 3, 1], [5, 1, 1, 4, 0],' '  [6, 4, 2, 4, 0], [7, 2, 3, 4, 1], [8, 5, 2, 5, 0], [9, 2, 4, 5, 0]];' > library.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

小さな図書館のデータを書く、その 2

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function put(db, table, rows) {' '  const marks = rows[0].map(function () { return \"?\"; }).join(\", \");' '  const insert = db.prepare(\"INSERT INTO \" + table + \" VALUES (\" + marks + \")\");' '  rows.forEach(function (row) { insert.run(...row); });' '}' 'function open(file) {' '  const db = new DatabaseSync(file || \":memory:\");' '  db.exec(\"CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, year INTEGER)\");' '  db.exec(\"CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT)\");' '  db.exec(\"CREATE TABLE loans (id INTEGER PRIMARY KEY, book_id INTEGER REFERENCES books(id), \"' '    + \"member_id INTEGER REFERENCES members(id), month INTEGER, returned INTEGER)\");' '  put(db, \"books\", BOOKS);' '  put(db, \"members\", MEMBERS);' '  put(db, \"loans\", LOANS);' '  return db;' '}' 'module.exports = { open };' >> library.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

読み取り専用で動かす部分を書く

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// 読み取り専用で開く。ガードが見逃したクエリでも、行を変えられない。' 'const db = new DatabaseSync(\"library.db\", { readOnly: true });' 'try {' '  const rows = db.prepare(process.argv[2]).all().map(function (r) { return Object.values(r); });' '  console.log(rows.length + \" 行: \" + JSON.stringify(rows));' '} catch (error) { console.log(\"エラー: \" + error.message); }' > runone.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ガードのスクリプトを書く、その 1

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask, sqlOf } = require(\"./model\");' 'const { open } = require(\"./library\");' 'const fs = require(\"node:fs\");' 'const { spawnSync } = require(\"node:child_process\");' 'fs.rmSync(\"library.db\", { force: true });' 'open(\"library.db\").close();' 'const BANNED = [\"INSERT\", \"UPDATE\", \"DELETE\", \"DROP\", \"ALTER\", \"CREATE\", \"ATTACH\", \"PRAGMA\", \"REPLACE\"];' 'const LIMIT = 5;' > guard.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ガードのスクリプトを書く、その 2

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function guard(sql) {' '  let text = sql.trim();' '  if (text.endsWith(\";\")) text = text.slice(0, -1).trim();' '  if (text.indexOf(\";\") >= 0) return { ok: false, why: \"文が二つ以上\" };' '  if (text.indexOf(\"--\") >= 0 || text.indexOf(\"/*\") >= 0) return { ok: false, why: \"コメント\" };' '  const words = text.toUpperCase().split(/[^A-Z_]+/).filter(function (w) { return w.length > 0; });' '  if ([\"SELECT\", \"WITH\"].indexOf(words[0]) < 0) return { ok: false, why: \"SELECT ではない\" };' '  const bad = words.filter(function (w) { return BANNED.indexOf(w) >= 0; });' '  if (bad.length) return { ok: false, why: \"禁止の語 \" + bad[0] };' '  return { ok: true, sql: \"SELECT * FROM (\" + text + \") LIMIT \" + LIMIT };' '}' >> guard.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ガードのスクリプトを書く、その 3

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function runOne(sql) {' '  const out = spawnSync(process.execPath, [\"--experimental-sqlite\", \"--no-warnings\", \"runone.js\", sql],' '    { encoding: \"utf8\", timeout: 2000 });' '  return out.error ? \"停止: 2 秒を超えた\" : out.stdout.trim();' '}' 'function run(sql) {' '  const g = guard(sql);' '  return g.ok ? runOne(g.sql) : \"拒否: \" + g.why;' '}' 'const N = \"WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n\";' >> guard.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

ガードのスクリプトを書く、その 4

コマンド
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' '  const tries = [\"SELECT title FROM books WHERE year > 2020\", \"SELECT title FROM books; DROP TABLE loans\",' '    \"DELETE FROM loans WHERE returned = 1\", N + \") SELECT COUNT(*) FROM n\",' '    N + \" WHERE i < 1000) SELECT i FROM n\"];' '  tries.forEach(function (sql, i) { console.log((i + 1) + \" \" + run(sql)); });' '  console.log(\"6 ガードなし、読み取り専用のファイル: \" + runOne(\"DELETE FROM loans\"));' '  const q = \"books(id, title, year)\n質問: 2020 年より後に出版された本は、どれですか。\";' '  const sample = \"SELECT title FROM books WHERE year > 2020;\";' '  const reply = await ask([{ role: \"system\", content: \"SQLite の SELECT 文を一つだけ答えてください。ほかには何も書かないでください。\" },' '    { role: \"user\", content: q }], sample, 120);' '  const sql = sqlOf(reply.text);' '  console.log(\"モデルが書いたクエリ: \" + sql);' '  console.log(\"  ガードを通した結果: \" + run(sql));' '}' 'main();' >> guard.js"

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

六つのクエリを鍵に通す

コマンド
docker exec -w /home/node/project agent node --experimental-sqlite --no-warnings guard.js

ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。

Run の出力

行意味
1 3 行: [["ガラスの港"],["静かなエンジン"],["正午の北"]]通したクエリ
2 拒否: 文が二つ以上隠れた DROP
3 拒否: SELECT ではない書き込み
4 停止: 2 秒を超えた終わらないクエリ
5 5 行: [[1],[2],[3],[4],[5]]1,000 行を五行に切った
6 ガードなし、読み取り専用のファイル: エラー: attempt to write a readonly database最後の鍵が効いている

学生が入力するセルは、このプレビューでは空白です。

このラボとソースについて: AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

授業で使いますか?このモジュールを学生に割り当てると、教員用ガイド、自動採点つきの課題、学生ごとのラボを使えます。