diff --git a/Directory.Packages.props b/Directory.Packages.props
index 29c3bfc9d..a7f323292 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -106,4 +106,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/SILENT_FALLBACK_AUDIT.md b/SILENT_FALLBACK_AUDIT.md
new file mode 100644
index 000000000..feda1286a
--- /dev/null
+++ b/SILENT_FALLBACK_AUDIT.md
@@ -0,0 +1,108 @@
+# Silent-Fallback / Silent-Discard Audit — Tool-Call Surface
+
+**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.
+
+---
+
+## The class unifies into 3 mechanisms + standalones
+
+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) and `ToolCallMeta.ExtractFrom` (meta keys, exact-match).
+- **M2 — present-but-invalid value silently coerced to a default.** `_ => null`/`_ => false`
+ switch 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.
+
+---
+
+## Findings (severity-ranked, de-duplicated)
+
+| # | 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: ]` 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`.
+
+---
+
+## Reusable "loud" patterns already in-repo (reuse before adding)
+
+- **`ToolOutputSpill.Compose` (`ToolOutputSpill.cs:108-115`)** — gold standard model-facing
+ truncation notice: `[output truncated to X of Y; saved to — 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 inside `ToolCallMeta.ExtractFrom` to fix M1 meta-key drops *and* detect near-misses.
+- **`ApprovalNearMiss` facility (`ApprovalPatternMatching.cs:192-311`, logged via
+ `ToolApprovalActor.LogApprovalNearMisses:163-185`)** — already classifies
+ "expected-match-but-missed" with a `Describe()`. A `ToolArgNearMiss` modeled on it gives the
+ "supplied X, recognized form is Y, here's why" surface for M1. Read-only diagnostics — safe.
+- **`RedirectToken` throw-on-unknown-enum (`IToolApprovalMatcher.cs:447`)** — correct alternative
+ to `_ => default` on a security-relevant enum map.
+- **`ShellTool` cwd handling (`:118-133`)** and **`RouteToBackgroundJobAsync` trust-context guard
+ (`:734-736`, `throw … "trust context cannot be defaulted"`)** — constitution-aligned exemplars.
+
+---
+
+## Proposed spec spine (for the OpenSpec change)
+
+1. **No-silent-discard invariant at the tool-arg seam.** Unknown keys → loud, recoverable
+ tool-result error with near-miss "did you mean" (reuse `ApprovalNearMiss` shape + flexible
+ matcher). Covers M1 at `NetclawToolGenerator.ParseArguments` + `ToolCallMeta.ExtractFrom`.
+2. **Absent vs present-but-invalid.** Present-but-unparseable values surface, never coerce to
+ `0`/`false`/`null`-args. Covers M2.
+3. **Surface every override to the agent.** Clamp/floor/format-fallback/truncation emit a
+ model-facing note (reuse `ToolOutputSpill`/`AppendModelInputHandoffWarning`). Covers M3.
+4. **Policy decisions, separately.** #3 (allowlist authority), #11 (null token fail-closed),
+ #17 (audit auto-grants) need a security-owner decision, not just a notice.
+5. **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.
diff --git a/docs/runbooks/background-jobs.md b/docs/runbooks/background-jobs.md
index 7d69a83f9..896228a5b 100644
--- a/docs/runbooks/background-jobs.md
+++ b/docs/runbooks/background-jobs.md
@@ -83,9 +83,9 @@ category as `shell_execute`).
## Configuration
-Background jobs inherit the session's tool timeout ceiling
-(`MaxToolTimeoutSeconds`, default 600s). The `_timeout_seconds` metadata field
-on the tool call controls per-job timeout within that ceiling.
+The `_timeout_seconds` metadata field on the tool call sets the per-job timeout
+and is honored as requested; when omitted, the session's default tool timeout
+(`SessionConfig.ToolExecutionTimeout`) applies.
No separate configuration surface exists — background jobs use the same
approval policy and audience ACL as regular shell execution.
diff --git a/docs/spec/configuration.md b/docs/spec/configuration.md
index f1f575fc3..71e284271 100644
--- a/docs/spec/configuration.md
+++ b/docs/spec/configuration.md
@@ -189,7 +189,6 @@ shape, confirm that strict-default fallback is active, or verify that
{
"Tools": {
"ShellMode": "HostAllowed",
- "ShellTimeoutSeconds": 60,
"MaxOutputChars": 32000,
"AudienceProfiles": {
"Public": {
@@ -230,7 +229,6 @@ shape, confirm that strict-default fallback is active, or verify that
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `ShellMode` | string? | `null` | Optional shell mode override (`Off`, `SandboxOnly`, `HostAllowed`). Falls back to security posture defaults when omitted. |
-| `ShellTimeoutSeconds` | int | `60` | Timeout for shell command execution. |
| `MaxOutputChars` | int | `32000` | Maximum characters captured from tool output. |
| `AudienceProfiles` | object | built-in defaults | Per-audience tool, MCP server, and filesystem scopes. Default tool grants are monotonic — `public` ⊆ `team` ⊆ `personal`. `public` gets read-only file tools only (`file_read`, `file_list`, `attach_file`) — no file-mutation and no outbound web tools; `team` adds the file-mutation, web (`web_search`/`web_fetch`), scheduling, and skill tools but not `shell_execute`, the webhook tools, or any MCP server; `personal` defaults to unrestricted tool/file access and all MCP servers. `public` and `team` keep session-scoped file access until the operator opts in. |
@@ -536,7 +534,6 @@ export NETCLAW_Session__MaxToolIterationsPerTurn="60"
"ToolExecutionTimeoutSeconds": 90
},
"Tools": {
- "ShellTimeoutSeconds": 60,
"MaxOutputChars": 32000
}
}
diff --git a/evals/run-evals.sh b/evals/run-evals.sh
index 4d058686a..cf58ca887 100755
--- a/evals/run-evals.sh
+++ b/evals/run-evals.sh
@@ -1017,6 +1017,15 @@ assert_tool_file_list() {
stdout_contains '\[tool:call\] file_list'
}
+assert_tool_timeout_arg_recovery() {
+ # Loud arg validation: if the model emits a near-miss timeout key
+ # (TimeoutSeconds, timeout_seconds), the rejection's did-you-mean must
+ # steer it to the canonical _timeout_seconds within the turn — the
+ # command actually running is the proof of recovery.
+ stdout_contains '\[tool:call\] shell_execute' \
+ && stdout_contains 'netclaw-timeout-eval-ok'
+}
+
# Category 5: Grounding & Alignment
assert_grounding_no_hallucinate_version() {
stdout_contains '\[tool:call\]'
@@ -1477,6 +1486,10 @@ run_all() {
run_case tool_file_list "file_list called" \
"What files are in my session directory?"
+ run_case tool_timeout_arg_recovery "long-timeout shell call lands on _timeout_seconds" \
+ "Run 'echo netclaw-timeout-eval-ok' in the shell with a 5 minute timeout." \
+ "Use the shell to run: echo netclaw-timeout-eval-ok — give it a 300 second timeout since it might be slow."
+
end_category
# ── Category 5: Grounding & Alignment ──
diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md
index c063f77af..9bfdc3bb2 100644
--- a/feeds/skills/.system/files/netclaw-operations/SKILL.md
+++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md
@@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
- version: "2.11.2"
+ version: "2.12.1"
---
# Netclaw Operations
@@ -258,6 +258,14 @@ Rules:
- Only `shell_execute` supports background mode. Other tools ignore `_background`.
- `_timeout_seconds` alone does NOT trigger background execution.
+- `_timeout_seconds` is honored as you set it (there is no ceiling or floor) —
+ set it to however long the work genuinely needs. When omitted, the default
+ tool timeout applies.
+- **Long-running delegation calls** (e.g. `curl` to a local coding-agent or
+ model server that takes minutes to respond) should run as background jobs and
+ carry a `_timeout_seconds` large enough for the work. A synchronous call set
+ to a short timeout (or left at the default) will be killed mid-flight while
+ the remote server is still working.
- The user must approve the command before it starts running in the background.
- Maximum 5 concurrent background jobs; overflow queues FIFO.
- Job definitions persist to `~/.netclaw/jobs/{id}.json`.
@@ -279,6 +287,18 @@ results proactively when the job completes.
Active background jobs appear in the `[active-background-jobs]` section of the
session context on every turn.
+## Tool argument validation
+
+Tool argument names are validated strictly — unrecognized keys reject the call
+before execution with a `did you mean ''?` suggestion and the list
+of valid argument names. Meta keys are exact-match: `_timeout_seconds` and
+`_background` (a leading underscore, snake_case). `TimeoutSeconds`,
+`timeout_seconds`, or `_timeoutSeconds` are rejected, never silently dropped.
+Values must parse as their declared type: `_timeout_seconds: "1200ms"` or
+`_background: "yes"` rejects the call instead of silently using defaults. When
+a call is rejected this way the tool did NOT run — fix the argument and
+re-issue once; do not retry the same shape.
+
## Large tool output
Tool output is bounded to a small inline budget
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/.openspec.yaml b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/.openspec.yaml
new file mode 100644
index 000000000..e0c0898ff
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-11
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/design.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/design.md
new file mode 100644
index 000000000..4c602b024
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/design.md
@@ -0,0 +1,253 @@
+# Design: Loud Tool-Argument Validation
+
+## Context
+
+LLM-supplied tool arguments flow through three seams today:
+
+1. **Provider boundary** — `OpenAiCompatibleChatClient.TryDeserializeArguments`
+ parses the model's arguments JSON; on `JsonException` it returns null and the
+ call is dispatched with **null arguments**.
+2. **Pipeline meta extraction** — `ToolCallMetaExtractor.Extract` →
+ `ToolCallMeta.ExtractFrom` pulls `_rationale` / `_timeout_seconds` /
+ `_background` by **exact key match** and silently drops malformed values
+ (`_ => null` / `_ => false`). `ComputeEffectiveTimeout` silently clamps to the
+ ceiling and silently ignores below-floor hints.
+3. **Tool binding** — generated `ParseArguments` (per-tool, from
+ `NetclawToolGenerator`) reads only declared parameters via
+ `ToolArgumentHelper.Get*` (which match **flexibly**: exact first, then
+ case/punctuation-normalized via `NormalizeKey`). Unknown keys are never
+ inspected; present-but-unparseable values coerce to `0`/`0.0`/`false` through
+ `GetNullable* … ?? default`.
+
+Two channels already exist that this design reuses rather than duplicates:
+
+- **Exception → error result**: `SessionToolExecutionPipeline` catches any tool
+ exception and converts it to `resultText = "Error executing tool: {message}"`
+ (`:553-556`) — the generated `ParseArguments` already uses this for
+ missing-required (`throw new ArgumentException`).
+- **Post-bounding notice append**: `AppendModelInputHandoffWarning` (`:571-574`)
+ appends model-facing notices to `resultText` *after* `ToolOutputSpill`
+ bounding, so notices can never be windowed away.
+
+Origin incident and full site inventory: `SILENT_FALLBACK_AUDIT.md` (repo root,
+this branch); proposal.md for scope.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- No LLM-supplied argument is ever silently discarded, coerced, or overridden:
+ every such event either rejects the call with a self-describing, recoverable
+ error (before execution) or surfaces a notice in the tool result.
+- Rejection errors are actionable in one model round-trip ("did you mean
+ `_timeout_seconds`?").
+- Zero behavior change for well-formed calls.
+- No new config knobs, no new persisted types, no new actors or messages.
+
+**Non-Goals:**
+
+- No fuzzy/alias *acceptance* of argument keys (explicitly decided against —
+ see D2).
+- No change to MCP tool argument handling (`mcp-schema-coercion` + server-side
+ validation remain authoritative).
+- No policy/ACL changes (parked in proposal).
+- No stuck-loop/no-progress detection (separate workstream).
+- No change to `TextToolCallParser` type fidelity (separate design needed).
+
+## Decisions
+
+### D1: Central unknown-key validation in `DispatchingToolExecutor`, driven by the tool's schema
+
+Unknown-key checking runs once, centrally, in `DispatchingToolExecutor.ExecuteAsync`
+before `tool.ExecuteAsync`, for **native tools only** (skip `McpToolAdapter`).
+The recognized-key set is derived from the tool's existing `ParameterSchema`
+(which the generator already augments with the meta keys), computed lazily once
+per tool type and cached — no generator changes needed for the set itself.
+
+**Recognition MUST mirror actual consumption semantics**, not an idealized rule:
+
+- A supplied key is recognized iff it would actually be consumed downstream:
+ - **declared parameters**: exact match OR `NormalizeKey`-equivalent — because
+ `ToolArgumentHelper.TryGetValueFlexible` already binds flexibly today;
+ - **meta keys** (`_`-prefixed): **exact match only** — because
+ `ToolCallMeta.ExtractFrom` extracts exactly.
+- Anything else → reject with a tool-result error, before execution.
+
+If recognition were stricter than binding (exact-only for declared params), the
+Qwen text-parser path — which emits lowercased keys that flexible binding
+accepts today — would start failing on working calls. If it were looser than
+extraction (flexible for meta keys), `TimeoutSeconds` would be "recognized" but
+never consumed — recreating the original bug behind the validator.
+
+*Alternatives considered:* (a) emit the check inside generated `ParseArguments`
+— rejected: N generated copies of one rule, and the executor seam also covers
+direct callers; (b) validate in `SessionToolExecutionPipeline` — rejected:
+sub-agent and non-pipeline dispatch paths also funnel through
+`DispatchingToolExecutor`, making it the true chokepoint.
+
+### D2: Suggestions only — fuzzy matching never accepts
+
+**Locked decision (user):** the system never acts on a guessed key. The
+dividing line is *who resolves ambiguity*:
+
+- **Deterministic canonicalization** (existing `NormalizeKey` case/punctuation
+ folding for declared params) is retained — it is existing, deterministic
+ consumption behavior, not guessing, and removing it would break working
+ callers.
+- **Guess-based matching** (edit distance, near-miss against meta keys) is used
+ **only to generate the suggestion text** in the rejection error:
+ `Unrecognized argument 'TimeoutSeconds'. Did you mean '_timeout_seconds'?
+ The tool was NOT executed.` The LLM resolves the ambiguity by re-issuing
+ explicitly.
+
+Suggestion generation: `NormalizeKey`-equality against meta keys first (catches
+the entire `TimeoutSeconds`/`_timeoutSeconds`/`timeout_seconds` family), then
+edit-distance ≤ 2 against all recognized names. Modeled on the
+`ApprovalNearMiss` shape (`ApprovalPatternMatching`): classify, describe,
+never alter the decision. The error also lists the tool's valid argument names
+(bounded — native tools have ≤ ~6 params + 3 meta keys).
+
+### D3: Present-but-invalid values reject via strict helper variants
+
+`ToolArgumentHelper` gains strict variants (`GetIntStrict`, `GetDoubleStrict`,
+`GetBoolStrict`, and nullable counterparts) that distinguish three states:
+**absent** (→ documented default, unchanged), **parsed** (→ value), and
+**present-but-invalid** (→ `throw ArgumentException("Parameter 'Limit' value
+'abc' is not a valid integer.")`). `NetclawToolGenerator` emits the strict
+variants in `ParseArguments`; the existing `ArgumentException` → pipeline
+catch → error-result channel surfaces it. Two latent value bugs are fixed in
+the same pass: `double d => (int)d` silent truncation (12.7 → 12) becomes
+invalid-unless-integral, and `JsonElement.GetInt32()` on non-integral/overflow
+numbers (currently an **uncaught throw**) becomes `TryGetInt32` →
+present-but-invalid.
+
+The non-strict `GetNullable*` helpers remain for callers that legitimately
+treat unparseable as absent (none known in generated code after this change;
+audit flagged the `?? 0/0.0/false` arms specifically).
+
+### D4: Malformed meta values reject the call — computed in the pipeline layer, not persisted
+
+`ToolCallMeta.ExtractFrom` keeps its signature and the persisted `ToolCallMeta`
+type is **unchanged** (it is persistence-owned; adding transient validation
+state to it would leak pipeline concerns into the serialization contract).
+Instead, `ToolCallMetaExtractor.Extract` (pipeline-side) returns validation
+errors alongside the meta: a present-but-invalid `_timeout_seconds` or
+`_background` value produces a tool-result error **before dispatch** — the
+agent expressed execution semantics we cannot honor, so we do not run the call
+on different semantics. Same rejection channel as D1/D3.
+
+### D5: Override notices via a `Notices` list on `ToolExecutionContext`, appended post-bounding
+
+`ToolExecutionContext` (already flowing through every seam — per the
+constitution, reuse what is already at the call site) gains a
+`List Notices`. Producers:
+
+- `SessionToolExecutionPipeline` / `ToolCallMetaExtractor.ComputeEffectiveTimeout`:
+ ceiling clamp → `[timeout clamped: requested 1200s, maximum 600s — use
+ _background:true for longer work]`; below-floor → `[timeout request 10s is
+ below the 60s tool default; 60s applied]`.
+- `WebFetchTool`: response-byte cap reached → `[content truncated at 5 MB — N
+ bytes not fetched]`.
+
+Notices are appended to `resultText` at the existing
+`AppendModelInputHandoffWarning` seam — after `ToolOutputSpill` bounding, so a
+notice can never be spilled or windowed away. Notices are additive text on the
+existing result string: no persistence change (results are already persisted as
+strings).
+
+*Alternative considered:* return notices through tool return values — rejected:
+changes the `string`-returning tool contract for every tool; the context object
+already traverses the exact path needed.
+
+### D6: Provider-boundary malformed args JSON → sentinel argument, rejected pre-dispatch
+
+When `TryDeserializeArguments` fails, instead of dispatching null arguments,
+the client attaches a single sentinel entry
+`__netclaw_args_parse_error: ""`. The pipeline detects the sentinel before meta extraction and emits
+a tool-result error for that call id without dispatching: `Tool call arguments
+were not valid JSON: …. The tool was NOT executed.`
+
+*Alternatives considered:* (a) custom `AIContent` subtype — rejected: invasive
+across message conversion and persistence for one error path;
+(b) drop the call silently — violates the invariant being established;
+(c) keep null-args dispatch + detect downstream — rejected: the raw payload
+(needed for a useful error) is only available at the client.
+The sentinel never collides with validation: it is checked and consumed before
+D1 runs, and if it ever leaked it is not in any schema → rejected loudly anyway.
+On persistence re-drive the sentinel round-trips as an ordinary argument and the
+pipeline rejects it again pre-dispatch — deterministic on replay.
+
+### D7: In-tool fixes — `web_fetch` format, `list_webhooks` filter
+
+- `WebFetchTool`: `Format` validated against `{null, "raw", "text"}`;
+ anything else → `ArgumentException` (same channel as D3). The silent
+ `useTextMode = Format == "text"` fallback is removed.
+- `ListWebhooksTool`: `Filter` honored — `"active"` (default) filters on
+ `definition.Enabled`, `"all"` returns everything, any other value rejects;
+ the applied filter is echoed in the result header.
+
+### D8: No escape hatches
+
+No config knob disables validation (a toggle would be a sanctioned silent
+fallback). If a future tool legitimately accepts free-form keys, it must opt
+out explicitly in source (e.g. an `[AllowUnknownArguments]` attribute on the
+tool class) where it is visible to review — not at runtime.
+
+## Actor Boundaries and Persistence Implications
+
+- **No new actors, messages, or protocols.** All changes live inside the
+ session actor's existing tool-execution pipeline (`LlmSessionActor` →
+ `SessionToolExecutionPipeline` → `DispatchingToolExecutor` → tool classes),
+ which is already transport-agnostic. Sub-agent dispatch funnels through the
+ same `DispatchingToolExecutor` and inherits validation unchanged.
+- **No persisted-type changes.** `ToolCallMeta` / `SerializableToolCall` are
+ untouched (D4 keeps validation state pipeline-side). Rejection errors and
+ notices are ordinary tool-result strings, persisted through the existing
+ `SerializableChatMessage` path. Legacy persisted tool calls deserialize and
+ re-drive exactly as before; a re-driven call carrying a bad key is rejected
+ deterministically (same input → same error), which is the correct replay
+ semantic.
+
+## Failure Modes and Recovery
+
+- **Validator rejects a key a model insists on** → error is recoverable and
+ self-describing (valid-key list + suggestion); the model corrects in one
+ round-trip. If a model loops on the same rejection, that is the (separate)
+ stuck-loop workstream's domain; the error text is deterministic, so loop
+ detection sees identical failures — the easy case.
+- **False rejection of a working call pattern** (biggest risk — e.g. a text-
+ parser key shape we did not anticipate) → recognition mirrors binding
+ semantics exactly (D1), and the eval suite + a replay of representative
+ session logs gate the release. Recovery: revert; no data migration involved.
+- **A tool with intentionally dynamic args breaks** → contingency is the
+ explicit source-level opt-out (D8); audit during implementation confirms no
+ current native tool needs it.
+- **Notice text inflates context** → notices are single bounded lines, appended
+ at most once per producer per call.
+- **Sentinel arg persisted mid-rollout, processed post-rollback** → an unknown
+ `__netclaw_args_parse_error` arg under old code is dropped by old binding
+ (the old silent behavior) — degraded but not corrupt.
+
+## Migration Plan
+
+1. Land helpers + generator change + validator behind nothing (no flag — the
+ change is the behavior).
+2. Run `dotnet slopwatch analyze`, full test suite, eval suite (tool-definition
+ change → required per constitution), and the light smoke tapes.
+3. Release on the beta channel; watch session logs for `Unrecognized argument`
+ / `not valid` error rates and any new tool-error loops.
+4. Stable release after beta soak.
+5. Rollback: revert the release tag; no persisted-schema or config migration in
+ either direction.
+
+## Open Questions
+
+1. Should the rejection error enumerate valid keys always, or only when no
+ near-miss suggestion is found? (Leaning: always — the list is small and
+ removes a second failure round-trip.)
+2. `skill_manage` / `set_webhook` accept structured sub-objects — confirm during
+ implementation that their generated param surface is flat (expected) so the
+ top-level key diff is sufficient; nested-object validation is out of scope.
+3. Exact wording of the `_background` steer in the clamp notice — coordinate
+ with the `netclaw-operations` skill update so the two use identical phrasing.
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/proposal.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/proposal.md
new file mode 100644
index 000000000..7dfbc67f1
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/proposal.md
@@ -0,0 +1,139 @@
+# Proposal: Loud Tool-Argument Validation
+
+## Why
+
+Netclaw silently discards or degrades LLM-supplied tool arguments: unknown keys are
+dropped without signal, present-but-unparseable values coerce to defaults, and
+requested values (timeouts) are clamped or ignored with no notice in the tool result.
+In production session `D0AC6CKBK5K_1781115410_840529`, the agent passed
+`"TimeoutSeconds":"1200"` (instead of the recognized `_timeout_seconds`), the key was
+silently dropped, the shell timeout fell back to a 90s default, and the agent's call
+to its code delegate was killed mid-flight — the agent formed a false belief ("I set a
+generous timeout") that fed a multi-hour stuck loop. A follow-up audit
+(`SILENT_FALLBACK_AUDIT.md`) found 17 sites sharing three mechanisms. This violates
+the constitution's no-silent-fallbacks rule and the secure-by-default posture of
+PRD-001 / PRD-002: the config surface already enforces strict validation
+(`additionalProperties: false` + `ConfigSchemaDoctorCheck`), but the tool-call
+argument surface — the agent's highest-frequency input path — has no equivalent.
+
+## What Changes
+
+- **Unknown-key rejection (M1).** A tool call carrying an argument key that matches
+ neither the tool's declared parameters nor the meta keys (`_rationale`,
+ `_timeout_seconds`, `_background`) is rejected with a recoverable tool-result error
+ before execution. The error names the unrecognized key and, when a near-miss is
+ detected, includes a "did you mean ``?" suggestion. **Fuzzy matching is
+ used ONLY to generate the suggestion text — never to accept a near-miss key.**
+ (Decided: no alias acceptance; system-side intent-guessing on a surface carrying
+ timeout/background/path semantics has unacceptable blast radius. The LLM resolves
+ the ambiguity explicitly by re-issuing.)
+- **Present-but-invalid value rejection (M2).** A value that is present but
+ unparseable for its declared type (e.g. `"abc"` for an int parameter,
+ `_timeout_seconds: "1200ms"`, `_background: "yes"`) returns a tool-result error
+ naming the parameter, the supplied value, and the expected type — instead of
+ silently coercing to `0`/`false`/null. Absent optional parameters keep their
+ documented defaults (no intent expressed → no error).
+- **Malformed tool-call JSON surfaces (M2, provider boundary).** A tool call whose
+ arguments JSON fails to deserialize produces a tool-result error for that call id
+ instead of dispatching the call with null arguments.
+- **Override notices (M3).** Every silent override of an agent-expressed value emits
+ a model-facing notice appended to the tool result, reusing the existing
+ `ToolOutputSpill.Compose` / `AppendModelInputHandoffWarning` notice patterns:
+ - timeout hint clamped to `MaxToolTimeoutSeconds` ceiling → `[timeout clamped from
+ 1200s to 600s maximum; use _background:true for longer work]`
+ - timeout hint below the tool default floor → noted, not silently ignored
+ - `web_fetch` `Format` value outside `{raw, text}` → error (not silent raw fallback)
+ - `web_fetch` 5 MB response cap reached → truncation marker in the summary
+- **Phantom argument fix.** `list_webhooks`' schema-advertised `Filter` parameter is
+ currently never read (complete no-op); it will be honored (filter on
+ `definition.Enabled`) with the applied filter echoed in the result.
+- **Skill guidance.** `netclaw-operations` system skill updated: long-running
+ delegation calls (e.g. HTTP calls to a local coding-agent server) must use
+ `_background: true` rather than a synchronous shell call under the timeout ceiling.
+
+Not breaking for well-formed callers: tool calls using declared parameters and valid
+values behave identically. Calls that previously "succeeded" by silently dropping
+arguments will now error — that is the intended behavior change.
+
+## Capabilities
+
+### New Capabilities
+
+- `tool-arg-validation`: validation contract for LLM-supplied tool arguments at the
+ dispatch seam — unknown-key rejection with suggestion-only near-miss matching,
+ present-but-invalid value rejection, absent-vs-invalid distinction, malformed
+ args-JSON handling at the provider boundary, and the model-facing override-notice
+ mechanism.
+
+### Modified Capabilities
+
+- `tool-call-metadata`: the "Per-call timeout hint" requirement currently specifies
+ *silent* clamping to the ceiling and *silent* ignoring of below-floor hints
+ (scenarios "Timeout hint exceeds ceiling", "Timeout hint below tool default
+ ignored"). Both change: the effective value still clamps/floors, but the override
+ is surfaced in the tool result. Malformed meta values (`_timeout_seconds`,
+ `_background`) change from silent drop to a tool-result error.
+- `netclaw-tools`: `web_fetch` gains explicit `Format` validation and a truncation
+ marker at the response-byte cap; `list_webhooks` gains honored `Filter` semantics.
+
+## Impact
+
+- **Affected code:**
+ - `Netclaw.Tools.Generators/NetclawToolGenerator.cs` (generated `ParseArguments` —
+ unknown-key diff + invalid-value errors; this is the seam covering all ~20 native
+ tools)
+ - `Netclaw.Tools.Abstractions/ToolCallMeta.cs`, `ToolArgumentHelper.cs` (meta
+ extraction, absent-vs-invalid distinction; also fix the latent uncaught-throw on
+ non-integral JSON numbers via `TryGetInt32`)
+ - `Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs`,
+ `SessionToolExecutionPipeline.cs` (clamp/floor notices on the result path)
+ - `Netclaw.Providers/SelfHosted/OpenAiCompatibleChatClient.cs`
+ (`TryDeserializeArguments` null-args dispatch)
+ - `Netclaw.Actors/Tools/WebFetchTool.cs`, `ListWebhooksTool.cs`
+ - `feeds/skills/.system/files/netclaw-operations/SKILL.md` (+ version bump, per the
+ System Skills Sync Rule)
+- **Reused constructs (no new parallel mechanisms):** near-miss suggestion modeled on
+ `ApprovalPatternMatching`'s `ApprovalNearMiss` shape; notices via the existing
+ result-append patterns; key normalization via `ToolArgumentHelper.NormalizeKey`
+ (for suggestion generation only).
+- **MCP tools:** unchanged — MCP servers validate their own schemas and reject
+ observably through `McpToolAdapter`'s existing error surface (`mcp-schema-coercion`
+ remains authoritative).
+- **Tests/evals:** tool-definition behavior changes → eval suite run required per the
+ constitution's Eval Suite rule; new unit coverage for the validation seam.
+
+### Security and Operational Impact
+
+- **Security:** net positive — closes a class the constitution flags as
+ privilege-escalation-adjacent (silently altered execution semantics). No policy or
+ ACL decision changes in this change. Rejection happens *before* execution, so no
+ partial side effects. The validator is fail-closed: ambiguity → error, never guess.
+- **Operational:** a transient rise in tool-call errors is expected immediately after
+ deployment as resident models learn canonical keys from the error messages; errors
+ are self-describing and recoverable in one round-trip, so no operator action is
+ required. Override notices add small, bounded text to tool results.
+
+### In Scope (MVP) vs Out of Scope
+
+**In scope:** the four mechanisms above (M1, M2, M3, skill guidance) plus the
+`list_webhooks` phantom-arg fix.
+
+**Out of scope — parked as open questions for a security owner** (from
+`SILENT_FALLBACK_AUDIT.md`, policy layer):
+
+1. Audience allowlist non-authority: `ToolAudienceProfileResolver.IsProfileManagedTool`
+ silently exempts non-managed tools (memory tools, `search_tools`, `load_tool`,
+ `spawn_agent`, `check_background_job`) from `Allowlist` profiles — needs a
+ product decision (govern vs document).
+2. Non-interactive shell trust-zone enforcement skips unnormalizable path tokens
+ (`continue`) while the working-directory branch fails closed — inconsistency
+ should be resolved fail-closed.
+3. Safe-verb auto-allow short-circuit emits no audit line — observability decision.
+
+Also out of scope: stuck-loop / no-progress detection for agent sessions (separate
+investigation, same originating incident); text-format tool-call parser type fidelity
+(`TextToolCallParser` string flattening — borderline, needs its own design).
+
+**Traceability:** PRD-001 (MVP tool surface), PRD-002 (gateway security envelope /
+fail-closed posture); constitution "No silent fallbacks" quality bar; origin incident
+documented in memorizer memory `e9a72b27-72d9-4e98-aad7-4d970ce52ecf`.
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/netclaw-tools/spec.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/netclaw-tools/spec.md
new file mode 100644
index 000000000..8ecdc3979
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/netclaw-tools/spec.md
@@ -0,0 +1,72 @@
+# netclaw-tools — Delta Spec
+
+## ADDED Requirements
+
+### Requirement: Web fetch format is validated
+
+The `web_fetch` tool SHALL validate the `Format` argument against the supported
+set (absent, `"raw"`, `"text"`). Any other value SHALL reject the call with a
+tool-result error naming the supplied value and the supported set. The tool
+SHALL NOT silently fall back to raw mode for an unsupported format value.
+
+#### Scenario: Unsupported format value rejects
+
+- **GIVEN** a `web_fetch` call with `"Format": "markdown"`
+- **WHEN** arguments are validated
+- **THEN** the call is rejected with an error naming `"markdown"` and the
+ supported values `raw` and `text`
+- **AND** no HTTP request is made
+
+#### Scenario: Supported formats behave unchanged
+
+- **GIVEN** a `web_fetch` call with `"Format": "text"` (or `Format` absent)
+- **WHEN** the fetch executes
+- **THEN** behavior is identical to current behavior
+
+### Requirement: Web fetch response-cap truncation is surfaced
+
+The `web_fetch` result SHALL include a notice stating the content was
+truncated at the cap whenever a fetched response body reaches the
+response-byte cap.
+The captured byte count alone SHALL NOT be the only signal.
+
+#### Scenario: Body larger than the cap carries a truncation notice
+
+- **GIVEN** a URL whose response body exceeds the 5 MB response cap
+- **WHEN** `web_fetch` returns its summary
+- **THEN** the result includes a notice that content was truncated at 5 MB
+
+#### Scenario: Body under the cap carries no truncation notice
+
+- **GIVEN** a URL whose response body is under the response cap
+- **WHEN** `web_fetch` returns its summary
+- **THEN** no truncation notice is present
+
+### Requirement: Webhook listing honors its filter argument
+
+The `list_webhooks` tool SHALL honor its schema-advertised `Filter` argument:
+`"active"` (the default) SHALL return only enabled webhooks, `"all"` SHALL
+return every webhook, and any other value SHALL reject the call naming the
+supported values. The applied filter SHALL be echoed in the result.
+
+#### Scenario: Active filter excludes disabled webhooks
+
+- **GIVEN** two registered webhooks, one enabled and one disabled
+- **AND** a `list_webhooks` call with `"Filter": "active"` (or `Filter` absent)
+- **WHEN** the tool executes
+- **THEN** only the enabled webhook is listed
+- **AND** the result states the `active` filter was applied
+
+#### Scenario: All filter includes disabled webhooks
+
+- **GIVEN** two registered webhooks, one enabled and one disabled
+- **AND** a `list_webhooks` call with `"Filter": "all"`
+- **WHEN** the tool executes
+- **THEN** both webhooks are listed with their enabled state
+- **AND** the result states the `all` filter was applied
+
+#### Scenario: Unknown filter value rejects
+
+- **GIVEN** a `list_webhooks` call with `"Filter": "enabled"`
+- **WHEN** arguments are validated
+- **THEN** the call is rejected naming the supported values `active` and `all`
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-arg-validation/spec.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-arg-validation/spec.md
new file mode 100644
index 000000000..40139a1c5
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-arg-validation/spec.md
@@ -0,0 +1,135 @@
+# tool-arg-validation — Delta Spec
+
+## ADDED Requirements
+
+### Requirement: Unknown argument keys reject the call before execution
+
+For native (first-party) tools, the dispatcher SHALL validate every supplied
+argument key against the tool's recognized-key set before execution. A supplied
+key is recognized if and only if it would be consumed downstream:
+
+- a declared tool parameter, matched exactly or by deterministic key
+ normalization (case/punctuation folding, mirroring existing flexible binding);
+- a meta key (`_rationale`, `_timeout_seconds`, `_background`), matched
+ **exactly only**.
+
+A call carrying one or more unrecognized keys SHALL be rejected with a
+tool-result error and the tool SHALL NOT execute. The error SHALL name each
+unrecognized key, state that the tool was not executed, and list the tool's
+valid argument names. MCP tools are exempt (server-side schema validation is
+authoritative).
+
+#### Scenario: Near-miss meta key rejected with suggestion
+
+- **GIVEN** a `shell_execute` call with `"TimeoutSeconds": "1200"`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected without executing the command
+- **AND** the tool result contains `Unrecognized argument 'TimeoutSeconds'`,
+ a `did you mean '_timeout_seconds'` suggestion, and the valid argument names
+
+#### Scenario: Case-variant declared parameter still accepted
+
+- **GIVEN** a `shell_execute` call with `"command": "ls"` (lowercase)
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the key is recognized via deterministic normalization
+- **AND** the tool executes exactly as it does today
+
+#### Scenario: Exact meta key accepted
+
+- **GIVEN** a tool call with `"_timeout_seconds": 300`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the key is recognized and extraction consumes it
+
+#### Scenario: Wholly unknown key rejected without suggestion
+
+- **GIVEN** a `file_read` call with `"Banana": true` and a valid `Path`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected naming `Banana` with no near-miss suggestion
+- **AND** the valid argument names for `file_read` are listed
+
+#### Scenario: MCP tool exempt from native key validation
+
+- **GIVEN** a tool call targeting an MCP server tool with an extra key
+- **WHEN** the dispatcher processes the call
+- **THEN** native key validation is skipped
+- **AND** the MCP server's own schema validation result is returned observably
+
+### Requirement: Fuzzy matching generates suggestions only — never acceptance
+
+Near-miss matching SHALL be used solely to generate "did you mean" suggestion
+text inside rejection errors (normalization equivalence against meta keys,
+edit distance against recognized names). The system SHALL NOT bind, alias, or
+otherwise act on a guessed key. Ambiguity SHALL always be resolved by the LLM
+re-issuing the call explicitly.
+
+#### Scenario: Near-miss key is never silently bound
+
+- **GIVEN** a tool call with `"timeout_seconds": 300` (missing the `_` prefix)
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected with a `did you mean '_timeout_seconds'`
+ suggestion
+- **AND** no timeout override is applied from the near-miss key
+
+### Requirement: Present-but-invalid argument values reject the call
+
+For native tools, the system SHALL reject a call whose argument key is
+recognized but whose value cannot be parsed as the declared type, with a
+tool-result error naming the parameter, the supplied value, and the expected
+type. The tool SHALL
+NOT execute. An absent optional parameter SHALL continue to use its documented
+default (absence expresses no intent; invalidity does). Numeric coercion SHALL
+NOT silently truncate: a non-integral value supplied for an integer parameter
+is invalid.
+
+#### Scenario: Unparseable integer rejects instead of coercing to zero
+
+- **GIVEN** a `file_read` call with `"Limit": "abc"`
+- **WHEN** arguments are bound
+- **THEN** the call is rejected with an error naming `Limit`, the value
+ `"abc"`, and the expected type integer
+- **AND** the file is not read
+
+#### Scenario: Non-integral number for integer parameter is invalid
+
+- **GIVEN** a tool call supplying `12.7` for an integer parameter
+- **WHEN** arguments are bound
+- **THEN** the call is rejected (no silent truncation to 12)
+
+#### Scenario: Absent optional parameter keeps its default
+
+- **GIVEN** a `file_read` call that omits `Limit`
+- **WHEN** arguments are bound
+- **THEN** the documented default applies and no error is raised
+
+### Requirement: Malformed tool-call arguments JSON rejects before dispatch
+
+The pipeline SHALL produce a tool-result error for a tool call whose arguments
+JSON the provider boundary fails to deserialize, stating the arguments were
+not valid JSON; the tool SHALL NOT be dispatched with null or empty arguments. The error SHALL include the parse failure detail so the
+model can correct its emission.
+
+#### Scenario: Truncated arguments JSON surfaces as an error result
+
+- **GIVEN** a streamed tool call whose accumulated arguments JSON is truncated
+ and fails to parse
+- **WHEN** the pipeline processes the call
+- **THEN** a tool-result error for that call id states the arguments were not
+ valid JSON and the tool was not executed
+- **AND** no tool receives a null-argument invocation
+
+### Requirement: Overridden argument values are surfaced to the model
+
+The system SHALL append a model-facing notice to a call's tool result whenever
+it honors the call but applies a value different from the one the LLM
+requested (clamping, flooring, capping), describing the requested value, the
+applied value, and the reason. Notices SHALL be appended after output
+bounding so they cannot be truncated away. Log-only signaling SHALL NOT
+satisfy this requirement: the notice MUST appear in the tool result the model
+reads.
+
+#### Scenario: Notice survives output bounding
+
+- **GIVEN** a tool call whose result exceeds the inline output budget
+- **AND** an override notice applies to the call
+- **WHEN** the result is bounded and spilled
+- **THEN** the notice is present in the inline result returned to the model
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-call-metadata/spec.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-call-metadata/spec.md
new file mode 100644
index 000000000..f3a422129
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/specs/tool-call-metadata/spec.md
@@ -0,0 +1,93 @@
+# tool-call-metadata — Delta Spec
+
+## MODIFIED Requirements
+
+### Requirement: Per-call timeout hint
+
+The `_timeout_seconds` field SHALL allow the LLM to request a per-call timeout
+override. The value SHALL be clamped to a configurable ceiling
+(`ToolConfig.MaxToolTimeoutSeconds`, default 600). Values below the tool's
+default timeout SHALL NOT lower the timeout (the default applies). The pipeline
+SHALL use the effective value when creating the per-call
+`CancellationTokenSource`. Whenever the effective value differs from the
+requested value (ceiling clamp or below-floor request), the pipeline SHALL
+append a model-facing notice to the tool result stating the requested value,
+the applied value, and — for ceiling clamps — steering the model to
+`_background: true` for longer work. Silent clamping or silent ignoring of the
+hint SHALL NOT occur.
+
+#### Scenario: Timeout hint applied within ceiling
+
+- **GIVEN** `MaxToolTimeoutSeconds` is 600
+- **AND** the LLM requests `_timeout_seconds: 300` on a shell_execute call
+- **WHEN** the pipeline creates the cancellation token
+- **THEN** the timeout is set to 300 seconds
+- **AND** no override notice is appended (requested value was honored)
+
+#### Scenario: Timeout hint exceeds ceiling
+
+- **GIVEN** `MaxToolTimeoutSeconds` is 600
+- **AND** the LLM requests `_timeout_seconds: 1200`
+- **WHEN** the pipeline creates the cancellation token
+- **THEN** the timeout is clamped to 600 seconds
+- **AND** the tool result includes a notice stating 1200s was requested, 600s
+ was applied, and `_background: true` is available for longer work
+
+#### Scenario: Timeout hint below tool default surfaces a notice
+
+- **GIVEN** `ShellTimeoutSeconds` is 60 (shell tool default)
+- **AND** the LLM requests `_timeout_seconds: 10`
+- **WHEN** the pipeline creates the cancellation token
+- **THEN** the timeout remains at 60 seconds (the tool default)
+- **AND** the tool result includes a notice stating 10s was requested and the
+ 60s tool default was applied
+
+#### Scenario: No timeout hint uses default
+
+- **GIVEN** the LLM does not provide `_timeout_seconds`
+- **WHEN** the pipeline creates the cancellation token
+- **THEN** the existing default timeout applies (60s for shell, 90s for
+ general tool execution)
+- **AND** no override notice is appended (no intent was expressed)
+
+## ADDED Requirements
+
+### Requirement: Malformed meta values reject the call
+
+The pipeline SHALL reject a tool call carrying a meta key whose value cannot
+be parsed as its declared type (`_timeout_seconds` not a positive integer;
+`_background` not a boolean) with a tool-result error before dispatch, naming
+the meta key, the supplied value, and the expected type. The tool SHALL NOT execute
+with default semantics in place of the expressed intent. Validation state SHALL
+be computed pipeline-side; the persisted `ToolCallMeta` type SHALL remain
+unchanged.
+
+#### Scenario: Unparseable timeout value rejects instead of silently defaulting
+
+- **GIVEN** a tool call with `"_timeout_seconds": "1200ms"`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the call is rejected with an error naming `_timeout_seconds`, the
+ value `"1200ms"`, and the expected type positive integer
+- **AND** the tool does not execute under the default timeout
+
+#### Scenario: Non-boolean background value rejects
+
+- **GIVEN** a tool call with `"_background": "yes"`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the call is rejected with an error naming `_background` and the
+ expected type boolean
+- **AND** the tool does not execute synchronously in place of the request
+
+#### Scenario: Non-integral JSON number for timeout is handled, not thrown
+
+- **GIVEN** a tool call with `"_timeout_seconds": 12.5`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** extraction does not throw an uncaught exception
+- **AND** the call is rejected as present-but-invalid
+
+#### Scenario: Legacy persisted tool call re-drives deterministically
+
+- **GIVEN** a persisted tool call carrying a malformed meta value is re-driven
+ after recovery
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the same rejection error is produced (deterministic on replay)
diff --git a/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/tasks.md b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/tasks.md
new file mode 100644
index 000000000..9f59fdc7f
--- /dev/null
+++ b/openspec/changes/archive/2026-06-11-loud-tool-arg-validation/tasks.md
@@ -0,0 +1,120 @@
+# Tasks: loud-tool-arg-validation
+
+## 1. Value-parsing helpers (foundation — no behavior change yet)
+
+- [x] 1.1 Add strict variants to `ToolArgumentHelper` (`GetIntStrict`,
+ `GetDoubleStrict`, `GetBoolStrict` + nullable counterparts) that
+ distinguish absent / parsed / present-but-invalid; present-but-invalid
+ throws `ArgumentException` naming parameter, supplied value, and expected
+ type. Non-integral numeric for integer parameter is invalid (no `(int)d`
+ truncation); replace `JsonElement.GetInt32()` with `TryGetInt32` so
+ overflow/non-integral never throws uncaught.
+- [x] 1.2 Unit tests for the strict helpers: absent → null/default, valid
+ parses (int/long/string-number/JsonElement), invalid string, non-integral
+ double, overflow JSON number, bool variants (`"yes"`, `1`, `"true"`).
+
+## 2. Generator: bind with strict helpers
+
+- [x] 2.1 Update `NetclawToolGenerator.ParseArguments` emission to call the
+ strict variants for integer/number/boolean parameters (required,
+ nullable, and optional-with-default arms) so present-but-invalid throws
+ instead of coercing to `0`/`0.0`/`false`.
+- [x] 2.2 Snapshot/golden tests for the generator output covering each
+ parameter arm; verify a representative generated tool
+ (e.g. `file_read` `Limit: "abc"`) surfaces
+ `Error executing tool: Parameter 'Limit' value 'abc'…` through the
+ pipeline catch.
+
+## 3. Unknown-key validation in the dispatcher
+
+- [x] 3.1 Compute and cache the recognized-key set per native tool in
+ `DispatchingToolExecutor` (or `NetclawToolBase`): schema property names
+ from `ParameterSchema` (includes meta keys); recognition = exact OR
+ `NormalizeKey`-equal for declared params, exact-only for `_`-prefixed
+ meta keys. Skip `McpToolAdapter`.
+- [x] 3.2 Implement rejection: unrecognized key(s) → return tool-result error
+ (do not execute) naming each key, stating the tool was NOT executed, and
+ listing valid argument names.
+- [x] 3.3 Implement suggestion generation (suggestion text ONLY — never
+ acceptance): `NormalizeKey`-equality against meta keys first, then edit
+ distance ≤ 2 against recognized names; model the near-miss
+ classification on `ApprovalPatternMatching`'s `ApprovalNearMiss` shape.
+- [x] 3.4 Unit tests: `TimeoutSeconds` → rejected with `_timeout_seconds`
+ suggestion; `timeout_seconds` → rejected with suggestion (never bound);
+ lowercase `command` → accepted (flexible binding preserved); exact
+ `_timeout_seconds` → accepted; wholly unknown key → rejected without
+ suggestion; MCP tool with extra key → not validated natively.
+- [x] 3.5 Audit all native tools for intentionally free-form argument surfaces;
+ if any exists, add the explicit source-level opt-out
+ (`[AllowUnknownArguments]`) and a test proving it is honored — otherwise
+ record "none needed" in the PR description.
+
+## 4. Meta-value validation and override notices (pipeline)
+
+- [x] 4.1 Extend `ToolCallMetaExtractor.Extract` (pipeline-side; persisted
+ `ToolCallMeta` type unchanged) to report present-but-invalid
+ `_timeout_seconds` / `_background` values; pipeline rejects the call
+ pre-dispatch with an error naming key, value, expected type.
+- [x] 4.2 Change `ComputeEffectiveTimeout` to report when the effective value
+ differs from the requested value (ceiling clamp, below-floor); plumb as
+ a notice, not a silent return.
+- [x] 4.3 Add `Notices` accumulation to `ToolExecutionContext` and append
+ notices to `resultText` at the existing `AppendModelInputHandoffWarning`
+ seam (post-bounding, so notices cannot be spilled away). Clamp notice
+ text steers to `_background: true` for longer work.
+- [x] 4.4 Unit tests: 1200s request with 600s ceiling → executes at 600s AND
+ result contains the clamp notice with `_background` steer; 10s request
+ with 60s floor → executes at 60s with notice; honored 300s request → no
+ notice; `_timeout_seconds: "1200ms"` → rejected pre-dispatch;
+ `_background: "yes"` → rejected; `_timeout_seconds: 12.5` → rejected
+ without uncaught throw; notice survives an over-budget result that
+ spills.
+
+## 5. Provider boundary: malformed arguments JSON
+
+- [x] 5.1 In `OpenAiCompatibleChatClient`, on `TryDeserializeArguments`
+ failure attach the `__netclaw_args_parse_error` sentinel (exception
+ message + first 200 chars of raw payload) instead of returning null
+ arguments.
+- [x] 5.2 In `SessionToolExecutionPipeline`, detect the sentinel before meta
+ extraction and emit a tool-result error for that call id without
+ dispatching ("arguments were not valid JSON… The tool was NOT
+ executed.").
+- [x] 5.3 Tests: truncated args JSON → error result for the call id, no tool
+ invocation; sentinel round-trips persistence and re-drives to the same
+ rejection deterministically.
+
+## 6. In-tool fixes
+
+- [x] 6.1 `WebFetchTool`: validate `Format ∈ {absent, "raw", "text"}`; reject
+ anything else (no silent raw fallback). Detect response-byte-cap hit in
+ `ReadBytesWithLimitAsync` and add the truncation notice to the summary.
+- [x] 6.2 `ListWebhooksTool`: honor `Filter` — `"active"` (default) filters on
+ `definition.Enabled`, `"all"` returns everything, other values reject;
+ echo the applied filter in the result.
+- [x] 6.3 Tests: unsupported format rejects with no HTTP request; >5 MB body
+ carries truncation notice, under-cap body does not; active/all/unknown
+ filter scenarios.
+
+## 7. Skill and documentation sync
+
+- [x] 7.1 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md`:
+ long-running delegation calls must use `_background: true` (phrasing
+ identical to the clamp-notice steer); bump `metadata.version`.
+- [x] 7.2 Verify no `Netclaw.Configuration` `*Config` property changed (no
+ schema sync needed) — confirm in PR description.
+
+## 8. Quality gates and verification
+
+- [x] 8.1 `dotnet slopwatch analyze` — no new violations.
+- [x] 8.2 `./scripts/Add-FileHeaders.ps1 -Verify` — headers on any new files.
+- [x] 8.3 Run the eval suite (`./evals/run-evals.sh`) — tool definitions
+ changed; add/adjust an eval case asserting the model recovers from an
+ unknown-key rejection in one round-trip.
+- [x] 8.4 Replay regression: drive the recorded arg shapes from session
+ `D0AC6CKBK5K_1781115410_840529` (`"TimeoutSeconds":"1200"` on
+ shell_execute) against the validator and assert the rejection +
+ suggestion; confirm representative text-parser (lowercase-key) calls
+ from session logs still bind.
+- [x] 8.5 Update `SILENT_FALLBACK_AUDIT.md` rows fixed by this change with
+ their resolution status.
diff --git a/openspec/specs/netclaw-tools/spec.md b/openspec/specs/netclaw-tools/spec.md
index 977fd7be7..1932f06bd 100644
--- a/openspec/specs/netclaw-tools/spec.md
+++ b/openspec/specs/netclaw-tools/spec.md
@@ -237,6 +237,74 @@ present in the media catalog.
- **WHEN** `web_fetch` saves the response
- **THEN** it chooses `.pdf` from the media catalog
+### Requirement: Web fetch format is validated
+
+The `web_fetch` tool SHALL validate the `Format` argument against the supported
+set (absent, `"raw"`, `"text"`). Any other value SHALL reject the call with a
+tool-result error naming the supplied value and the supported set. The tool
+SHALL NOT silently fall back to raw mode for an unsupported format value.
+
+#### Scenario: Unsupported format value rejects
+
+- **GIVEN** a `web_fetch` call with `"Format": "markdown"`
+- **WHEN** arguments are validated
+- **THEN** the call is rejected with an error naming `"markdown"` and the
+ supported values `raw` and `text`
+- **AND** no HTTP request is made
+
+#### Scenario: Supported formats behave unchanged
+
+- **GIVEN** a `web_fetch` call with `"Format": "text"` (or `Format` absent)
+- **WHEN** the fetch executes
+- **THEN** behavior is identical to current behavior
+
+### Requirement: Web fetch response-cap truncation is surfaced
+
+The `web_fetch` result SHALL include a notice stating the content was
+truncated at the cap whenever a fetched response body reaches the
+response-byte cap. The captured byte count alone SHALL NOT be the only signal.
+
+#### Scenario: Body larger than the cap carries a truncation notice
+
+- **GIVEN** a URL whose response body exceeds the 5 MB response cap
+- **WHEN** `web_fetch` returns its summary
+- **THEN** the result includes a notice that content was truncated at 5 MB
+
+#### Scenario: Body under the cap carries no truncation notice
+
+- **GIVEN** a URL whose response body is under the response cap
+- **WHEN** `web_fetch` returns its summary
+- **THEN** no truncation notice is present
+
+### Requirement: Webhook listing honors its filter argument
+
+The `list_webhooks` tool SHALL honor its schema-advertised `Filter` argument:
+`"active"` (the default) SHALL return only enabled webhooks, `"all"` SHALL
+return every webhook, and any other value SHALL reject the call naming the
+supported values. The applied filter SHALL be echoed in the result.
+
+#### Scenario: Active filter excludes disabled webhooks
+
+- **GIVEN** two registered webhooks, one enabled and one disabled
+- **AND** a `list_webhooks` call with `"Filter": "active"` (or `Filter` absent)
+- **WHEN** the tool executes
+- **THEN** only the enabled webhook is listed
+- **AND** the result states the `active` filter was applied
+
+#### Scenario: All filter includes disabled webhooks
+
+- **GIVEN** two registered webhooks, one enabled and one disabled
+- **AND** a `list_webhooks` call with `"Filter": "all"`
+- **WHEN** the tool executes
+- **THEN** both webhooks are listed with their enabled state
+- **AND** the result states the `all` filter was applied
+
+#### Scenario: Unknown filter value rejects
+
+- **GIVEN** a `list_webhooks` call with `"Filter": "enabled"`
+- **WHEN** arguments are validated
+- **THEN** the call is rejected naming the supported values `active` and `all`
+
### Requirement: File read tool
The system SHALL provide a `file_read` first-party tool that authorizes the
diff --git a/openspec/specs/tool-arg-validation/spec.md b/openspec/specs/tool-arg-validation/spec.md
new file mode 100644
index 000000000..974f63bcb
--- /dev/null
+++ b/openspec/specs/tool-arg-validation/spec.md
@@ -0,0 +1,145 @@
+# tool-arg-validation Specification
+
+## Purpose
+
+Defines the validation contract for LLM-supplied tool arguments at the
+dispatch seam. No argument the model expressed intent through is ever silently
+discarded, coerced, or overridden: unknown keys and invalid values reject the
+call with a recoverable, self-describing error before execution, and every
+honored-but-overridden value is surfaced in the tool result. Originated from a
+production incident where a near-miss timeout key (`TimeoutSeconds` instead of
+`_timeout_seconds`) was silently dropped, the shell timeout silently fell back
+to a default, and the agent's false belief fed a stuck loop.
+
+## Requirements
+
+### Requirement: Unknown argument keys reject the call before execution
+
+For native (first-party) tools, the dispatcher SHALL validate every supplied
+argument key against the tool's recognized-key set before execution. A supplied
+key is recognized if and only if it would be consumed downstream:
+
+- a declared tool parameter, matched exactly or by deterministic key
+ normalization (case/punctuation folding, mirroring existing flexible binding);
+- a meta key (`_rationale`, `_timeout_seconds`, `_background`), matched
+ **exactly only**.
+
+A call carrying one or more unrecognized keys SHALL be rejected with a
+tool-result error and the tool SHALL NOT execute. The error SHALL name each
+unrecognized key, state that the tool was not executed, and list the tool's
+valid argument names. MCP tools are exempt (server-side schema validation is
+authoritative).
+
+#### Scenario: Near-miss meta key rejected with suggestion
+
+- **GIVEN** a `shell_execute` call with `"TimeoutSeconds": "1200"`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected without executing the command
+- **AND** the tool result contains `Unrecognized argument 'TimeoutSeconds'`,
+ a `did you mean '_timeout_seconds'` suggestion, and the valid argument names
+
+#### Scenario: Case-variant declared parameter still accepted
+
+- **GIVEN** a `shell_execute` call with `"command": "ls"` (lowercase)
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the key is recognized via deterministic normalization
+- **AND** the tool executes exactly as it does today
+
+#### Scenario: Exact meta key accepted
+
+- **GIVEN** a tool call with `"_timeout_seconds": 300`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the key is recognized and extraction consumes it
+
+#### Scenario: Wholly unknown key rejected without suggestion
+
+- **GIVEN** a `file_read` call with `"Banana": true` and a valid `Path`
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected naming `Banana` with no near-miss suggestion
+- **AND** the valid argument names for `file_read` are listed
+
+#### Scenario: MCP tool exempt from native key validation
+
+- **GIVEN** a tool call targeting an MCP server tool with an extra key
+- **WHEN** the dispatcher processes the call
+- **THEN** native key validation is skipped
+- **AND** the MCP server's own schema validation result is returned observably
+
+### Requirement: Fuzzy matching generates suggestions only — never acceptance
+
+Near-miss matching SHALL be used solely to generate "did you mean" suggestion
+text inside rejection errors (normalization equivalence against meta keys,
+edit distance against recognized names). The system SHALL NOT bind, alias, or
+otherwise act on a guessed key. Ambiguity SHALL always be resolved by the LLM
+re-issuing the call explicitly.
+
+#### Scenario: Near-miss key is never silently bound
+
+- **GIVEN** a tool call with `"timeout_seconds": 300` (missing the `_` prefix)
+- **WHEN** the dispatcher validates argument keys
+- **THEN** the call is rejected with a `did you mean '_timeout_seconds'`
+ suggestion
+- **AND** no timeout override is applied from the near-miss key
+
+### Requirement: Present-but-invalid argument values reject the call
+
+For native tools, the system SHALL reject a call whose argument key is
+recognized but whose value cannot be parsed as the declared type, with a
+tool-result error naming the parameter, the supplied value, and the expected
+type. The tool SHALL NOT execute. An absent optional parameter SHALL continue
+to use its documented default (absence expresses no intent; invalidity does).
+Numeric coercion SHALL NOT silently truncate: a non-integral value supplied
+for an integer parameter is invalid.
+
+#### Scenario: Unparseable integer rejects instead of coercing to zero
+
+- **GIVEN** a `file_read` call with `"Limit": "abc"`
+- **WHEN** arguments are bound
+- **THEN** the call is rejected with an error naming `Limit`, the value
+ `"abc"`, and the expected type integer
+- **AND** the file is not read
+
+#### Scenario: Non-integral number for integer parameter is invalid
+
+- **GIVEN** a tool call supplying `12.7` for an integer parameter
+- **WHEN** arguments are bound
+- **THEN** the call is rejected (no silent truncation to 12)
+
+#### Scenario: Absent optional parameter keeps its default
+
+- **GIVEN** a `file_read` call that omits `Limit`
+- **WHEN** arguments are bound
+- **THEN** the documented default applies and no error is raised
+
+### Requirement: Malformed tool-call arguments JSON rejects before dispatch
+
+The pipeline SHALL produce a tool-result error for a tool call whose arguments
+JSON the provider boundary fails to deserialize, stating the arguments were
+not valid JSON; the tool SHALL NOT be dispatched with null or empty arguments.
+The error SHALL include the parse failure detail so the model can correct its
+emission.
+
+#### Scenario: Truncated arguments JSON surfaces as an error result
+
+- **GIVEN** a streamed tool call whose accumulated arguments JSON is truncated
+ and fails to parse
+- **WHEN** the pipeline processes the call
+- **THEN** a tool-result error for that call id states the arguments were not
+ valid JSON and the tool was not executed
+- **AND** no tool receives a null-argument invocation
+
+### Requirement: Overridden argument values are surfaced to the model
+
+The system SHALL append a model-facing notice to a call's tool result whenever
+it honors the call but applies a value different from the one the LLM
+requested (clamping, flooring, capping), describing the requested value, the
+applied value, and the reason. Notices SHALL be appended after output bounding
+so they cannot be truncated away. Log-only signaling SHALL NOT satisfy this
+requirement: the notice MUST appear in the tool result the model reads.
+
+#### Scenario: Notice survives output bounding
+
+- **GIVEN** a tool call whose result exceeds the inline output budget
+- **AND** an override notice applies to the call
+- **WHEN** the result is bounded and spilled
+- **THEN** the notice is present in the inline result returned to the model
diff --git a/openspec/specs/tool-call-metadata/spec.md b/openspec/specs/tool-call-metadata/spec.md
index 5dd826f61..876a13ec2 100644
--- a/openspec/specs/tool-call-metadata/spec.md
+++ b/openspec/specs/tool-call-metadata/spec.md
@@ -7,10 +7,10 @@ every tool's JSON schema and populated by the LLM as part of normal tool
calling. The metadata captures the model's intent (`_rationale`), a per-call
synchronous timeout hint (`_timeout_seconds`), and an explicit background
execution signal (`_background`). The tool execution pipeline extracts these
-fields before dispatch so tool implementations never receive them, clamps the
-timeout hint to a configurable ceiling, persists the metadata on the tool call
-for journal replay, and enriches audit entries with the rationale and timeout
-hint. This capability defines the signaling and metadata mechanism only;
+fields before dispatch so tool implementations never receive them, honors the
+timeout hint as requested (rejecting only invalid values), persists the
+metadata on the tool call for journal replay, and enriches audit entries with
+the rationale and timeout hint. This capability defines the signaling and metadata mechanism only;
actual background job execution is consumed by a follow-on change.
## Requirements
@@ -88,39 +88,39 @@ call in one sentence — what are you trying to accomplish and why?"
### Requirement: Per-call timeout hint
-The `_timeout_seconds` field SHALL allow the LLM to request a per-call timeout
-override. The value SHALL be clamped to a configurable ceiling
-(`ToolConfig.MaxToolTimeoutSeconds`, default 600). Values below the tool's
-default timeout SHALL be ignored (the default applies). The pipeline SHALL use
-the clamped value when creating the per-call `CancellationTokenSource`.
+The `_timeout_seconds` field SHALL allow the LLM to set a per-call timeout. A
+positive value SHALL be honored exactly — it SHALL NOT be clamped to a ceiling
+nor floored to the tool default; the agent owns this judgement. When no hint is
+provided, the inherited per-call default (`SessionConfig.ToolExecutionTimeout`)
+SHALL apply. The pipeline SHALL use this value when creating the per-call
+`CancellationTokenSource`, and the same value SHALL govern the background-job
+path when `_background` is set. (A present-but-invalid value — non-positive or
+unparseable — is rejected before dispatch; see "Malformed meta values".)
-#### Scenario: Timeout hint applied within ceiling
+#### Scenario: Timeout hint is honored exactly
-- **GIVEN** `MaxToolTimeoutSeconds` is 600
-- **AND** the LLM requests `_timeout_seconds: 300` on a shell_execute call
+- **GIVEN** the LLM requests `_timeout_seconds: 1200` on a shell_execute call
- **WHEN** the pipeline creates the cancellation token
-- **THEN** the timeout is set to 300 seconds
+- **THEN** the timeout is set to 1200 seconds
+- **AND** nothing is appended to the tool result
-#### Scenario: Timeout hint exceeds ceiling
+#### Scenario: A small timeout hint is honored, not floored
-- **GIVEN** `MaxToolTimeoutSeconds` is 600
-- **AND** the LLM requests `_timeout_seconds: 1200`
-- **WHEN** the pipeline creates the cancellation token
-- **THEN** the timeout is clamped to 600 seconds
-
-#### Scenario: Timeout hint below tool default ignored
-
-- **GIVEN** `ShellTimeoutSeconds` is 60 (shell tool default)
- **AND** the LLM requests `_timeout_seconds: 10`
- **WHEN** the pipeline creates the cancellation token
-- **THEN** the timeout remains at 60 seconds (the tool default)
+- **THEN** the timeout is set to 10 seconds (no floor is imposed)
-#### Scenario: No timeout hint uses default
+#### Scenario: No timeout hint uses the inherited default
- **GIVEN** the LLM does not provide `_timeout_seconds`
- **WHEN** the pipeline creates the cancellation token
-- **THEN** the existing default timeout applies (60s for shell, 90s for
- general tool execution)
+- **THEN** the `SessionConfig.ToolExecutionTimeout` default applies
+
+#### Scenario: Background path honors the same hint
+
+- **GIVEN** the LLM sets `_background: true` and `_timeout_seconds: 1800`
+- **WHEN** the call is routed to a background job
+- **THEN** the job's timeout is 1800 seconds (not clamped)
### Requirement: Background execution signal
@@ -151,10 +151,50 @@ execution); this spec defines only the signaling mechanism.
- **GIVEN** background job execution is not yet available
- **AND** the LLM requests `_background: true`
- **WHEN** the pipeline processes the tool call
-- **THEN** the tool executes synchronously with the requested (clamped) timeout
+- **THEN** the tool executes synchronously with the requested timeout
- **AND** a log message indicates background execution was requested but is not
yet available
+### Requirement: Malformed meta values reject the call
+
+The pipeline SHALL reject a tool call carrying a meta key whose value cannot
+be parsed as its declared type (`_timeout_seconds` not a positive integer;
+`_background` not a boolean) with a tool-result error before dispatch, naming
+the meta key, the supplied value, and the expected type. The tool SHALL NOT
+execute with default semantics in place of the expressed intent. Validation
+state SHALL be computed pipeline-side; the persisted `ToolCallMeta` type SHALL
+remain unchanged.
+
+#### Scenario: Unparseable timeout value rejects instead of silently defaulting
+
+- **GIVEN** a tool call with `"_timeout_seconds": "1200ms"`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the call is rejected with an error naming `_timeout_seconds`, the
+ value `"1200ms"`, and the expected type positive integer
+- **AND** the tool does not execute under the default timeout
+
+#### Scenario: Non-boolean background value rejects
+
+- **GIVEN** a tool call with `"_background": "yes"`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the call is rejected with an error naming `_background` and the
+ expected type boolean
+- **AND** the tool does not execute synchronously in place of the request
+
+#### Scenario: Non-integral JSON number for timeout is handled, not thrown
+
+- **GIVEN** a tool call with `"_timeout_seconds": 12.5`
+- **WHEN** the pipeline extracts meta fields
+- **THEN** extraction does not throw an uncaught exception
+- **AND** the call is rejected as present-but-invalid
+
+#### Scenario: Legacy persisted tool call re-drives deterministically
+
+- **GIVEN** a persisted tool call carrying a malformed meta value is re-driven
+ after recovery
+- **WHEN** the pipeline extracts meta fields
+- **THEN** the same rejection error is produced (deterministic on replay)
+
### Requirement: ToolCallMeta persistence
Extracted `ToolCallMeta` SHALL be persisted on `SerializableToolCall` as an
@@ -194,31 +234,4 @@ allow/deny, duration, approval decision).
- **GIVEN** the LLM provides a timeout hint on a tool call
- **WHEN** the audit entry is logged
-- **THEN** the entry includes the `TimeoutHintSeconds` value (pre-clamp, as
- requested by the LLM)
-
-### Requirement: Configuration for timeout ceiling
-
-`ToolConfig` SHALL include `MaxToolTimeoutSeconds` (int, default 600). It SHALL
-be validated in the config schema (`netclaw-config.v1.schema.json`) with
-`minimum: 1`. The schema SHALL include a default value for migration-friendly
-`netclaw doctor --fix` support.
-
-#### Scenario: Config properties parsed
-
-- **GIVEN** the config file includes `tools.MaxToolTimeoutSeconds: 900`
-- **WHEN** the config is loaded
-- **THEN** `ToolConfig.MaxToolTimeoutSeconds` is 900
-
-#### Scenario: Config defaults applied
-
-- **GIVEN** the config file does not include timeout properties
-- **WHEN** the config is loaded
-- **THEN** `ToolConfig.MaxToolTimeoutSeconds` is 600
-
-#### Scenario: Config schema validates new properties
-
-- **GIVEN** `netclaw-config.v1.schema.json` includes the new properties
-- **WHEN** `netclaw doctor` validates a config with these properties
-- **THEN** validation passes
-- **AND** `SchemaFixResolver` can insert defaults for missing properties
+- **THEN** the entry includes the `TimeoutHintSeconds` value as requested by the LLM
diff --git a/samples/Netclaw.Demo.AppHost.IntegrationTests/Netclaw.Demo.AppHost.IntegrationTests.csproj b/samples/Netclaw.Demo.AppHost.IntegrationTests/Netclaw.Demo.AppHost.IntegrationTests.csproj
index b85c5bed3..29bbcc73e 100644
--- a/samples/Netclaw.Demo.AppHost.IntegrationTests/Netclaw.Demo.AppHost.IntegrationTests.csproj
+++ b/samples/Netclaw.Demo.AppHost.IntegrationTests/Netclaw.Demo.AppHost.IntegrationTests.csproj
@@ -13,6 +13,9 @@
+
+
diff --git a/samples/Netclaw.Demo.AppHost/Netclaw.Demo.AppHost.csproj b/samples/Netclaw.Demo.AppHost/Netclaw.Demo.AppHost.csproj
index b8bfca2a1..774919731 100644
--- a/samples/Netclaw.Demo.AppHost/Netclaw.Demo.AppHost.csproj
+++ b/samples/Netclaw.Demo.AppHost/Netclaw.Demo.AppHost.csproj
@@ -14,6 +14,9 @@
+
+
diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs
index d3db5ee01..cda6c5e1d 100644
--- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs
@@ -117,6 +117,52 @@ await SessionToolExecutionPipeline.ExecuteToolsAsync(
Assert.Equal("long running build", received.Rationale);
}
+ [Fact]
+ public async Task ExplicitBackground_HonorsRequestedTimeout()
+ {
+ // The agent's requested timeout is honored on the background path too —
+ // it is not clamped to a ceiling (the agent owns that judgement).
+ var executor = new EchoExecutor();
+ var probe = CreateTestProbe("pipeline-probe-bg-timeout");
+ var jobManagerProbe = CreateTestProbe("job-manager-bg-timeout");
+ var fakeJobManager = Sys.ActorOf(Props.Create(() => new FakeJobManager(jobManagerProbe.Ref)));
+
+ var toolCalls = new List
+ {
+ new("call-bg-timeout", "shell_execute", new Dictionary
+ {
+ ["command"] = "sleep 1200",
+ ["_background"] = true,
+ ["_timeout_seconds"] = 1800,
+ ["_rationale"] = "long job"
+ })
+ };
+
+ await SessionToolExecutionPipeline.ExecuteToolsAsync(
+ executor, toolCalls,
+ new SessionId("test/background-timeout"),
+ source: TestMessageSource(),
+ auditLogger: null,
+ timeProvider: TimeProvider.System,
+ sessionDir: Path.GetTempPath(),
+ maxInlineToolResultChars: 4096,
+ timeout: TimeSpan.FromSeconds(5),
+ self: probe.Ref,
+ emitSubAgentOutput: _ => { },
+ spawnChildActor: static (_, _, _) => Task.FromResult