@ducci/jarvis 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ # Finding 006: Malformed Tool Schema Poisons Every Subsequent Request
2
+
3
+ **Date:** 2026-02-27
4
+ **Severity:** High — permanently breaks all model calls for the session until tools.json is manually repaired
5
+ **Status:** Fixed
6
+
7
+ ---
8
+
9
+ ## What Happened
10
+
11
+ During a session installing security tools (Nuclei, Subfinder, Naabu), the agent called `save_tool` to create a custom `scan` tool. The model passed the `parameters` field as a **JSON-serialized string** instead of a JSON object:
12
+
13
+ ```json
14
+ "parameters": "{\"type\":\"object\",\"properties\":{\"domain\":{\"type\":\"string\",...}}}"
15
+ ```
16
+
17
+ `save_tool` stored this verbatim — no validation occurred. The malformed tool definition was written to `~/.jarvis/data/tools/tools.json` as tool index 12.
18
+
19
+ On the very next model call (within the same run), Jarvis reloaded tools after `save_tool` completed (`toolsModified = true`) and sent all tool definitions to the provider. OpenRouter's provider API returned:
20
+
21
+ ```
22
+ 400 Provider returned error
23
+ [{'type': 'dict_type', 'loc': ('body', 'tools', 12, 'function', 'parameters'),
24
+ 'msg': 'Input should be a valid dictionary', 'input': '{"type":"object",...}'}]
25
+ ```
26
+
27
+ Every subsequent user message — including trivial ones like "Was ist schief gegangen?" and "Wie ist deine session id" — also failed with the same 400. The malformed tool was permanently in `tools.json` and included in every model request.
28
+
29
+ ---
30
+
31
+ ## Root Cause
32
+
33
+ ### 1. `save_tool` did not validate `parameters`
34
+
35
+ The `save_tool` code stored `args.parameters` directly into `tools.json` without checking its type. The OpenAI tool-calling spec requires `parameters` to be a JSON Schema object (a dictionary). When a model passes a JSON string instead of an object — a common mistake with weaker models — the result is a permanently malformed tool definition.
36
+
37
+ ### 2. `getToolDefinitions` sent all tools to the provider without validation
38
+
39
+ `getToolDefinitions` returned all tool definitions unconditionally. A single malformed tool poisoned every request that included the tools list — which is every request, since tool definitions are always sent.
40
+
41
+ These two gaps compound each other: the first allows bad data in, the second ensures it breaks everything downstream.
42
+
43
+ ---
44
+
45
+ ## Why it Persists Across All Subsequent Messages
46
+
47
+ Every `handleChat` call loads tools fresh from `tools.json` via `loadTools()`. The malformed `scan` tool is always in the list. Every model call sends all tool definitions. The provider rejects every request. The session is stuck until `tools.json` is manually repaired.
48
+
49
+ ---
50
+
51
+ ## Intermittent Behavior
52
+
53
+ The failure is not always immediate. In the debugging session, two tool calls succeeded in later runs before the 400 fired. This is consistent with free/preview model providers (nvidia via OpenRouter) applying schema validation inconsistently across backend instances. The bug is therefore not "always broken" but **reliably broken under load** — which is harder to detect and debug than a consistent failure.
54
+
55
+ ---
56
+
57
+ ## Fix
58
+
59
+ Two targeted changes to `src/server/tools.js`.
60
+
61
+ ### 1. `save_tool` validates and auto-corrects `parameters`
62
+
63
+ Before writing to `tools.json`, the `save_tool` code now:
64
+ - If `parameters` is a string, attempts `JSON.parse()` — models commonly serialize the object when they should pass it directly. If parsing succeeds and yields an object, the corrected value is used silently.
65
+ - If `parameters` is still not a plain object after the attempted parse, returns an error immediately with a clear message. Nothing is written to disk.
66
+
67
+ ```js
68
+ let parameters = args.parameters;
69
+ if (typeof parameters === 'string') {
70
+ try {
71
+ parameters = JSON.parse(parameters);
72
+ } catch {
73
+ return { status: 'error', error: 'parameters must be a JSON Schema object, not a string. Pass the object directly, not as a JSON-serialized string.' };
74
+ }
75
+ }
76
+ if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) {
77
+ return { status: 'error', error: 'parameters must be a JSON Schema object (e.g. { type: "object", properties: {...} }).' };
78
+ }
79
+ ```
80
+
81
+ ### 2. `getToolDefinitions` filters out malformed tools
82
+
83
+ `getToolDefinitions` now validates `parameters` on each tool before including it in the definitions sent to the provider. A tool with a non-object `parameters` is skipped with a `console.warn`, not thrown — this is a defence-in-depth guard, not a primary error path.
84
+
85
+ ```js
86
+ export function getToolDefinitions(tools) {
87
+ const defs = [];
88
+ for (const [name, t] of Object.entries(tools)) {
89
+ const params = t.definition?.function?.parameters;
90
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
91
+ console.warn(`[tools] Skipping tool '${name}': parameters is not a valid object (got ${typeof params})`);
92
+ continue;
93
+ }
94
+ defs.push(t.definition);
95
+ }
96
+ return defs;
97
+ }
98
+ ```
99
+
100
+ Together: Fix 1 prevents malformed tools from entering `tools.json`. Fix 2 ensures that even if a malformed tool somehow ends up in `tools.json` (e.g. from an older version, manual edit, or a bug that slips through), it is silently excluded from every model request rather than poisoning the session.
101
+
102
+ ---
103
+
104
+ ## Secondary Issue: Agent Hallucinated Successful Action
105
+
106
+ In the run that preceded the `save_tool` call, the agent responded with:
107
+
108
+ > "The scanning script has been created at /root/.jarvis/projects/cybersecurity/scan.sh."
109
+
110
+ No such file was ever created — the agent had only installed Nuclei successfully. This hallucination forced the next run to attempt recovery via `save_tool`, which is where the malformed tool was introduced. The hallucination itself is a model-quality issue with the free nvidia model, not a Jarvis bug.
111
+
112
+ ---
113
+
114
+ ## Outcome
115
+
116
+ - `save_tool` auto-corrects the common case (string instead of object) and rejects the rest with a clear error before writing to disk
117
+ - A pre-existing malformed tool in `tools.json` no longer poisons model requests — it is silently skipped per call
118
+ - Sessions are no longer permanently broken by a single bad `save_tool` call
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -149,7 +149,7 @@ const SEED_TOOLS = {
149
149
  },
