Files
forge/apps/api/tests/test_tools_hardening.py
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

357 lines
15 KiB
Python

"""Hardening tests for the tool executors (robustness-audit fixes).
Covers: retry semantics (idempotency + transient-only default), the shared entitlement gate for
every non-REST kind, reliability parity (rate_limit/cache) for graphql/sql/code via the shared
wrapper, JMESPath projection error markers, SQL read-only/limit/cell-cap hardening, GraphQL
in-band error handling + operationName, mcp being creatable, REST multipart + download-size guard,
and production-default trace redaction.
"""
from __future__ import annotations
import sqlite3
import uuid
import httpx
import pytest
from forge.services.runtime import make_runtime_ctx
from forge.tools import rest as rest_mod
from forge.tools.graphql import GraphQLToolError, execute_graphql
from forge.tools.materialize import materialize_tool
from forge.tools.projection import project_response
from forge.tools.rest import _resolve_retry, _retry_types, _should_retry, execute_rest
from forge.tools.sql import SqlToolError, execute_sql
@pytest.fixture(autouse=True)
def _enable_code_tools(monkeypatch):
from forge.config import settings
monkeypatch.setattr(settings, "enable_code_tools", True)
def _rest_cfg(**extra) -> dict:
return {
"name": f"t_{uuid.uuid4().hex[:8]}",
"kind": "rest_api",
"request": {"method": "GET", "url_template": "https://api.acme.dev/v2/ping", "fields": []},
**extra,
}
# --- Finding 1: retry semantics (transient-only default, idempotency gating, schema default) ----
def test_should_retry_gates_on_idempotency_and_status():
transient = _retry_types([])
# 4xx is never retried by default (regression: HTTPError superclass used to retry it).
resp4 = httpx.Response(404, request=httpx.Request("GET", "https://x"))
err4 = httpx.HTTPStatusError("nf", request=resp4.request, response=resp4)
assert _should_retry(err4, transient, True, "GET", {}) is False
# 5xx IS retried on an idempotent method by the default classification...
resp5 = httpx.Response(503, request=httpx.Request("GET", "https://x"))
err5 = httpx.HTTPStatusError("boom", request=resp5.request, response=resp5)
assert _should_retry(err5, transient, True, "GET", {}) is True
# ...but NOT on a non-idempotent POST unless explicitly opted in.
assert _should_retry(err5, transient, True, "POST", {}) is False
assert _should_retry(err5, transient, True, "POST", {"retry_non_idempotent": True}) is True
def test_resolve_retry_defaults_align_with_schema():
# No retry block => opt-out (no retries), preserving historic behavior.
assert _resolve_retry({})[0] == 0
# A retry block present but max_retries omitted => schema default of 2.
assert _resolve_retry({"retry": {}})[0] == 2
assert _resolve_retry({"retry": {"max_retries": 5}})[0] == 5
async def test_default_retry_does_not_retry_4xx():
calls = {"n": 0}
def handler(req):
calls["n"] += 1
return httpx.Response(404, json={"e": "nope"})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
cfg = _rest_cfg(retry={"max_retries": 3, "initial_delay": 0.001, "jitter": False})
with pytest.raises(httpx.HTTPStatusError):
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert calls["n"] == 1 # a permanent 4xx is not retried
async def test_default_retry_retries_transient_5xx_on_get():
state = {"n": 0}
def handler(req):
state["n"] += 1
return httpx.Response(500 if state["n"] == 1 else 200, json={"ok": state["n"]})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
cfg = _rest_cfg(retry={"max_retries": 2, "initial_delay": 0.001, "jitter": False}) # no retry_on
res = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert state["n"] == 2 and res["status"] == 200
async def test_post_5xx_not_retried_by_default():
calls = {"n": 0}
def handler(req):
calls["n"] += 1
return httpx.Response(500, json={})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
cfg = _rest_cfg(retry={"max_retries": 3, "initial_delay": 0.001, "jitter": False})
cfg["request"]["method"] = "POST"
with pytest.raises(httpx.HTTPStatusError):
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert calls["n"] == 1 # non-idempotent POST is not auto-retried
# --- Finding 4: broken JMESPath projection -> structured error marker (not silent full payload) --
def test_broken_jmespath_returns_error_marker():
data = {"secret": "x" * 100, "items": [1, 2, 3]}
out = project_response(data, {"projection_jmespath": "items[?"}) # malformed expression
assert isinstance(out, dict) and out.get("error") == "projection_error"
assert "expression" in out and out != data # did NOT masquerade as the full payload
def test_valid_jmespath_missing_key_is_not_error():
out = project_response({"a": 1}, {"projection_jmespath": "nope.missing"})
assert out is None # a valid expression selecting nothing is not an error
# --- Finding 5: SQL read-only / limit / cell-cap hardening ---------------------------------------
def _sqlite_url(tmp_path, rows=5) -> str:
db = tmp_path / f"h_{uuid.uuid4().hex[:6]}.db"
con = sqlite3.connect(db)
con.executescript("CREATE TABLE t(id INTEGER, name TEXT);")
con.executemany("INSERT INTO t VALUES (?, ?)", [(i, f"n{i}") for i in range(1, rows + 1)])
con.commit()
con.close()
return f"sqlite+aiosqlite:///{db.as_posix()}"
async def test_sql_forbids_into_outfile(tmp_path):
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
"query": "SELECT * FROM t INTO OUTFILE '/tmp/x'"}
with pytest.raises(SqlToolError):
await execute_sql(cfg, {}, tenant_id="t", project_id="p")
async def test_sql_streaming_truncation_is_accurate(tmp_path):
url = _sqlite_url(tmp_path, rows=5)
over = await execute_sql({"name": "q", "kind": "sql", "connection_url": url,
"query": "SELECT id FROM t ORDER BY id", "max_rows": 3}, {},
tenant_id="t", project_id="p")
assert over["row_count"] == 3 and over["truncated"] is True
exact = await execute_sql({"name": "q", "kind": "sql", "connection_url": url,
"query": "SELECT id FROM t ORDER BY id", "max_rows": 5}, {},
tenant_id="t", project_id="p")
assert exact["row_count"] == 5 and exact["truncated"] is False # exactly max_rows is not truncated
async def test_sql_caps_large_cell(tmp_path):
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path, rows=1),
"query": "SELECT printf('%.*c', 30000, 'x') AS big"}
res = await execute_sql(cfg, {}, tenant_id="t", project_id="p")
big = res["rows"][0]["big"]
assert len(big) < 30000 and "truncated" in big
# --- Finding 7: GraphQL in-band errors + operationName -------------------------------------------
async def test_graphql_errors_with_null_data_raise():
client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda r: httpx.Response(200, json={"data": None, "errors": [{"message": "boom"}]})
))
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql", "query": "{ me { id } }"}
with pytest.raises(GraphQLToolError, match="boom"):
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
async def test_graphql_partial_data_passes_through():
client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda r: httpx.Response(200, json={"data": {"me": {"id": "1"}}, "errors": [{"message": "field x failed"}]})
))
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql", "query": "{ me { id } }"}
res = await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert res["raw"]["data"] == {"me": {"id": "1"}} # partial success is valid GraphQL
async def test_graphql_sends_operation_name():
seen = {}
def handler(req):
import json as _j
seen.update(_j.loads(req.content))
return httpx.Response(200, json={"data": {"ok": True}})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql",
"query": "query A { a } query B { b }", "operation_name": "B"}
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert seen.get("operationName") == "B"
# --- Finding 2: entitlement gate for every non-REST kind (deny independently of the LLM) ---------
def _ctx_without(entitlement="billing:read"):
ctx = make_runtime_ctx("t", "p")
ctx.end_user = {"id": "u1", "entitlements": []} # user lacks the required entitlement
return ctx
async def test_graphql_entitlement_denied():
ctx = _ctx_without()
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql",
"query": "{ me { id } }", "required_entitlements": ["billing:read"]}
tool = materialize_tool(cfg, ctx)
out = await tool.ainvoke({})
assert "Not permitted" in out # denied before any network call
async def test_sql_entitlement_denied(tmp_path):
ctx = _ctx_without()
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
"query": "SELECT id FROM t", "required_entitlements": ["billing:read"]}
tool = materialize_tool(cfg, ctx)
out = await tool.ainvoke({})
assert "Not permitted" in out
async def test_code_entitlement_denied():
ctx = _ctx_without()
cfg = {"name": "c", "kind": "code", "language": "python",
"source": "def main():\n return 1\n", "required_entitlements": ["billing:read"]}
tool = materialize_tool(cfg, ctx)
out = await tool.ainvoke({})
assert "Not permitted" in out
async def test_component_entitlement_denied():
from forge.tools.components import build_component_tool
ctx = _ctx_without()
cfg = {"id": "c1", "name": "chart", "props_schema": {}, "required_entitlements": ["billing:read"]}
tool = build_component_tool(cfg, ctx)
out = await tool.ainvoke({})
assert "Not permitted" in out
async def test_entitled_user_is_allowed(tmp_path):
ctx = make_runtime_ctx("t", "p")
ctx.end_user = {"id": "u2", "entitlements": ["billing:read"]}
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path, rows=2),
"query": "SELECT id FROM t ORDER BY id", "required_entitlements": ["billing:read"]}
tool = materialize_tool(cfg, ctx)
out = await tool.ainvoke({})
assert [r["id"] for r in out] == [1, 2] # entitled -> query actually runs
# --- Finding 3: reliability (rate_limit + cache) reach graphql/sql/code via the shared wrapper ---
async def test_sql_rate_limit_via_wrapper(tmp_path):
ctx = make_runtime_ctx(f"t_{uuid.uuid4().hex[:6]}", "p")
cfg = {"name": f"q_{uuid.uuid4().hex[:6]}", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
"query": "SELECT id FROM t", "rate_limit": {"per_minute": 1}}
tool = materialize_tool(cfg, ctx)
await tool.ainvoke({})
with pytest.raises(RuntimeError, match="rate limit"):
await tool.ainvoke({})
async def test_sql_cache_via_wrapper(tmp_path):
url = _sqlite_url(tmp_path, rows=1)
ctx = make_runtime_ctx("t", "p")
cfg = {"name": f"q_{uuid.uuid4().hex[:6]}", "kind": "sql", "connection_url": url,
"query": "SELECT id FROM t ORDER BY id", "cache": {"ttl_seconds": 60}}
tool = materialize_tool(cfg, ctx)
first = await tool.ainvoke({})
# Mutate the DB behind the cache; a cache hit must still return the ORIGINAL rows.
path = url.split(":///", 1)[1]
con = sqlite3.connect(path)
con.execute("INSERT INTO t VALUES (99, 'new')")
con.commit()
con.close()
second = await tool.ainvoke({})
assert first == second == [{"id": 1}] # served from cache, not re-queried
# --- Finding 9: kind:"mcp" is creatable (materialize returns a deferred None, does not raise) -----
def test_materialize_mcp_returns_none_instead_of_raising():
ctx = make_runtime_ctx("t", "p")
cfg = {"name": "gh", "kind": "mcp", "mcp_client_id": "srv1", "remote_tool_name": "list_issues"}
assert materialize_tool(cfg, ctx) is None
def test_materialize_unknown_kind_still_raises():
ctx = make_runtime_ctx("t", "p")
with pytest.raises(ValueError, match="Unknown tool kind"):
materialize_tool({"name": "x", "kind": "bogus"}, ctx)
# --- Finding 6: REST multipart encoding + download-size guard ------------------------------------
async def test_multipart_body_encoding_sends_form_data():
seen = {}
def handler(req):
seen["ct"] = req.headers.get("content-type", "")
seen["body"] = req.content.decode("utf-8", "replace")
return httpx.Response(200, json={"ok": True})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
cfg = _rest_cfg()
cfg["request"] = {
"method": "POST", "url_template": "https://api.acme.dev/upload", "body_encoding": "multipart",
"fields": [{"path": "title", "type": "string", "in": "body"}],
}
await execute_rest(cfg, {"title": "hello"}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert seen["ct"].startswith("multipart/form-data") and "hello" in seen["body"]
async def test_download_size_guard_marks_oversized_body(monkeypatch):
monkeypatch.setattr(rest_mod, "_MAX_DOWNLOAD_BYTES", 50)
client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda r: httpx.Response(200, json={"blob": "x" * 500})
))
res = await execute_rest(_rest_cfg(), {}, tenant_id="t", project_id="p", client=client)
await client.aclose()
assert res["raw"]["error"] == "response_too_large" and res["raw"]["bytes"] > 50
# --- Finding 10: trace I/O redaction defaults ON for a production install --------------------------
def test_trace_redaction_defaults_on_in_production(monkeypatch):
from forge.config import settings
from forge.tracing import tool_io
monkeypatch.setattr(settings, "trace_tool_io_redact", False)
# Dev + flag off => values pass through.
monkeypatch.setattr(settings, "environment", "development")
assert tool_io.redact_headers({"Authorization": "Bearer secret"})["Authorization"] == "Bearer secret"
# Production + flag off => sensitive values are masked anyway.
monkeypatch.setattr(settings, "environment", "production")
masked = tool_io.redact_headers({"Authorization": "Bearer secret"})["Authorization"]
assert masked != "Bearer secret" and "secret" not in masked