Compare commits

...

7 Commits

Author SHA1 Message Date
will.anderson 6f113a9601 Merge pull request 'feat(agentic): scope file/command tools to an agent workspace root' (#23) from feat/agent-tool-workspace-scope into main
Neuron Soul CI / build (push) Failing after 6m47s
Deploy Soul to GKE / deploy (push) Failing after 5m21s
2026-06-18 16:29:35 +00:00
will.anderson 8e25da3673 Merge pull request 'fix(identity): bridge public self anchor to the curated self node' (#24) from fix/canonical-self-bridge into main
Deploy Soul to GKE / deploy (push) Failing after 8m15s
Neuron Soul CI / build (push) Failing after 14m56s
2026-06-18 16:29:16 +00:00
will.anderson ca29e7ca35 Merge pull request 'fix(ci): link soul.c only — fixes capability #error breaking every build' (#26) from fix/ci-soul-build-single-file into main
Neuron Soul CI / build (push) Failing after 9m27s
Deploy Soul to GKE / deploy (push) Failing after 10m3s
fix(ci): link soul.c only — fixes capability #error breaking every build
2026-06-18 16:29:05 +00:00
will.anderson 6576dddca2 Merge pull request 'fix(chat): remove duplicate web_search tool crashing all agentic requests' (#22) from fix/agentic-tools-duplicate-web-search into main
Deploy Soul to GKE / deploy (push) Failing after 8m40s
Neuron Soul CI / build (push) Failing after 10m21s
fix(chat): remove duplicate web_search tool crashing all agentic requests
2026-06-18 16:28:41 +00:00
Tim Lingo 149a042db9 fix(identity): bridge public self anchor to the curated self node
Neuron Soul CI / build (pull_request) Failing after 4m34s
The graph API resolves name=self/neuron to kn-efeb4a5b (neuron-api.el:471),
which carries only 8 incidental 'tagged' edges. The curated identity lives on
self node 015644f5 (1461 edges: identity, embodies, remembers, values). So
public self-traversal reaches tags, not the real self.

Add ensure_self_canonical_bridge(): an idempotent boot-time repair that links
kn-efeb4a5b <-> 015644f5 with a 'canonical-self' edge, only if missing. Runs in
the genesis safe-to-seed path regardless of the <100-edge gate, so the live
populated graph gets repaired and persisted. Connect-only-if-missing prevents
the duplicate-edge stacking that gates init_soul_edges().

Compile-checked with elc (darwin arm64); not link/run-gated locally. Needs a
soul build + smoke test before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:53:13 -05:00
Tim Lingo 071c0eeb9f feat(agentic): scope file/command tools to an agent workspace root
Neuron Soul CI / build (pull_request) Failing after 5m7s
Confine the agentic file tools (read_file, write_file, list_files, grep)
to a configured workspace subtree via a lexical path check, and run
run_command with its cwd set to that root. Root comes from state key
"agent_workspace_root" or env NEURON_AGENT_ROOT. When no root is set,
behavior is unchanged (unscoped) for backward compatibility.

Defense-in-depth, NOT a hard boundary: the lexical guard does not resolve
symlinks and cannot stop an arbitrary shell command from cd-ing out of the
root. Real confinement needs runtime support (cwd-locked exec / sandbox-exec
/ chroot) in el_runtime.c.

Compile-checked with elc (darwin arm64); not link/run-gated locally
(darwin elb unavailable). Needs a soul build + smoke test before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:49:01 -05:00
will.anderson 53fb75353f fix(chat): remove duplicate web_search tool in agentic_tools_all
Neuron Soul CI / build (pull_request) Failing after 5m24s
agentic_tools_literal() already contains a custom web_search tool.
agentic_tools_with_web() adds the Anthropic server-side web_search_20250305
tool (also named web_search). Combining them caused Anthropic to reject
every agentic request with 'Tool names must be unique.'

agentic_tools_all() now calls agentic_tools_literal() directly. Connector
tools splice in as before. The web_search-only variant (agentic_tools_with_web)
is unchanged for callers that specifically want native search without connectors.
2026-06-17 14:11:50 -05:00
2 changed files with 104 additions and 8 deletions
+83 -8
View File
@@ -317,10 +317,13 @@ fn connector_tools_json() -> String {
return arr return arr
} }
// Built-in tools + native web_search + every connector tool, as one tools array. // Built-in tools + every connector tool, as one tools array.
// Splices connector tools in before the closing bracket of the base array. // Uses agentic_tools_literal (not agentic_tools_with_web) to avoid a duplicate
// "web_search" name the literal already includes a custom web_search handler,
// and adding the Anthropic server-side web_search_20250305 (same name) causes
// Anthropic to reject with "Tool names must be unique."
fn agentic_tools_all() -> String { fn agentic_tools_all() -> String {
let base: String = agentic_tools_with_web() let base: String = agentic_tools_literal()
let conn: String = connector_tools_json() let conn: String = connector_tools_json()
let conn_inner: String = str_slice(conn, 1, str_len(conn) - 1) let conn_inner: String = str_slice(conn, 1, str_len(conn) - 1)
if str_eq(conn_inner, "") { if str_eq(conn_inner, "") {
@@ -377,16 +380,78 @@ fn call_neuron_mcp(tool_name: String, args: String) -> String {
return json_safe(result) return json_safe(result)
} }
// ---------------------------------------------------------------------------
// Agent workspace scope (defense-in-depth, NOT a hard security boundary).
//
// When a workspace root is configured (state key "agent_workspace_root", else
// env NEURON_AGENT_ROOT), the path-based tools (read_file, write_file,
// list_files, grep) are confined to that subtree by a lexical check, and
// run_command runs with its cwd set to the root. With no root set, behavior is
// unchanged (unscoped) for backward compatibility.
//
// LIMITATION FLAGGED FOR WILL'S REVIEW: this is a lexical guard. It does not
// resolve symlinks and cannot stop an arbitrary shell command from cd-ing out
// of the root. Real confinement needs runtime support (cwd-locked exec /
// sandbox-exec / chroot) in el_runtime.c. This raises the floor; it is not a
// boundary. The default-allow-when-unset policy and the "cd <root> && (...)"
// wrapping are deliberate choices to confirm against the intended design.
// ---------------------------------------------------------------------------
fn agent_workspace_root() -> String {
let s: String = state_get("agent_workspace_root")
if !str_eq(s, "") {
return s
}
return env("NEURON_AGENT_ROOT")
}
// Allow if path stays under root. Empty root = no sandbox = allow. Rejects
// parent traversal and ~ expansion; absolute paths must live under root.
fn path_within_root(path: String, root: String) -> Bool {
if str_eq(root, "") {
return true
}
if str_contains(path, "..") {
return false
}
if str_starts_with(path, "~") {
return false
}
if str_starts_with(path, "/") {
return str_starts_with(path, root)
}
return true
}
// Resolve a relative tool path against the root so it lands inside the subtree.
fn resolve_in_root(path: String, root: String) -> String {
if str_eq(root, "") {
return path
}
if str_starts_with(path, "/") {
return path
}
return root + "/" + path
}
fn dispatch_tool(tool_name: String, tool_input: String) -> String { fn dispatch_tool(tool_name: String, tool_input: String) -> String {
if str_eq(tool_name, "read_file") { if str_eq(tool_name, "read_file") {
let path: String = json_get(tool_input, "path") let path: String = json_get(tool_input, "path")
let content: String = fs_read(path) let root: String = agent_workspace_root()
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
let content: String = fs_read(resolve_in_root(path, root))
return json_safe(content) return json_safe(content)
} }
if str_eq(tool_name, "write_file") { if str_eq(tool_name, "write_file") {
let path: String = json_get(tool_input, "path") let path: String = json_get(tool_input, "path")
let content: String = json_get(tool_input, "content") let content: String = json_get(tool_input, "content")
fs_write(path, content) let root: String = agent_workspace_root()
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
fs_write(resolve_in_root(path, root), content)
return json_safe("{\"ok\":true}") return json_safe("{\"ok\":true}")
} }
if str_eq(tool_name, "web_get") { if str_eq(tool_name, "web_get") {
@@ -401,7 +466,9 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
} }
if str_eq(tool_name, "run_command") { if str_eq(tool_name, "run_command") {
let cmd: String = json_get(tool_input, "command") let cmd: String = json_get(tool_input, "command")
let result: String = exec_capture(cmd) let root: String = agent_workspace_root()
let scoped: String = if str_eq(root, "") { cmd } else { "cd " + root + " && ( " + cmd + " )" }
let result: String = exec_capture(scoped)
return json_safe(result) return json_safe(result)
} }
// MCP connector tools (namespaced mcp__<server>__<tool>) are routed through // MCP connector tools (namespaced mcp__<server>__<tool>) are routed through
@@ -421,13 +488,21 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
} }
if str_eq(tool_name, "list_files") { if str_eq(tool_name, "list_files") {
let path: String = json_get(tool_input, "path") let path: String = json_get(tool_input, "path")
let result: String = exec_capture("ls -la " + path + " 2>&1") let root: String = agent_workspace_root()
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
let result: String = exec_capture("ls -la " + resolve_in_root(path, root) + " 2>&1")
return json_safe(result) return json_safe(result)
} }
if str_eq(tool_name, "grep") { if str_eq(tool_name, "grep") {
let pattern: String = json_get(tool_input, "pattern") let pattern: String = json_get(tool_input, "pattern")
let path: String = json_get(tool_input, "path") let path: String = json_get(tool_input, "path")
let result: String = exec_capture("grep -rn \"" + pattern + "\" " + path + " 2>&1 | head -50") let root: String = agent_workspace_root()
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
let result: String = exec_capture("grep -rn \"" + pattern + "\" " + resolve_in_root(path, root) + " 2>&1 | head -50")
return json_safe(result) return json_safe(result)
} }
if str_eq(tool_name, "edit_file") { if str_eq(tool_name, "edit_file") {
+21
View File
@@ -95,6 +95,24 @@ fn init_soul_edges() -> Void {
engram_connect(val_hope, val_trust, el_from_float(0.7), "co-value") engram_connect(val_hope, val_trust, el_from_float(0.7), "co-value")
} }
// ensure_self_canonical_bridge link the public self anchor (the graph API's
// traversal_root, kn-efeb4a5b, which carries only incidental tag edges) to the
// curated self node (015644f5, where the real identity / value / co-value edges
// live). Without this, public self-traversal (name=self / neuron) reaches tags
// instead of the curated identity. Idempotent: connects only if the edge is
// missing, so it is safe to run every boot including on an already-populated
// graph where init_soul_edges() is skipped by the <100-edge gate.
fn ensure_self_canonical_bridge() -> Void {
let pub_self: String = "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee"
let curated_self: String = "015644f5-8194-4af0-800d-dd4a0cd71396"
let nbrs: String = engram_neighbors_json(pub_self, 1, "out")
if !str_contains(nbrs, curated_self) {
engram_connect(pub_self, curated_self, el_from_float(0.95), "canonical-self")
engram_connect(curated_self, pub_self, el_from_float(0.95), "canonical-self")
println("[soul] canonical-self bridge built: kn-efeb4a5b <-> 015644f5")
}
}
// load_identity_context pull key identity nodes from engram into working state. // load_identity_context pull key identity nodes from engram into working state.
// Called at boot after engram_load. These nodes contain values, intellectual-dna, // Called at boot after engram_load. These nodes contain values, intellectual-dna,
// memory-philosophy the graph-stored self that chat.el can include in prompts. // memory-philosophy the graph-stored self that chat.el can include in prompts.
@@ -398,6 +416,9 @@ if is_genesis && safe_to_seed {
} else { } else {
println("[soul] edges already present (" + int_to_str(edge_count_now) + ") - skipping init") println("[soul] edges already present (" + int_to_str(edge_count_now) + ") - skipping init")
} }
// Canonical-self bridge is idempotent run it regardless of edge count so an
// already-populated graph still gets the public->curated self link.
ensure_self_canonical_bridge()
// Genesis saves to its local snapshot file (it manages its own Engram). // Genesis saves to its local snapshot file (it manages its own Engram).
state_set("soul_snapshot_path", snapshot) state_set("soul_snapshot_path", snapshot)
engram_save(snapshot) engram_save(snapshot)