Origin: session D0AC6CKBK5K_1781115410_840529 (memory e9a72b27). The agent passed
"TimeoutSeconds":"1200" to extend a shell timeout; Netclaw only recognizes the meta key
_timeout_seconds, so the value was silently dropped, the timeout fell back to a 90s default,
and the agent got no signal — forming a false belief that fed a stuck loop.
Constitution rule violated: "When something fails or is misconfigured, fail loudly — do
not silently degrade to a default… on security-relevant paths they can silently escalate
privileges." The rule is enforced on the config surface (additionalProperties:false +
ConfigSchemaDoctorCheck) but not on the live tool-call argument surface. That
asymmetry is the gap.
Method: 3 parallel auditors (arg/meta/pipeline layer; tool implementations; policy/security layer), identical rubric. CRITICAL finding independently re-verified by hand and down-graded.
Resolution status (change loud-tool-arg-validation): findings #1, #2, #4, #5, #6, #7,
#8, #9, #10 are FIXED by this change (unknown-key validator at the dispatcher,
strict value binding in the generator + ToolArgumentHelper, pipeline-side meta-value
rejection, ComputeEffectiveTimeout clamp/floor notices via ToolExecutionContext.Notices,
provider-boundary args-parse sentinel, web_fetch format validation + truncation notice,
list_webhooks Filter honored). The latent GetInt32 uncaught-throw is also fixed
(TryGetInt32). Findings #3, #11, #15, #17 (policy layer) remain OPEN — parked for a
security owner, recorded as out-of-scope open questions in the change proposal. #12–#14, #16
(BORDERLINE) remain open, unchanged.
Most of the 17 sites are not independent bugs — they are three repeated shapes:
- M1 — unknown / near-miss key silently dropped. The original bug. Lives at two layers:
generated
ParseArguments(all tools) andToolCallMeta.ExtractFrom(meta keys, exact-match). - M2 — present-but-invalid value silently coerced to a default.
_ => null/_ => falseswitch arms; malformed args JSON → null args. - M3 — requested value silently clamped/overridden. timeout floor & ceiling; format fallback; output/byte truncation with no marker.
A loud-by-default tool-arg seam (reject unknown keys + "did you mean" + surface every override) closes M1–M3 for all ~20 tools at ~3 chokepoints.
| # | file:line | mechanism | what's silently handled | verdict | sev | minimal loud fix |
|---|---|---|---|---|---|---|
| 1 | Netclaw.Tools.Generators/NetclawToolGenerator.cs:234-296 + DispatchingToolExecutor.cs:67 |
M1 | any unknown/misspelled arg key, all tools (the exact TimeoutSeconds mechanism) |
VIOLATION | HIGH | diff supplied keys vs schema props (+ meta keys); return Error: unrecognized argument 'X'. Did you mean 'Y'? |
| 2 | Netclaw.Tools.Abstractions/ToolCallMeta.cs:69,80,95 |
M1 | near-miss meta keys (TimeoutSeconds, _timeoutSeconds, _timeout-seconds) — exact-match TryGetValue, unlike normal args which use ToolArgumentHelper.TryGetValueFlexible |
VIOLATION | HIGH | reuse the flexible matcher for meta keys; emit "did you mean _timeout_seconds?" notice on near-miss |
| 3 | ToolAudienceProfileResolver.cs:70-71,175-192 |
(policy) | audience Allowlist profile silently does not govern tools outside a hardcoded set (memory tools, search_tools, load_tool, spawn_agent, check_background_job) → always allowed |
VIOLATION / maybe by-design | HIGH (re-classified from CRITICAL) | product decision: default-deny unmanaged first-party tools under Allowlist, or document + surface that the allowlist is non-authoritative. Approval gate still applies (mitigates). |
| 4 | ToolArgumentHelper.cs:111,129,145 (_ => null) + generator :260,274,288 (?? 0/0.0/false) |
M2 | present-but-unparseable scalar ("abc" int, "yes" bool) → coerced to 0/false |
VIOLATION | HIGH | distinguish absent vs present-but-unparseable; throw/surface on the latter |
| 5 | ToolCallMeta.cs:89,103 (_ => null/_ => false) |
M2 | malformed _timeout_seconds/_background value → meta silently empty → default |
VIOLATION | HIGH | surface "ignored _timeout_seconds=\"1200ms\" — expected positive int; used default" |
| 6 | Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs:779-792 (TryDeserializeArguments) |
M2 | malformed/truncated tool-call args JSON → return null, call dispatched with null args |
VIOLATION | HIGH | emit a tool-result error for that call id instead of an arg-less dispatch |
| 7 | Sessions/Pipelines/ToolCallMetaExtractor.cs:37-39 & :41 |
M3 | requested timeout below floor → default; above ceiling → silent Math.Min clamp (the literal prod scenario) |
VIOLATION | HIGH | append [timeout clamped 1200s→600s max] / [requested 10s below 60s floor] to result |
| 8 | Tools/ListWebhooksTool.cs:31 |
standalone | schema-advertised Filter arg is never read — ListRouteFiles() ignores it; complete no-op |
VIOLATION | MEDIUM | honor filter (definition.Enabled) or reject unknown values; echo applied filter |
| 9 | Tools/WebFetchTool.cs:111 |
M3 | Format ≠ "text" (e.g. "markdown", typo) → silent raw-HTML fallback |
VIOLATION | MEDIUM | validate Format ∈ {raw,text}; error/notice otherwise |
| 10 | Tools/WebFetchTool.cs:212-217 → :107,149 |
M3 | body > 5 MB cap → truncated, summary shows count but no "truncated" marker | VIOLATION | MEDIUM | append [content truncated at 5 MB — N bytes not fetched] |
| 11 | ToolAccessPolicy.cs:204-205 (non-interactive shell trust-zone) |
(policy) | path token that NormalizePathToken returns null for → continue (unchecked); working-dir branch fails closed — inconsistent, allow-leaning |
BORDERLINE | MEDIUM | fail closed on null-normalized token (shell_unresolvable_path_token) |
| 12 | Protocol/ChatMessageConverter.cs:94-97 |
M2 | persisted media file missing at request-build → log + continue; attachment vanishes from LLM message (log-only, model blind) |
BORDERLINE | MEDIUM | insert [attachment unavailable: <name>] placeholder into contents |
| 13 | Providers/SelfHosted/TextToolCallParser.cs:40 |
M2 | text/XML tool-call params all coerced to trimmed string; arrays/objects/types lost (the Qwen text-format path — relevant: orchestrator was Qwen) | BORDERLINE | MEDIUM | try JsonDocument.Parse per value; keep structured form; surface unreconcilable values |
| 14 | OpenAiCompatibleChatClient.cs:749-752 |
M2 | streamed tool call missing function.name → ?? string.Empty → masquerades as "unknown tool", args lost |
BORDERLINE | MEDIUM | treat nameless finished call as stream-assembly error (log + diagnostic) |
| 15 | ToolAccessPolicy.cs:493-525 (ResolveApprovalMode) |
(policy) | a future /-bearing matcher key would skip McpServerDefaults (latent, not currently reachable) |
BORDERLINE | MEDIUM | guard: matcher keys must be first-party (no /) |
| 16 | Tools/WebSearchTool.cs:40 / Tools/FileReadTool.cs:84-85 |
M3 | MaxResults clamp to 30 (documented); StartLine/Limit 0/neg treated as unspecified |
BORDERLINE | LOW | optional [capped at 30] note; reject non-positive line numbers |
| 17 | ToolAccessPolicy.cs:338-346 (safe-verb short-circuit) |
(policy) | read-only-verb auto-ALLOW with no audit line (cf. LogApprovalNearMisses which does log) |
BORDERLINE | LOW | emit info/audit log on safe-verb auto-grant |
Separate defect class (not silent-fallback, flagged for awareness):
ToolCallMeta.cs:87 — je.GetInt32() inside a when guard throws (uncaught) on a
non-integral/overflow JSON number (_timeout_seconds: 12.5 or 1e12). ExtractFrom has no
try/catch. Use TryGetInt32.
ToolOutputSpill.Compose(ToolOutputSpill.cs:108-115) — gold standard model-facing truncation notice:[output truncated to X of Y; saved to <path> — read a slice…].SessionToolExecutionPipeline.AppendModelInputHandoffWarning(:924) — in-band, model-facing notice driven by a requested-vs-actual gap. Exact mechanism for M3 overrides.ToolArgumentHelper.TryGetValueFlexible/NormalizeKey(:17-68) — case/punct-insensitive matcher; reuse insideToolCallMeta.ExtractFromto fix M1 meta-key drops and detect near-misses.ApprovalNearMissfacility (ApprovalPatternMatching.cs:192-311, logged viaToolApprovalActor.LogApprovalNearMisses:163-185) — already classifies "expected-match-but-missed" with aDescribe(). AToolArgNearMissmodeled on it gives the "supplied X, recognized form is Y, here's why" surface for M1. Read-only diagnostics — safe.RedirectTokenthrow-on-unknown-enum (IToolApprovalMatcher.cs:447) — correct alternative to_ => defaulton a security-relevant enum map.ShellToolcwd handling (:118-133) andRouteToBackgroundJobAsynctrust-context guard (:734-736,throw … "trust context cannot be defaulted") — constitution-aligned exemplars.
- No-silent-discard invariant at the tool-arg seam. Unknown keys → loud, recoverable
tool-result error with near-miss "did you mean" (reuse
ApprovalNearMissshape + flexible matcher). Covers M1 atNetclawToolGenerator.ParseArguments+ToolCallMeta.ExtractFrom. - Absent vs present-but-invalid. Present-but-unparseable values surface, never coerce to
0/false/null-args. Covers M2. - Surface every override to the agent. Clamp/floor/format-fallback/truncation emit a
model-facing note (reuse
ToolOutputSpill/AppendModelInputHandoffWarning). Covers M3. - Policy decisions, separately. #3 (allowlist authority), #11 (null token fail-closed), #17 (audit auto-grants) need a security-owner decision, not just a notice.
- Ergonomic fix alongside the safety net: accept obvious aliases (
TimeoutSeconds→_timeout_seconds) so correct intent just works; the validator is the backstop.
Highest single leverage: items 1 + 2 (the M1 seam) — closes the original bug's entire class in ~2 places.