Por qué el SQL del modelo solo lee
Ejecutar sin peligro el SQL de un modelo es dejarle leer sin cambiar nunca nada. Un modelo puede escribir DELETE o DROP por error, o porque la pregunta de un usuario lo ha engañado.
El módulo 4 enseña a cambiar datos a propósito. Esta lección pone tres cerrojos delante de la consulta de un modelo: un guardián, unos límites y una conexión de solo lectura.
Al final de esta lección
Nota
Puedo ejecutar la consulta de un modelo de forma que solo pueda leer, poco a poco.
Palabras clave
| Palabra | Qué significa | Ejemplo |
|---|---|---|
| Guardián | código que rechaza una consulta antes de que se ejecute | |
| Sentencia | una orden SQL completa | SELECT title FROM books |
| Límite de filas | el mayor número de filas que puede devolver una consulta | |
| Tiempo máximo | lo más que puede durar una consulta | |
| Solo lectura | poder leer filas sin cambiarlas nunca |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Acepta un solo SELECT y nada más
El guardián ordena la consulta y quita un punto y coma final. Si aún queda un punto y coma, hay una segunda sentencia, y la rechaza.
La primera palabra tiene que ser SELECT o WITH. Cualquier palabra de escritura, como DELETE, DROP o UPDATE, se rechaza esté donde esté. También un comentario, porque puede esconder texto.
Ejemplo resuelto: lo que dice el guardián
| Consulta | Veredicto | Motivo |
|---|---|---|
| SELECT title FROM books WHERE year > 2020 | Aceptada | Un solo SELECT |
| SELECT title FROM books; DROP TABLE loans | Rechazada | Más de una sentencia |
| DELETE FROM loans WHERE returned = 1 | Rechazada | No es un SELECT |
| SELECT title FROM books -- nota | Rechazada | Un comentario |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Un límite de filas y de tiempo
Una consulta correcta puede devolver un millón de filas o no terminar nunca. Envuélvela así: SELECT * FROM (la consulta) LIMIT 5. Vuelven como mucho cinco filas.
Ejecútala en un proceso aparte, con un tiempo máximo. Cuando se acaba el tiempo, el proceso se detiene y la página responde en lugar de quedarse colgada.
La solo lectura es el último cerrojo
Un guardián comprueba palabras, y algunas palabras se pueden colar. Por eso abre la base en solo lectura, o conéctate con una cuenta que solo tenga permiso de SELECT.
No ejecutes nunca el SQL de un modelo sobre datos reales con permiso de escritura. Usa una copia de solo lectura: lo peor que puede hacer una mala consulta es tardar.
Un error frecuente
Advertencia
Mucha gente cree que basta con un guardián que comprueba palabras. Un guardián tiene huecos, porque el SQL tiene mil formas de decir lo mismo. Mantén también la conexión en solo lectura: así una consulta que se escape seguirá sin poder escribir.
Escribe el ayudante del modelo, parte 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// Los tokens de una respuesta, o null si no trae ninguno.' 'function count(data, name) {' ' return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' '// Una respuesta puede envolver la consulta en un bloque delimitado: nos quedamos con el interior.' '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"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el ayudante del modelo, parte 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(\"estado \" + res.status);' ' const d = await res.json();' ' return { text: d.choices[0].message.content, inTokens: count(d, \"prompt_tokens\"), outTokens: count(d, \"completion_tokens\"), recorded: false };' ' } catch (error) {' ' console.log(\"Muestra grabada: ningún modelo respondió (\" + error.message + \").\");' ' return { text: sample, inTokens: null, outTokens: null, recorded: true };' ' }' '}' 'module.exports = { ask, sqlOf };' >> model.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe la base de la biblioteca, parte 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// Una biblioteca inventada: seis libros, cuatro socios, nueve préstamos.' 'const BOOKS = [[1, \"La calle de la sal\", 2019], [2, \"Puerto de cristal\", 2021], [3, \"Mapa de la lluvia\", 2015],' ' [4, \"Motores en calma\", 2023], [5, \"Siete teteras\", 2018], [6, \"Al norte del mediodía\", 2022]];' 'const MEMBERS = [[1, \"Alba\"], [2, \"Bruno\"], [3, \"Carmen\"], [4, \"Diego\"]];' '// id, book_id, member_id, month, returned (1 devuelto, 0 aún fuera)' '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"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe la base de la biblioteca, parte 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"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el ejecutor de solo lectura
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// Abierta en solo lectura: ni una consulta que se le escape al guardián puede cambiar una fila.' '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(\"filas \" + rows.length + \": \" + JSON.stringify(rows));' '} catch (error) { console.log(\"error: \" + error.message); }' > runone.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el script del guardián, parte 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"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el script del guardián, parte 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: \"más de una sentencia\" };' ' if (text.indexOf(\"--\") >= 0 || text.indexOf(\"/*\") >= 0) return { ok: false, why: \"un comentario\" };' ' 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: \"no es un SELECT\" };' ' const bad = words.filter(function (w) { return BANNED.indexOf(w) >= 0; });' ' if (bad.length) return { ok: false, why: \"palabra prohibida \" + bad[0] };' ' return { ok: true, sql: \"SELECT * FROM (\" + text + \") LIMIT \" + LIMIT };' '}' >> guard.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el script del guardián, parte 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 ? \"detenida: más de 2 segundos\" : out.stdout.trim();' '}' 'function run(sql) {' ' const g = guard(sql);' ' return g.ok ? runOne(g.sql) : \"rechazada: \" + g.why;' '}' 'const N = \"WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n\";' >> guard.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el script del guardián, parte 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 sin guardián, archivo de solo lectura: \" + runOne(\"DELETE FROM loans\"));' ' await live();' '}' >> guard.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el script del guardián, parte 5
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function live() {' ' const q = \"books(id, title, year)\nPregunta: ¿Qué libros se publicaron después de 2020?\";' ' const sample = \"SELECT title FROM books WHERE year > 2020;\";' ' const reply = await ask([{ role: \"system\", content: \"Responde con una sola consulta SELECT de SQLite y nada más.\" },' ' { role: \"user\", content: q }], sample, 120);' ' const sql = sqlOf(reply.text);' ' console.log(\"el modelo escribió: \" + sql);' ' console.log(\" tras el guardián: \" + run(sql));' '}' 'main();' >> guard.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Pasa seis consultas por los cerrojos
docker exec -w /home/node/project agent node --experimental-sqlite --no-warnings guard.jsSe muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Lo que imprime el Run
| Línea | Qué muestra |
|---|---|
| 1 filas 3: [["Puerto de cristal"],["Motores en calma"],["Al norte del mediodía"]] | Una consulta aceptada |
| 2 rechazada: más de una sentencia | Un DROP escondido |
| 3 rechazada: no es un SELECT | Una escritura |
| 4 detenida: más de 2 segundos | Una consulta que no termina nunca |
| 5 filas 5: [[1],[2],[3],[4],[5]] | 1000 filas reducidas a cinco |
| 6 sin guardián, archivo de solo lectura: error: attempt to write a readonly database | El último cerrojo aguanta |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.