Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,8 @@ Both require an **admin** role (enforced in-handler in addition to the middlewar

Returns audit events. Optional query params: `type` (filter by event type), `limit` (default 100). Response: `{ "events": [], "total": 0 }`. `POST /api/admin/audit` appends an event (user auto-filled from the session).

Events of type `agent_operation` come from the agent execution path (#328) and additionally carry `correlationId` — the id joining one execution's policy-decision event to its execution-outcome event (a refused operation emits the decision event only, with an `agent_*` reason code). It is opaque and per execution: it identifies neither a user nor a session. On the authoritative stdout line the same value appears as `correlation_id`, and it is omitted entirely from every event that does not set it.

#### POST /api/admin/fleet-health

Body `{ "connections": [...] }`; returns per-connection health `{ "results": [{ connectionId, status, latencyMs, ... }] }`. `400` if `connections` is missing. `401` with no session, `403` with a session that is not an admin — see the note above.
Expand Down
87 changes: 87 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,90 @@ reading the stored row before every write and preserving an existing envelope wh
value is absent - which would also silently resurrect a password the user deliberately cleared, a
worse bug than the one it fixes. Done when a design is found that distinguishes "the client never
had this value" from "the client cleared this value" without adding a field to the stored shape.

---

## Agent M1 deferrals (#328)

Each of these was decided while building the operation/policy layer, not overlooked. Delete an
entry when the work lands.

### A1. A SQLite agent statement can block the runtime for its whole duration

`src/lib/db/providers/sql/sqlite.ts`'s `queryReadOnly` enforces `statementTimeoutMs` as a
post-execution deadline: the result of an overrunning statement is refused, but the statement is
never preempted. SQLite has no transaction-local statement timeout, and neither `bun:sqlite` nor
`node:sqlite` exposes `sqlite3_interrupt` or a progress handler, so there is nothing to preempt it
with. Because both drivers are synchronous, a hostile recursive CTE therefore blocks the whole
runtime while it runs. This is the same property as the normal SQLite query path, but the input
source is different in kind: there the SQL comes from an authenticated operator, here it comes from
an agent. Done when either driver exposes an interrupt/progress hook, or agent SQLite execution
moves to a worker that can be killed on deadline.

### A2. `VACUUM INTO` can create an empty file at an agent-chosen path

The SQLite agent profile's read-only open governs the target database file only; `VACUUM INTO
'<path>'` writes to a *different* file and is refused by `PRAGMA query_only`, which the profile
re-asserts and verifies before every statement. SQLite creates the destination file before the
write is refused, so a zero-byte file can still appear at any path the server process can write to
(no data reaches it - asserted on both adapters by file size). Closing this needs an authorizer
callback, which `bun:sqlite` does not expose at all. Done when a control exists on both adapters,
or when agent SQLite targets are constrained to an allowlisted directory (related: the base-dir
allowlist proposed in issue #125).

### A3. Out-of-scope READS have no database-native control on either provider

Both agent profiles bound what a statement can WRITE with a database-native control. What it can
READ is bounded only by the policy layer's declared-target allowlist plus the input-stage statement
guard - and both of those read SQL, which this milestone treats as defense in depth rather than a
boundary:

- SQLite: `ATTACH` of an *existing* file succeeds on a read-only handle and its rows become
readable. No authorizer exists on `bun:sqlite`, so there is nothing engine-side to stop it
(docs/providers/sqlite.md section 12.3).
- PostgreSQL: the read-only role can read every table its grants allow, whatever catalog or schema
the request declared. Per-table `SELECT` grants are the only real bound
(docs/providers/postgres.md section 12.3).

Done when out-of-scope reads are refused by something that does not read SQL - a per-target grant
set generated for the agent role, an allowlisted directory for SQLite targets, or an authorizer both
adapters expose.

### A4. No wall-clock deadline bounds an agent run

`maxTotalRunMs` bounds the DATABASE time a run consumes: the execution layer reports each completed
call's elapsed time and the tracker sums them. Nothing bounds the run's wall clock. Time between
calls is not counted, so a run that spends minutes in model latency or waiting on a caller stays
inside its budget indefinitely; parallel calls each contribute their own duration, so the sum can
exceed real elapsed time; and a call admitted just under the limit can still overrun it by up to one
statement timeout.

This is deliberate at this layer. `ExecutionBudgetTracker` has no clock so that budget accounting
stays deterministic under test, and database time is the bound that actually protects the database.
The missing control is a run-level one: a runaway agent is bounded by how long it may run, not by
how much database time it used.

Done when the run loop owns a monotonic deadline per run, refuses to admit a call that cannot finish
inside the remaining time, and clamps each effective statement timeout to what is left. That belongs
with the WorkflowAgent run loop in M2 (#329), which is the first component that owns a run's
lifetime.

### A5. The PostgreSQL profile's regression tests model the server rather than run one

`tests/integration/db/postgres-provider.test.ts` proves the read-only profile against a stateful
hand-written engine mock. Every rule it models was verified against a live PostgreSQL 18 while the
profile was built - read-only transaction rejection by engine state, the extended-protocol refusal of
multi-command strings, `SET TRANSACTION READ WRITE` really relaxing the transaction, advisory locks
surviving rollback - and the mock encodes them faithfully enough that bypass attempts fail on real
modeled behavior (a write actually landing) rather than on protocol metadata.

What it cannot catch is a future regression on the other side of the seam: a driver change, a server
version that behaves differently, or a `pg` option that stops meaning what it meant. The assertions
would stay green because the mock, not the server, defines the semantics. The repository's
integration suites are mock-based by convention and CI runs no database service; the only real
engine in the pipeline today is the throwaway PostgreSQL container behind
`loop/scripts/functional-smoke.sh`.

Done when a container-backed test proves, against a supported PostgreSQL, that a direct write and a
multi-command escape are rejected through the profile under the resolved role. The cheapest path is
extending the functional-smoke container rather than adding a service to every CI test job.
23 changes: 23 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Two consequences worth stating before the table:
| 3.1 | Credentials are encrypted at rest in the server-side store | Implemented | [`src/lib/storage/encryption.ts`](../src/lib/storage/encryption.ts), [`src/lib/storage/connection-secrets.ts`](../src/lib/storage/connection-secrets.ts), [`src/lib/storage/encrypting-provider.ts`](../src/lib/storage/encrypting-provider.ts), [`src/lib/storage/factory.ts`](../src/lib/storage/factory.ts) | [`tests/security/credential-at-rest.test.ts`](../tests/security/credential-at-rest.test.ts), [`tests/isolated/factory-singleton.test.ts`](../tests/isolated/factory-singleton.test.ts), [`tests/integration/storage/sqlite-credential-encryption.test.ts`](../tests/integration/storage/sqlite-credential-encryption.test.ts) |
| 3.2 | Every authoritative (server-generated) audit event is emitted as one structured JSON line on stdout | Implemented | [`src/lib/audit.ts`](../src/lib/audit.ts) | [`tests/security/audit-redaction.test.ts`](../tests/security/audit-redaction.test.ts), [`tests/security/audit-type-safety.test.ts`](../tests/security/audit-type-safety.test.ts) |
| 3.3 | This page is checked against the repository on every build | Implemented | [`scripts/security-check.mjs`](../scripts/security-check.mjs) | [`tests/unit/security-check.test.ts`](../tests/unit/security-check.test.ts) |
| 3.4 | A statement submitted on the agent execution path cannot write, change schema, reach another database, load code, or run the executing form of EXPLAIN | Partial | [`src/lib/db/operations/policy.ts`](../src/lib/db/operations/policy.ts), [`src/lib/db/operations/statement-guard.ts`](../src/lib/db/operations/statement-guard.ts), [`src/lib/db/providers/sql/postgres.ts`](../src/lib/db/providers/sql/postgres.ts), [`src/lib/db/providers/sql/sqlite.ts`](../src/lib/db/providers/sql/sqlite.ts) | [`tests/security/agent-statement-boundary.test.ts`](../tests/security/agent-statement-boundary.test.ts), [`tests/integration/db/postgres-provider.test.ts`](../tests/integration/db/postgres-provider.test.ts), [`tests/integration/db/sqlite-provider.test.ts`](../tests/integration/db/sqlite-provider.test.ts) |
| 3.5 | Every agent-path operation — allowed, denied, or held for approval — is audited under one correlation id, and its result is released with the run | Partial | [`src/lib/db/operations/execution.ts`](../src/lib/db/operations/execution.ts), [`src/lib/db/operations/artifacts.ts`](../src/lib/db/operations/artifacts.ts), [`src/lib/audit.ts`](../src/lib/audit.ts) | [`tests/security/agent-execution-audit.test.ts`](../tests/security/agent-execution-audit.test.ts), [`tests/unit/db/operations/execution.test.ts`](../tests/unit/db/operations/execution.test.ts), [`tests/unit/db/operations/artifacts.test.ts`](../tests/unit/db/operations/artifacts.test.ts), [`tests/api/db/query.test.ts`](../tests/api/db/query.test.ts) |

## Notes on individual rows

Expand Down Expand Up @@ -76,6 +78,27 @@ close that gap; `postgres` deployments do not share this exposure by default. Fu
stdout, and that is deliberate: its body is client-supplied, so giving it the authoritative channel
would let an admin session forge an indistinguishable log line.

**3.4.** WRITES are refused by the database itself — a PostgreSQL read-only transaction carrying
exactly one statement, run by a role verified at open to hold neither superuser nor any
server-file/program privilege (a read-only transaction does not stop `COPY … TO PROGRAM`); and a
separate SQLite read-only open with `PRAGMA query_only` re-asserted before every statement. Reading
the SQL is defense in depth only, never the boundary. **Partial** for two reasons: out-of-scope
READS have no database-native control on either provider (only the declared-target allowlist, the
statement guard, and whatever the role's grants bound — see
[`docs/BACKLOG.md`](./BACKLOG.md) A3), and no route or agent runtime reaches this layer yet, so it
governs nothing in a shipped release until the agent surface lands.

**3.5.** Each execution emits a decision event and, once allowed, an execution-outcome event
sharing one server-generated correlation id; a denial emits the decision event alone with a typed
`agent_*` reason. The decision is recorded **before** the provider is called and the emission is not
wrapped in a try/catch, so an execution that cannot be audited does not run. What the events carry
is deliberately narrow: the registry-resolved operation id, an `agent:<role>` actor label, the
outcome, the reason code, the elapsed time and the correlation id — never the statement, the
agent-supplied operation id, the session identifier or a driver message. Results live in an
in-process artifact store released with the run (TTL and an entry cap are backstops), so no agent
result is written at rest. **Partial** for the same two reasons as 3.4: no route reaches this layer
yet, and the in-app ring buffer is per-process — the stdout line remains the authoritative record.

## Known limits

These are real, current, and not oversights. Each is a decision with a reason.
Expand Down
8 changes: 5 additions & 3 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,19 +348,21 @@ nothing to switch on and nothing to configure.

### What is encrypted

Six fields, all on a saved connection:
Seven fields, all on a saved connection:

| Field | What it is |
|-------|------------|
| `password` | The database password |
| `connectionString` | A URL that can embed `user:password@` |
| `agentPassword` | The optional least-privilege agent-profile password (#328) |
| `ssl.clientKey` | The TLS client private key |
| `sshTunnel.password` | The SSH password |
| `sshTunnel.privateKey` | The SSH private key |
| `sshTunnel.passphrase` | The passphrase that unlocks the key above |

Everything else stays readable, deliberately: `host`, `port`, `user`, `database`, `name` and the
TLS certificates (`ssl.caCert`, `ssl.clientCert` — certificates are public by construction). An
Everything else stays readable, deliberately: `host`, `port`, `user`, `agentUser`, `database`,
`name` and the TLS certificates (`ssl.caCert`, `ssl.clientCert` — certificates are public by
construction). An
operator holding a dump has to be able to answer "which of my databases is in here"; that is
incident response, not a leak. No other collection is touched — `history` and `saved_queries` hold
SQL text, which is the product's data rather than its secrets.
Expand Down
Loading
Loading