Skip to content
Open
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
8 changes: 8 additions & 0 deletions deploy/agami.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,11 @@ AGAMI_ADMIN_PASSWORD=
# LATERAL source. This is the safe default. `warn` logs and allows instead; use it only as a
# temporary staged-rollout escape hatch, never as the shipped setting.
# AGAMI_SQL_UNSCOPABLE_POSTURE=enforce

# Optional: SQL resource limits — bound a runaway query (a recursive CTE, a cartesian product, an
# unbounded scan). Both are enforced at the executor, so every SQL surface inherits them.
# AGAMI_SQL_TIMEOUT_S=30 # per-statement timeout in seconds; a query that exceeds it is cancelled
# # (server-side where the engine supports it) and refused. Default 30;
# # keep it below ~240 (the executor's own process wall).
# AGAMI_SQL_MAX_ROWS=1000 # max rows returned per query; a larger result is truncated and flagged,
# # never silently cut. Raise it for bigger pulls. Default 1000.
311 changes: 253 additions & 58 deletions packages/agami-core/src/execute_sql.py

Large diffs are not rendered by default.

311 changes: 253 additions & 58 deletions plugins/agami/lib/execute_sql.py

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions plugins/agami/skills/agami-deploy/bundle/agami.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ AGAMI_ADMIN_PASSWORD=
# LATERAL source. This is the safe default. `warn` logs and allows instead; use it only as a
# temporary staged-rollout escape hatch, never as the shipped setting.
# AGAMI_SQL_UNSCOPABLE_POSTURE=enforce

# Optional: SQL resource limits — bound a runaway query (a recursive CTE, a cartesian product, an
# unbounded scan). Both are enforced at the executor, so every SQL surface inherits them.
# AGAMI_SQL_TIMEOUT_S=30 # per-statement timeout in seconds; a query that exceeds it is cancelled
# # (server-side where the engine supports it) and refused. Default 30;
# # keep it below ~240 (the executor's own process wall).
# AGAMI_SQL_MAX_ROWS=1000 # max rows returned per query; a larger result is truncated and flagged,
# # never silently cut. Raise it for bigger pulls. Default 1000.
3 changes: 3 additions & 0 deletions tests/test_ace044_bounded_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ def __enter__(self):
def __exit__(self, *a):
return False

def cancel(self): # the per-statement watchdog's cancel primitive (no-op here — never fires)
pass

def close(self):
pass

Expand Down
22 changes: 22 additions & 0 deletions tests/test_execute_sql_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,28 @@ def test_refusal_line_is_found_among_interleaved_notices(monkeypatch):
assert env["status"] == "refused" and env["refusal"]["kind"] == "sensitive_columns"


def test_resource_limit_refusal_discards_partial_stdout(monkeypatch):
# The per-statement timeout emits a {"refusal": {"kind": "resource_limit"}} line from the
# executor. Both transports (stdio + HTTP) funnel through tool_execute_sql, so this single relay
# is the both-surfaces guarantee. Critically: a streaming engine may have flushed a partial CSV
# (e.g. the header row) to stdout before the cancel — the relay must DISCARD it on the non-zero
# exit, so a killed query is a refused Envelope with no data. Feed partial stdout to pin that.
stderr = json.dumps(
{
"refusal": {
"kind": "resource_limit",
"reason": "the query exceeded the 30s statement timeout and was cancelled",
"remediation": "Narrow the query, or raise AGAMI_SQL_TIMEOUT_S (currently 30s).",
}
}
)
env = _run(monkeypatch, _Proc(1, stdout="n\n1\n2\n", stderr=stderr))
assert env["status"] == "refused"
assert env["refusal"]["kind"] == "resource_limit"
assert "data" not in env # the partial CSV on stdout is discarded — no partial data leaks
assert env["audit_id"]


def test_bare_operational_failure_is_classified_by_exit_code(monkeypatch):
# Exit 5 = SQL execution error with no {"refusal"} line → classified as 'syntax' by exit code.
env = _run(monkeypatch, _Proc(5, stderr='relation "x" does not exist'))
Expand Down
Loading
Loading