Skip to content

Commit f5f7728

Browse files
committed
fix: Pipeline stalls after triage — OpenClaw security envelope blocks tool calls (fixes #13)
OpenClaw wraps webhook payloads in EXTERNAL_UNTRUSTED_CONTENT which tells models not to execute tools from untrusted content. Our web_fetch callback URLs were inside this envelope, so agents correctly refused to invoke them. Two-layer fix: - Add allowUnsafeExternalContent: true to all 6 hook mappings in openclaw.json, openclaw-airgapped.json, and install.sh template (safe: loopback + token auth) - Restructure webhook messages to be data-only — callback URL templates now live in each agent's AGENTS.md system prompt, not the webhook payload
1 parent d5fd422 commit f5f7728

File tree

6 files changed

+88
-53
lines changed

6 files changed

+88
-53
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Pipeline stalls after triage — OpenClaw security envelope blocks tool calls** (fixes #13): OpenClaw wraps webhook payloads in `EXTERNAL_UNTRUSTED_CONTENT` which instructs models not to execute tools mentioned within untrusted content. Our `web_fetch` callback URLs were inside this envelope, so agents correctly refused to call them. Fix: added `allowUnsafeExternalContent: true` to all 6 hook mappings (safe — webhooks are loopback-only and token-authenticated), and restructured webhook messages to be data-only (callback URL templates now live in each agent's AGENTS.md system prompt instead of the webhook payload).
12+
1013
### Added
1114
- **Provider policy notice and OpenRouter recommendation**: README, QUICKSTART, and openclaw.json now warn users that Anthropic and Google have banned subscription OAuth tokens in third-party agent tools. OpenRouter is recommended as the safest single-key option. Direct provider API keys (pay-per-token) still work fine.
1215
- **Stalled-pipeline detector**: Automatically detects cases stuck in transient statuses (open, triaged, correlated, etc.) for longer than a configurable threshold and re-dispatches the webhook to give the agent another attempt. Configurable via `STALLED_PIPELINE_ENABLED`, `STALLED_PIPELINE_THRESHOLD_MINUTES` (default 30), and `STALLED_PIPELINE_CHECK_INTERVAL_MS` (default 300000). New metrics: `autopilot_stalled_pipeline_detected_total`, `autopilot_stalled_pipeline_redispatched_total`.

docs/TROUBLESHOOTING.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,33 @@ If `web_fetch` is only in agent allow lists but missing from the global allow li
266266
journalctl -u openclaw-gateway | grep "unknown entries"
267267
```
268268

269+
### Agent receives webhook but doesn't call web_fetch (EXTERNAL_UNTRUSTED_CONTENT)
270+
271+
OpenClaw wraps all webhook payloads in a security envelope (`EXTERNAL_UNTRUSTED_CONTENT`) that instructs models **not** to execute tools or commands mentioned within the untrusted content. This is a safety feature to prevent prompt injection from external sources.
272+
273+
**Problem:** If your `openclaw.json` hook mappings don't include `"allowUnsafeExternalContent": true`, the model sees the callback URL inside the security envelope and correctly refuses to call `web_fetch`. The agent outputs a text summary but never advances the pipeline.
274+
275+
**Symptoms:**
276+
- Agent sessions show the model producing text analysis but never invoking `web_fetch`
277+
- Cases stay in `open` status despite triage agent running
278+
- Stalled pipeline detector fires repeatedly with no progress
279+
- Session logs show `stopReason: "stop"` (not `"error"`) with token usage > 0
280+
281+
**Fix:** Add `"allowUnsafeExternalContent": true` to each hook mapping in `~/.openclaw/openclaw.json`:
282+
283+
```json
284+
{
285+
"match": { "path": "wazuh-alert" },
286+
"action": "agent",
287+
"agentId": "wazuh-triage",
288+
"messageTemplate": "{{message}}",
289+
"name": "Wazuh Alert Triage",
290+
"allowUnsafeExternalContent": true
291+
}
292+
```
293+
294+
This is safe because webhook payloads come from your own runtime service on loopback (`127.0.0.1`), authenticated by `hooks.token`. Apply to all 6 hook mappings. Version 2.4.4+ of the installer and reference configs include this flag by default.
295+
269296
### Stalled pipeline detector (automatic recovery)
270297

271298
The runtime includes a stalled-pipeline detector that automatically re-dispatches webhooks for cases stuck in transient statuses (`open`, `triaged`, `correlated`, `investigated`, `planned`, `approved`). If a case remains in one of these statuses longer than the threshold, the detector re-sends the appropriate webhook to give the agent another chance.

install/install.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -995,12 +995,12 @@ deploy_agents() {
995995
"path": "/webhook",
996996
"token": "$OPENCLAW_WEBHOOK_TOKEN",
997997
"mappings": [
998-
{"match": {"path": "wazuh-alert"}, "action": "agent", "agentId": "wazuh-triage", "messageTemplate": "{{message}}", "name": "Wazuh Alert Triage"},
999-
{"match": {"path": "case-created"}, "action": "agent", "agentId": "wazuh-correlation", "messageTemplate": "{{message}}", "name": "Wazuh Correlation"},
1000-
{"match": {"path": "investigation-request"}, "action": "agent", "agentId": "wazuh-investigation", "messageTemplate": "{{message}}", "name": "Wazuh Investigation"},
1001-
{"match": {"path": "plan-request"}, "action": "agent", "agentId": "wazuh-response-planner", "messageTemplate": "{{message}}", "name": "Wazuh Response Planning"},
1002-
{"match": {"path": "policy-check"}, "action": "agent", "agentId": "wazuh-policy-guard", "messageTemplate": "{{message}}", "name": "Wazuh Policy Check"},
1003-
{"match": {"path": "execute-action"}, "action": "agent", "agentId": "wazuh-responder", "messageTemplate": "{{message}}", "name": "Wazuh Action Execution"}
998+
{"match": {"path": "wazuh-alert"}, "action": "agent", "agentId": "wazuh-triage", "messageTemplate": "{{message}}", "name": "Wazuh Alert Triage", "allowUnsafeExternalContent": true},
999+
{"match": {"path": "case-created"}, "action": "agent", "agentId": "wazuh-correlation", "messageTemplate": "{{message}}", "name": "Wazuh Correlation", "allowUnsafeExternalContent": true},
1000+
{"match": {"path": "investigation-request"}, "action": "agent", "agentId": "wazuh-investigation", "messageTemplate": "{{message}}", "name": "Wazuh Investigation", "allowUnsafeExternalContent": true},
1001+
{"match": {"path": "plan-request"}, "action": "agent", "agentId": "wazuh-response-planner", "messageTemplate": "{{message}}", "name": "Wazuh Response Planning", "allowUnsafeExternalContent": true},
1002+
{"match": {"path": "policy-check"}, "action": "agent", "agentId": "wazuh-policy-guard", "messageTemplate": "{{message}}", "name": "Wazuh Policy Check", "allowUnsafeExternalContent": true},
1003+
{"match": {"path": "execute-action"}, "action": "agent", "agentId": "wazuh-responder", "messageTemplate": "{{message}}", "name": "Wazuh Action Execution", "allowUnsafeExternalContent": true}
10041004
]
10051005
},
10061006

openclaw/openclaw-airgapped.json

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,48 +232,57 @@
232232
"enabled": true,
233233
"path": "/webhook",
234234
"token": "${OPENCLAW_WEBHOOK_TOKEN}",
235+
// allowUnsafeExternalContent: Webhook payloads come from our own runtime
236+
// on loopback, authenticated by hooks.token. Without this, OpenClaw wraps
237+
// messages in EXTERNAL_UNTRUSTED_CONTENT which blocks tool invocations.
235238
"mappings": [
236239
{
237240
"match": { "path": "wazuh-alert" },
238241
"action": "agent",
239242
"agentId": "wazuh-triage",
240243
"messageTemplate": "{{message}}",
241-
"name": "Wazuh Alert Triage"
244+
"name": "Wazuh Alert Triage",
245+
"allowUnsafeExternalContent": true
242246
},
243247
{
244248
"match": { "path": "case-created" },
245249
"action": "agent",
246250
"agentId": "wazuh-correlation",
247251
"messageTemplate": "{{message}}",
248-
"name": "Wazuh Correlation"
252+
"name": "Wazuh Correlation",
253+
"allowUnsafeExternalContent": true
249254
},
250255
{
251256
"match": { "path": "investigation-request" },
252257
"action": "agent",
253258
"agentId": "wazuh-investigation",
254259
"messageTemplate": "{{message}}",
255-
"name": "Wazuh Investigation"
260+
"name": "Wazuh Investigation",
261+
"allowUnsafeExternalContent": true
256262
},
257263
{
258264
"match": { "path": "plan-request" },
259265
"action": "agent",
260266
"agentId": "wazuh-response-planner",
261267
"messageTemplate": "{{message}}",
262-
"name": "Wazuh Response Planning"
268+
"name": "Wazuh Response Planning",
269+
"allowUnsafeExternalContent": true
263270
},
264271
{
265272
"match": { "path": "policy-check" },
266273
"action": "agent",
267274
"agentId": "wazuh-policy-guard",
268275
"messageTemplate": "{{message}}",
269-
"name": "Wazuh Policy Check"
276+
"name": "Wazuh Policy Check",
277+
"allowUnsafeExternalContent": true
270278
},
271279
{
272280
"match": { "path": "execute-action" },
273281
"action": "agent",
274282
"agentId": "wazuh-responder",
275283
"messageTemplate": "{{message}}",
276-
"name": "Wazuh Action Execution"
284+
"name": "Wazuh Action Execution",
285+
"allowUnsafeExternalContent": true
277286
}
278287
]
279288
},

openclaw/openclaw.json

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,48 +269,60 @@
269269
"enabled": true,
270270
"path": "/webhook",
271271
"token": "${OPENCLAW_WEBHOOK_TOKEN}",
272+
// allowUnsafeExternalContent: Webhook payloads come from our own runtime
273+
// service on loopback (127.0.0.1), authenticated by hooks.token. Without
274+
// this flag, OpenClaw wraps the message in an EXTERNAL_UNTRUSTED_CONTENT
275+
// security envelope that instructs the model NOT to execute tools mentioned
276+
// in the message — which blocks agents from calling web_fetch to advance
277+
// the pipeline. Safe here because the webhook source is trusted internal.
272278
"mappings": [
273279
{
274280
"match": { "path": "wazuh-alert" },
275281
"action": "agent",
276282
"agentId": "wazuh-triage",
277283
"messageTemplate": "{{message}}",
278-
"name": "Wazuh Alert Triage"
284+
"name": "Wazuh Alert Triage",
285+
"allowUnsafeExternalContent": true
279286
},
280287
{
281288
"match": { "path": "case-created" },
282289
"action": "agent",
283290
"agentId": "wazuh-correlation",
284291
"messageTemplate": "{{message}}",
285-
"name": "Wazuh Correlation"
292+
"name": "Wazuh Correlation",
293+
"allowUnsafeExternalContent": true
286294
},
287295
{
288296
"match": { "path": "investigation-request" },
289297
"action": "agent",
290298
"agentId": "wazuh-investigation",
291299
"messageTemplate": "{{message}}",
292-
"name": "Wazuh Investigation"
300+
"name": "Wazuh Investigation",
301+
"allowUnsafeExternalContent": true
293302
},
294303
{
295304
"match": { "path": "plan-request" },
296305
"action": "agent",
297306
"agentId": "wazuh-response-planner",
298307
"messageTemplate": "{{message}}",
299-
"name": "Wazuh Response Planning"
308+
"name": "Wazuh Response Planning",
309+
"allowUnsafeExternalContent": true
300310
},
301311
{
302312
"match": { "path": "policy-check" },
303313
"action": "agent",
304314
"agentId": "wazuh-policy-guard",
305315
"messageTemplate": "{{message}}",
306-
"name": "Wazuh Policy Check"
316+
"name": "Wazuh Policy Check",
317+
"allowUnsafeExternalContent": true
307318
},
308319
{
309320
"match": { "path": "execute-action" },
310321
"action": "agent",
311322
"agentId": "wazuh-responder",
312323
"messageTemplate": "{{message}}",
313-
"name": "Wazuh Action Execution"
324+
"name": "Wazuh Action Execution",
325+
"allowUnsafeExternalContent": true
314326
}
315327
]
316328
},

runtime/autopilot-service/index.js

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,12 +1044,16 @@ async function updateCase(caseId, updates) {
10441044
};
10451045
const webhookPath = statusWebhooks[updates.status];
10461046
if (webhookPath) {
1047+
// NOTE: Callback URLs are in each agent's AGENTS.md (system prompt), not here.
1048+
// OpenClaw wraps webhook content in EXTERNAL_UNTRUSTED_CONTENT which blocks
1049+
// tool invocations from the message body. Agents read case_id from the data
1050+
// below and use the URL templates from their system prompt.
10471051
const statusMessages = {
1048-
triaged: `Correlate case ${caseId} (${evidencePack.severity} severity). Search for related alerts, identify attack patterns, then use web_fetch to call: http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseId}&status=correlated`,
1049-
correlated: `Investigate case ${caseId} (${evidencePack.severity} severity). Perform deep analysis using MCP tools: check agent health, search security events, analyze threat indicators. Then use web_fetch to call: http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseId}&status=investigated`,
1050-
investigated: `Plan response for case ${caseId} (${evidencePack.severity} severity). Review investigation findings and create a response plan. Then use web_fetch to submit the plan: http://localhost:${config.metricsPort}/api/agent-action/create-plan?case_id=${caseId}&title={url_encoded_title}&risk_level={risk_level}&actions={url_encoded_actions_json}`,
1051-
planned: `Evaluate proposed plan for case ${caseId} (${evidencePack.severity} severity). Check all policy rules, risk levels, and approval requirements. Then use web_fetch to submit your decision: http://localhost:${config.metricsPort}/api/agent-action/approve-plan?plan_id={plan_id}&approver_id=policy-guard&decision={allow|deny|escalate}&reason={url_encoded_reason}`,
1052-
approved: `Execute approved plan for case ${caseId} (${evidencePack.severity} severity). Check responder status, then execute the plan. Use web_fetch to call: http://localhost:${config.metricsPort}/api/agent-action/execute-plan?plan_id={plan_id}&executor_id=responder-agent`,
1052+
triaged: `New correlation task. Case ID: ${caseId}. Severity: ${evidencePack.severity}. Search for related alerts, identify attack patterns, and advance the pipeline per your AGENTS.md instructions.`,
1053+
correlated: `New investigation task. Case ID: ${caseId}. Severity: ${evidencePack.severity}. Perform deep analysis using MCP tools, then advance the pipeline per your AGENTS.md instructions.`,
1054+
investigated: `New response planning task. Case ID: ${caseId}. Severity: ${evidencePack.severity}. Review investigation findings and create a response plan per your AGENTS.md instructions.`,
1055+
planned: `New policy evaluation task. Case ID: ${caseId}. Severity: ${evidencePack.severity}. Check all policy rules, risk levels, and approval requirements per your AGENTS.md instructions.`,
1056+
approved: `New execution task. Case ID: ${caseId}. Severity: ${evidencePack.severity}. Execute the approved plan per your AGENTS.md instructions.`,
10531057
};
10541058
dispatchToGateway(webhookPath, {
10551059
message: statusMessages[updates.status] || `Process case ${caseId} — status changed to ${updates.status}.`,
@@ -3354,8 +3358,14 @@ function createServer() {
33543358
log("info", "triage", "Created new case from alert", { case_id: caseId, alert_id: alertId, severity });
33553359

33563360
// Dispatch to triage agent via OpenClaw gateway
3361+
// NOTE: Callback URLs are NOT included in the webhook message because
3362+
// OpenClaw wraps webhook content in an EXTERNAL_UNTRUSTED_CONTENT security
3363+
// envelope that instructs models not to execute tools from untrusted content.
3364+
// Instead, each agent's AGENTS.md (loaded as system prompt) contains the
3365+
// callback URL templates. The agent reads case_id from this data and
3366+
// substitutes it into the URL pattern from its system prompt.
33573367
dispatchToGateway("/webhook/wazuh-alert", {
3358-
message: `Triage new ${severity}-severity alert: ${caseData.title}. Case ${caseId} with ${entities.length} entities extracted. Analyze the alert, assess threat level, then use web_fetch to call: http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseId}&status=triaged`,
3368+
message: `New triage task. Case ID: ${caseId}. Severity: ${severity}. Title: ${caseData.title}. Entities: ${entities.length} extracted. Follow your AGENTS.md instructions to triage this alert and advance the pipeline.`,
33593369
case_id: caseId,
33603370
severity,
33613371
title: caseData.title,
@@ -4405,35 +4415,9 @@ async function checkStalledPipeline() {
44054415

44064416
try {
44074417
const evidencePack = await getCase(caseSummary.case_id);
4408-
const statusMessages = {
4409-
open: `[RETRY] Triage alert for case ${caseSummary.case_id}`,
4410-
triaged: `[RETRY] Correlate case ${caseSummary.case_id}`,
4411-
correlated: `[RETRY] Investigate case ${caseSummary.case_id}`,
4412-
investigated: `[RETRY] Plan response for case ${caseSummary.case_id}`,
4413-
planned: `[RETRY] Evaluate plan for case ${caseSummary.case_id}`,
4414-
approved: `[RETRY] Execute plan for case ${caseSummary.case_id}`,
4415-
};
4416-
4417-
// Build callback URLs — for statuses that need a plan_id, look it up from the evidence pack
4418-
const latestPlan = (evidencePack.plans || []).slice(-1)[0];
4419-
const planId = latestPlan?.plan_id || "";
4420-
const callbackUrls = {
4421-
open: `http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseSummary.case_id}&status=triaged`,
4422-
triaged: `http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseSummary.case_id}&status=correlated`,
4423-
correlated: `http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseSummary.case_id}&status=investigated`,
4424-
// For investigated: don't include actions — let the agent build its own plan
4425-
investigated: `http://localhost:${config.metricsPort}/api/agent-action/update-case?case_id=${caseSummary.case_id}&status=investigated`,
4426-
};
4427-
// Only include plan-dependent URLs if we actually have a plan_id
4428-
if (planId) {
4429-
callbackUrls.planned = `http://localhost:${config.metricsPort}/api/agent-action/approve-plan?plan_id=${encodeURIComponent(planId)}&approver_id=policy-guard&decision=allow`;
4430-
callbackUrls.approved = `http://localhost:${config.metricsPort}/api/agent-action/execute-plan?plan_id=${encodeURIComponent(planId)}&executor_id=responder`;
4431-
}
4432-
4433-
let msg = `${statusMessages[caseSummary.status]} (${evidencePack.severity || caseSummary.severity} severity, stalled ${ageMinutes}m).`;
4434-
if (callbackUrls[caseSummary.status]) {
4435-
msg += ` Use web_fetch to call: ${callbackUrls[caseSummary.status]}`;
4436-
}
4418+
// NOTE: Callback URLs are in each agent's AGENTS.md (system prompt), not
4419+
// in the webhook message. See comment in updateCase() for rationale.
4420+
const msg = `[RETRY] Case ID: ${caseSummary.case_id}. Severity: ${evidencePack.severity || caseSummary.severity}. Status: ${caseSummary.status}. Stalled for ${ageMinutes}m. Follow your AGENTS.md instructions to process this case and advance the pipeline.`;
44374421

44384422
dispatchToGateway(webhookPath, {
44394423
message: msg,

0 commit comments

Comments
 (0)