ffp-sql-sandbox 0.1.1 → 0.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/README.md +132 -106
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
# ffp-sql-sandbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**From First Principle** — when an LLM (or any untrusted caller) wants to run SQL, don’t trust a clever prompt. Trust a small set of checks you can name, test, and refuse to weaken.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Install
|
|
5
|
+
`ffp-sql-sandbox` is a Node.js library for **read-only SQL execution with hard limits**: a fail-fast `validateSql`, plus a one-shot Docker `executeSql` that enforces resource caps, dual timeouts, streaming row/byte ceilings, and a required host allowlist.
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
8
|
pnpm add ffp-sql-sandbox
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Package: [npmjs.com/package/ffp-sql-sandbox](https://www.npmjs.com/package/ffp-sql-sandbox) · Org: [FFP Tech Lab](https://github.com/FFP-Tech-Lab)
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
---
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner@sha256:13cc50f33c2d00a9ae464f3742c49a18a6b2750fdc39c23d68022476c79171a9
|
|
19
|
-
# tags :v1 and :0.1.0 point at the same image
|
|
20
|
-
docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1
|
|
15
|
+
## Why this exists
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
docker build -t ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1 ./sandbox
|
|
24
|
-
```
|
|
17
|
+
Most “AI → SQL” stacks fail the same way: the model produces a string, something runs it, and safety is a pile of regexes plus hope. That feels productive until the first write, SSRF, or unbounded result set.
|
|
25
18
|
|
|
26
|
-
|
|
19
|
+
We built this library around a different bet:
|
|
27
20
|
|
|
28
|
-
|
|
21
|
+
1. **Name the proof.** If you can’t point at *what* stops a mutation or an unbounded read, you don’t have a sandbox — you have vibes.
|
|
22
|
+
2. **Keep the surface small.** Two calls. No query wizard, no schema sync, no NL layer. Host apps own product UX.
|
|
23
|
+
3. **Refuse soft knobs that erase the threat model.** No “just allow this DML prefix.” No password in container env. No floating `:latest` as the default trust root.
|
|
24
|
+
|
|
25
|
+
FFP Tech Lab ships primitives you can reason about. This is one of them.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Design principles
|
|
30
|
+
|
|
31
|
+
| Principle | What it means here |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| Proof over ceremony | Safety claims map to concrete mechanisms (read-only DB role, container limits, streaming caps, host allowlist) — not to `validateSql` returning `{ ok: true }`. |
|
|
34
|
+
| Fail closed | Empty allowlist denies all. Missing Docker fails loudly. Soft “fall back to in-process SQL” is not an option. |
|
|
35
|
+
| Streaming limits, not post-hoc truncate | `maxRows` / `maxBytes` apply as rows arrive. Buffering the full result and then slicing is not the limits implementation. |
|
|
36
|
+
| Secrets stay out of `Env` | The password goes to tmpfs (`/run/secrets/db_password`). SQL rides stdin JSON. `docker inspect` should not print credentials. |
|
|
37
|
+
| Honest non-goals | We do not claim absolute network isolation, a full SQL AST, or that regex is authorization. |
|
|
38
|
+
|
|
39
|
+
`validateSql` is a **cheap fail-fast** for obvious writes and multi-statement junk — not the proof. Treat `{ ok: true }` as “not obviously broken,” never as “safe to run outside this sandbox.”
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
Docker is a **hard dependency** of `executeSql`.
|
|
29
46
|
|
|
30
47
|
```ts
|
|
31
48
|
import {
|
|
@@ -35,102 +52,105 @@ import {
|
|
|
35
52
|
DEFAULT_SANDBOX_IMAGE,
|
|
36
53
|
} from 'ffp-sql-sandbox'
|
|
37
54
|
|
|
38
|
-
validateSql(
|
|
55
|
+
const check = validateSql('SELECT 1')
|
|
56
|
+
if (!check.ok) throw new Error(check.reason)
|
|
39
57
|
|
|
40
|
-
executeSql(
|
|
41
|
-
sql:
|
|
58
|
+
const result = await executeSql({
|
|
59
|
+
sql: 'SELECT 1 AS n',
|
|
42
60
|
connection: {
|
|
43
|
-
type: 'postgres'
|
|
44
|
-
host:
|
|
45
|
-
port:
|
|
46
|
-
user:
|
|
47
|
-
password:
|
|
48
|
-
database:
|
|
49
|
-
}
|
|
50
|
-
hostAllowlist:
|
|
51
|
-
limits?: Partial<
|
|
52
|
-
image?: string // untrusted override;
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
type: 'postgres', // or 'mysql'
|
|
62
|
+
host: 'db.internal',
|
|
63
|
+
port: 5432,
|
|
64
|
+
user: 'readonly_user',
|
|
65
|
+
password: process.env.DB_PASSWORD!,
|
|
66
|
+
database: 'app',
|
|
67
|
+
},
|
|
68
|
+
hostAllowlist: ['db.internal'],
|
|
69
|
+
// limits?: Partial<typeof DEFAULT_SANDBOX_LIMITS>
|
|
70
|
+
// image?: string // untrusted override; prefer DEFAULT_SANDBOX_IMAGE
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
console.error(result.code, result.error)
|
|
75
|
+
} else {
|
|
76
|
+
console.log(result.data.columns, result.data.rows)
|
|
77
|
+
}
|
|
57
78
|
```
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`resolveSandboxDbHost` is **not** exported. Loopback rewrite for container DNS, if needed, stays private (and in the host adapter — see INTEGRATION.md).
|
|
80
|
+
Wire framework DI, secret decryption, and result mapping in the host — see [INTEGRATION.md](./INTEGRATION.md).
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
### Runner image
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
- A multi-tenant auth / connection-pool product
|
|
67
|
-
- Claiming absolute network isolation (the runner uses Docker `bridge` so it can reach the allowlisted database)
|
|
68
|
-
- Exporting `resolveSandboxDbHost` as public API
|
|
69
|
-
- Making `validateSql` a proof of safety (it is a cheap reject, not a guarantee)
|
|
84
|
+
Default runner is on GHCR, digest-pinned as `DEFAULT_SANDBOX_IMAGE`:
|
|
70
85
|
|
|
71
|
-
|
|
86
|
+
```bash
|
|
87
|
+
docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner@sha256:13cc50f33c2d00a9ae464f3742c49a18a6b2750fdc39c23d68022476c79171a9
|
|
88
|
+
# convenience tags (same image): :v1 and :0.1.0 — image tags, not the npm package version
|
|
89
|
+
docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1
|
|
90
|
+
```
|
|
72
91
|
|
|
73
|
-
|
|
92
|
+
Local build is an **untrusted** override (`image` option) — pin and review if you use it:
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
| 2. Container limits + dual timeout | Memory, nano-CPUs, PID cap, dropped capabilities. **Dual timeout**: the runner sets DB `statement_timeout` / `MAX_EXECUTION_TIME` to `timeoutMs`, and the host kills the container after `timeoutMs + 2000ms` if it is still running. |
|
|
79
|
-
| 3. Streaming `maxRows` / `maxBytes` | The runner applies limits **as rows arrive** and stops/cancels instead of buffering the full result. The host consumer is a backstop on the live Docker attach stream. **Demux-then-truncate of a completed log buffer is not the limits implementation.** |
|
|
80
|
-
| 4. `connection.host` allowlist | SSRF defense. `hostAllowlist` is required. Hosts not on the list are rejected **before** any container is created. No arbitrary hosts, no glob, no CIDR in v1 — exact match after trim + case-insensitive compare. |
|
|
94
|
+
```bash
|
|
95
|
+
docker build -t ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1 ./sandbox
|
|
96
|
+
```
|
|
81
97
|
|
|
82
|
-
|
|
98
|
+
---
|
|
83
99
|
|
|
84
|
-
|
|
100
|
+
## What actually provides the proof
|
|
85
101
|
|
|
86
|
-
|
|
|
102
|
+
| Guard | Role |
|
|
87
103
|
| --- | --- |
|
|
88
|
-
| `
|
|
89
|
-
|
|
|
104
|
+
| 1. Read-only DB role | **You** connect as a read-only role (and/or `READ ONLY` transaction). The runner also prefers a read-only session and sets `statement_timeout` / `MAX_EXECUTION_TIME`. The role is still the real write barrier. |
|
|
105
|
+
| 2. Container limits + dual timeout | Memory, CPU, PID caps. DB timeout **and** host kill at `timeoutMs + 2000ms`. |
|
|
106
|
+
| 3. Streaming `maxRows` / `maxBytes` | Stop as data arrives; result may set `truncated: true`. |
|
|
107
|
+
| 4. `hostAllowlist` | Required. Exact match (trim, case-insensitive). Empty list → deny all. Checked **before** any container is created (SSRF). |
|
|
90
108
|
|
|
91
|
-
|
|
109
|
+
### Default limits
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
| Limit | Default |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `timeoutMs` | `10000` |
|
|
114
|
+
| `memoryMb` | `128` |
|
|
115
|
+
| `nanoCpus` | `500000000` (0.5 CPU) |
|
|
116
|
+
| `maxRows` | `1000` |
|
|
117
|
+
| `maxBytes` | `1000000` |
|
|
98
118
|
|
|
99
|
-
|
|
100
|
-
2. **Package settings** → **Danger Zone** → **Change visibility** → **Public**.
|
|
101
|
-
3. Org owners can allow public package *creation* under **Organization settings → Packages → Package creation**.
|
|
119
|
+
### `hostAllowlist`
|
|
102
120
|
|
|
103
|
-
|
|
121
|
+
- Required on every `executeSql` call.
|
|
122
|
+
- Allowlist the host string you pass in `connection.host`.
|
|
123
|
+
- The library may privately rewrite loopback (`localhost`, `127.0.0.1`, …) to `host.docker.internal` *after* the allowlist check. That helper is **not** a public export.
|
|
104
124
|
|
|
105
|
-
|
|
125
|
+
### Default image vs custom `image`
|
|
106
126
|
|
|
107
|
-
|
|
|
108
|
-
| --- | --- |
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `nanoCpus` | `500000000` (0.5 CPU) | Container CFS quota |
|
|
112
|
-
| `maxRows` | `1000` | Streaming row cap; result may set `truncated: true` |
|
|
113
|
-
| `maxBytes` | `1000000` | Streaming serialized-row / stdout byte cap; result may set `truncated: true` |
|
|
127
|
+
| Image | Trust |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `DEFAULT_SANDBOX_IMAGE` (`…@sha256:…`) | Supported, digest-pinned runner. |
|
|
130
|
+
| `image` override | **Untrusted.** A custom image can ignore caps or leak secrets. |
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
---
|
|
116
133
|
|
|
117
|
-
-
|
|
118
|
-
- Empty list → `HOST_NOT_ALLOWED` (deny all).
|
|
119
|
-
- Compared against the **caller-supplied** `connection.host` (trimmed, case-insensitive). The library may rewrite loopback (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) to `host.docker.internal` *after* the allowlist check so the container can reach a DB on the Docker host. That rewrite is private and not a public API.
|
|
120
|
-
- Put the host you actually pass in on the list (e.g. `db.internal` or `localhost`). Do not accept user-controlled hosts without your own allowlist.
|
|
134
|
+
## Non-goals
|
|
121
135
|
|
|
122
|
-
|
|
136
|
+
- Natural language → SQL, guidance UIs, or schema sync
|
|
137
|
+
- A full SQL AST as a v1 requirement
|
|
138
|
+
- Multi-tenant auth / connection-pool product
|
|
139
|
+
- Claiming absolute network isolation (bridge networking reaches the allowlisted DB by design)
|
|
140
|
+
- Exporting host-rewrite helpers as public API
|
|
141
|
+
- Treating `validateSql` as authorization
|
|
123
142
|
|
|
124
|
-
`validateSql`
|
|
143
|
+
### Known `validateSql` bypass classes (fail-fast only)
|
|
125
144
|
|
|
126
|
-
| Class | Example |
|
|
145
|
+
| Class | Example | Real guard |
|
|
127
146
|
| --- | --- | --- |
|
|
128
|
-
| Writes that
|
|
129
|
-
|
|
|
130
|
-
| Comment / encoding tricks | Leading
|
|
131
|
-
| Expensive reads | `SELECT * FROM huge_table` |
|
|
132
|
-
|
|
|
133
|
-
|
|
147
|
+
| Writes that look like `SELECT` | `SELECT … INTO …`, side-effect functions | Read-only role |
|
|
148
|
+
| Keyword false positives | `… WHERE action = 'UPDATE'` | Not an AST |
|
|
149
|
+
| Comment / encoding tricks | Leading `--`, homoglyphs | Not a parser |
|
|
150
|
+
| Expensive reads | `SELECT * FROM huge_table` | Timeout + streaming caps + role |
|
|
151
|
+
| SQL-level network | `dblink`, FDW, `LOAD_FILE` | Locked-down role (allowlist is on `connection.host`) |
|
|
152
|
+
|
|
153
|
+
---
|
|
134
154
|
|
|
135
155
|
## Error codes
|
|
136
156
|
|
|
@@ -143,48 +163,54 @@ New organization packages on `ghcr.io` default to **private**. Anonymous `docker
|
|
|
143
163
|
| `DOCKER_UNAVAILABLE` | `executeSql` |
|
|
144
164
|
| `TIMEOUT` | `executeSql` |
|
|
145
165
|
| `INVALID_LIMITS` | `executeSql` |
|
|
146
|
-
| `IMAGE_UNPINNED` | `executeSql`
|
|
147
|
-
| `IMAGE_UNAVAILABLE` | `executeSql`
|
|
166
|
+
| `IMAGE_UNPINNED` | `executeSql` |
|
|
167
|
+
| `IMAGE_UNAVAILABLE` | `executeSql` |
|
|
148
168
|
| `UNSUPPORTED_DIALECT` | `executeSql` |
|
|
149
169
|
| `EXECUTION_FAILED` | `executeSql` |
|
|
150
170
|
|
|
171
|
+
---
|
|
172
|
+
|
|
151
173
|
## Scripts
|
|
152
174
|
|
|
153
175
|
```bash
|
|
154
|
-
pnpm test
|
|
176
|
+
pnpm test
|
|
155
177
|
pnpm typecheck
|
|
156
178
|
pnpm build
|
|
157
179
|
```
|
|
158
180
|
|
|
181
|
+
---
|
|
182
|
+
|
|
159
183
|
## Release
|
|
160
184
|
|
|
161
|
-
Library (npm) and runner image (GHCR)
|
|
185
|
+
Library (npm) and runner image (GHCR) use **separate** workflows.
|
|
162
186
|
|
|
163
|
-
### npm
|
|
187
|
+
### npm
|
|
164
188
|
|
|
165
189
|
Workflow: [`.github/workflows/publish-npm.yml`](./.github/workflows/publish-npm.yml).
|
|
166
190
|
|
|
167
|
-
|
|
191
|
+
1. Bump `version` in `package.json` (keep in sync with the git tag).
|
|
192
|
+
2. Repo secret **`NPM_TOKEN`**: npm granular token with publish + bypass 2FA.
|
|
193
|
+
3. Merge to `main`, then `git tag vX.Y.Z && git push origin vX.Y.Z` (or `workflow_dispatch` / GitHub Release).
|
|
194
|
+
4. Already-published versions are skipped (no force republish).
|
|
195
|
+
|
|
196
|
+
Published versions: [npm](https://www.npmjs.com/package/ffp-sql-sandbox).
|
|
168
197
|
|
|
169
|
-
|
|
170
|
-
2. Add repo secret **`NPM_TOKEN`**: **Settings → Secrets and variables → Actions → New repository secret**. Use an npm [granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens) with permission to **publish** `ffp-sql-sandbox` and **bypass 2FA**. The workflow maps it to `NODE_AUTH_TOKEN` / `.npmrc`; do not commit a token.
|
|
171
|
-
3. Merge the version bump to `main`, then either:
|
|
172
|
-
- **Usual path:** `git tag v0.1.1 && git push origin v0.1.1`
|
|
173
|
-
- **Actions tab:** run **Publish npm** (`workflow_dispatch`) on the intended ref
|
|
174
|
-
- **GitHub Release:** publish a release for tag `vX.Y.Z`
|
|
175
|
-
4. The job runs `pnpm install`, `pnpm test`, `pnpm build`, then `pnpm publish --access public`. A tag that does not match `package.json` version fails.
|
|
198
|
+
### GHCR runner
|
|
176
199
|
|
|
177
|
-
`
|
|
200
|
+
Workflow: [`.github/workflows/publish-runner.yml`](./.github/workflows/publish-runner.yml).
|
|
178
201
|
|
|
179
|
-
|
|
202
|
+
- Triggers on `sandbox/**` changes to `main` or `workflow_dispatch`.
|
|
203
|
+
- Pushes `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1` (and a convenience tag such as `:0.1.0` for the image line — **not** the npm semver).
|
|
204
|
+
- After a runner change, update `DEFAULT_SANDBOX_IMAGE` to the new digest from the job summary.
|
|
180
205
|
|
|
181
|
-
|
|
206
|
+
Org package visibility must allow public packages for anonymous `docker pull`. If pull returns unauthorized, check [org Packages settings](https://github.com/orgs/FFP-Tech-Lab/packages) and the package’s visibility.
|
|
182
207
|
|
|
183
|
-
|
|
184
|
-
- Pushes `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1` and `:0.1.0`, and prints the digest in the job summary.
|
|
185
|
-
- After a runner change, update `DEFAULT_SANDBOX_IMAGE` to that `sha256:…` pin.
|
|
186
|
-
- Anonymous `docker pull` needs the package **public** — see [GHCR package visibility](#ghcr-package-visibility).
|
|
208
|
+
---
|
|
187
209
|
|
|
188
210
|
## License
|
|
189
211
|
|
|
190
212
|
MIT.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
*Built at [FFP Tech Lab](https://github.com/FFP-Tech-Lab) — From First Principle: build from fundamentals, ship with clarity.*
|