feat: direct tool invocation via /tool_name(arg=val) + wire Curry as real tools

Adds synapse/slash_commands.py: a chat message that's nothing but
/tool_name(arg=val, arg=val) dispatches straight through tools.dispatch(),
skipping model selection, RAG/playbook context assembly, and the ask-policy
approval round-trip entirely. A human typing this IS the approval - there's
no one else to ask - so it's a deliberate, reviewed bypass of the approval
step specifically, not of anything a tool validates internally (path
boundaries, size caps, Curry's own sandbox checks all still run). Argument
values parse via ast.literal_eval only: strings/numbers/bools/None/literal
containers, no names, no calls, no attribute access - a malformed or
hostile-looking argument fails to parse rather than executing anything.

Wired into chat_stream_endpoint (main.py) as an early short-circuit, before
any of the RAG/model-selection work that a slash-command doesn't need. Web
needed no changes (it already forwards raw text unchanged); the TUI
previously swallowed every leading "/" locally and never reached the backend
with it, so tui_app.py's _handle_slash now falls through to _start_chat for
anything shaped like a tool call while still handling its own local
meta-commands (/help, /model, /new, ...) exactly as before.

Also finally wires Curry in as ten real tools (curry_declare_constant,
curry_get_constant/_latest, curry_list_constants, curry_retire_constant,
curry_declare_function, curry_get_function, curry_list_functions,
curry_call_function, curry_retire_function) - deferred from the vendoring
pass. The five write/execute ones are ACTION tools in the same
always-ask-regardless-of-global-policy floor as edit_source
(ALWAYS_ASK_ACTION_TOOLS, generalized in tools.py from the old
self_edit-only ALWAYS_ASK_TOOLS so future tool families share one place to
register into). curry_call_function is gated as an action for the same
reason run_snippet is: it executes code, even sandboxed.

Fixed a real bug surfaced while wiring this up: curry_db is a long-lived
singleton holding one sqlite3 connection (unlike NexusOS's own memory store,
which opens/closes a fresh connection per call specifically to dodge this),
and sqlite3 forbids using a connection from a different thread than created
it. That's a non-issue in production (uvicorn's single event-loop thread),
but Starlette's TestClient runs the ASGI app through an anyio portal thread,
so it broke immediately under test. Fixed at the source (curry_core.py,
Curry.__init__) with check_same_thread=False, documented as a second
deliberate vendoring deviation alongside the PR #4 sandbox fix - there was
never real concurrent access here, just an overly strict same-thread
assertion tripping on a thread-identity change with only one logical caller.

Verified: 244 backend tests pass (18 new for the parser + endpoint wiring +
curry tool registration, 4 new for the TUI passthrough); the 12 pre-existing
C/C++/Rust toolchain failures are unrelated and unchanged. Confirmed by hand
over the real HTTP endpoint: successful dispatch, zero tool_request events
(approval bypass working as designed), a format()-dunder exploit attempt
still rejected by the vendored sandbox fix even through the new tool
registration, malformed arguments rejected before ever reaching dispatch,
and an unknown tool name rejected cleanly. Wheel rebuilt and content-checked
(bin/check.sh's gate now also asserts slash_commands.py ships).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:11:00 -05:00
co-authored by Claude Sonnet 5
parent cb3d3f0a1f
commit 663e540b6b
9 changed files with 878 additions and 34 deletions
+33 -15
View File
@@ -7,21 +7,27 @@ for NexusOS. Kept as a single self-contained, stdlib-only file specifically so
it can be vendored cleanly like this - no external dependencies, no package
metadata of its own to reconcile with pyproject.toml.
Includes the fix from https://github.com/Athena-Pro/Curry/pull/4: a function
body could pass validate_function_body's AST check by hiding dunder-attribute
traversal inside a str.format()/str.format_map() field spec (e.g.
'{0.__globals__}'.format(x)), which the AST walk never inspects since it only
looks at literal Attribute/Name nodes, not string constant contents. Any
function with a function_bindings dependency hands eval_context a real Python
closure, and a closure's __globals__ is this module's own namespace - so that
was a working sandbox escape, not a theoretical one. Re-sync deliberately, not
automatically: pull upstream changes by hand and re-diff against this file's
history rather than scripting the sync, so a change here always has a reason
attached to it.
Two deliberate deviations from upstream, both explained at their call site
rather than just here — re-sync by hand and re-diff against this file's
history rather than scripting the sync, so every change here keeps its reason
attached:
Only Curry's own database operations execute against this file (declare_*,
call_function, etc.) - nothing in NexusOS wires model-authored content into
declare_function today. See synapse/curry_store.py for how NexusOS opens it.
1. The fix from https://github.com/Athena-Pro/Curry/pull/4 (validate_function_body,
below): a function body could pass the AST check by hiding dunder-attribute
traversal inside a str.format()/str.format_map() field spec (e.g.
'{0.__globals__}'.format(x)), which the AST walk never inspects since it
only looks at literal Attribute/Name nodes, not string constant contents —
a working sandbox escape, not a theoretical one.
2. check_same_thread=False on the connection (Curry.__init__, below) — a
long-lived singleton created at import time can legitimately be called
from a different OS thread than it was constructed on (Starlette's
TestClient runs the ASGI app through an anyio portal thread); nothing here
adds genuinely concurrent access, it relaxes an overly strict assertion.
curry_declare_function/curry_call_function ARE reachable from model-issued
tool calls in NexusOS (see synapse/tools.py) — both are ACTION tools requiring
per-call human approval (synapse/tools.py's ALWAYS_ASK_ACTION_TOOLS), same as
run_snippet. See synapse/curry_store.py for how NexusOS opens this file.
"""
import sqlite3
@@ -95,7 +101,19 @@ class Curry:
"""Initialize Curry with SQLite backend."""
self.db_path = db_path
self.fallback_db = fallback_db
self.conn = sqlite3.connect(db_path, uri=uri)
# NexusOS deviation: check_same_thread=False. self.conn is held for
# this object's whole lifetime (unlike NexusOS's own memory store,
# which opens/closes a fresh connection per call specifically to avoid
# this), and a long-lived singleton created at import time can
# legitimately be called from a different OS thread than it was
# constructed on — e.g. Starlette's TestClient runs the ASGI app
# through an anyio portal thread, and any future to_thread-offloaded
# caller would too. There is still only ever one logical caller at a
# time here (asyncio's single event loop + the GIL serialize access;
# nothing in NexusOS calls curry_db from two threads concurrently) —
# this relaxes sqlite3's same-thread assertion, it does not add real
# concurrent access that wasn't already being serialized.
self.conn = sqlite3.connect(db_path, uri=uri, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL;")
self._initialize_schema()