150
150
  },
151
151
  },
152
- code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); tools[args.name] = { definition: { type: 'function', function: { name: args.name, description: args.description, parameters: args.parameters } }, code: args.code }; await fs.promises.writeFile(toolsFile, JSON.stringify(tools, null, 2), 'utf8'); return { status: 'ok', saved: args.name };`,
152
+ code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); let parameters = args.parameters; if (typeof parameters === 'string') { try { parameters = JSON.parse(parameters); } catch { return { status: 'error', error: 'parameters must be a JSON Schema object, not a string. Pass the object directly, not as a JSON-serialized string.' }; } } if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) { return { status: 'error', error: 'parameters must be a JSON Schema object (e.g. { type: "object", properties: {...} }).' }; } tools[args.name] = { definition: { type: 'function', function: { name: args.name, description: args.description, parameters } }, code: args.code }; await fs.promises.writeFile(toolsFile, JSON.stringify(tools, null, 2), 'utf8'); return { status: 'ok', saved: args.name };`,
153
153
  },
154
154
  get_tool: {
155
155
  definition: {
@@ -424,7 +424,16 @@ export async function loadTools() {
424
424
  }
425
425
 
426
426
  export function getToolDefinitions(tools) {
427
- return Object.values(tools).map(t => t.definition);
427
+ const defs = [];
428
+ for (const [name, t] of Object.entries(tools)) {
429
+ const params = t.definition?.function?.parameters;
430
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
431
+ console.warn(`[tools] Skipping tool '${name}': parameters is not a valid object (got ${typeof params})`);
432
+ continue;
433
+ }
434
+ defs.push(t.definition);
435
+ }
436
+ return defs;
428
437
  }
429
438
 
430
439
  export async function executeTool(tools, name, toolArgs) {