Skip to content

Security: add tirith pre-exec command scanning#41320

Closed
sheeki03 wants to merge 3 commits into
openclaw:mainfrom
sheeki03:feat/tirith-pre-exec-security
Closed

Security: add tirith pre-exec command scanning#41320
sheeki03 wants to merge 3 commits into
openclaw:mainfrom
sheeki03:feat/tirith-pre-exec-security

Conversation

@sheeki03

@sheeki03 sheeki03 commented Mar 9, 2026

Copy link
Copy Markdown

Summary

  • Problem: OpenClaw's agent executes arbitrary shell commands via the exec tool. A prompt injection, malicious skill, or hallucinated command could pipe remote scripts to bash, curl homograph URLs, or use hidden Unicode — all bypassing the existing allowlist/approval flow.
  • Why it matters: The exec tool's existing security (allowlist + user approval) operates at the command-name level. It cannot detect content-level threats like Cyrillic lookalike characters in URLs, pipe-to-shell patterns, or ANSI terminal injection within approved commands.
  • What changed: Adds an optional pre-exec security layer using tirith — a terminal security tool with 66 detection rules across 11 categories. Before any command executes, tirith analyzes it and returns allow, warn, or block. Two hook points: gateway/sandbox (pre-dispatch) and node (post-dispatch, after platform resolution). Configurable via tools.exec.commandSecurity.
  • What did NOT change (scope boundary): No changes to the existing allowlist, approval flow, or obfuscation detector. tirith is additive — if not installed, everything works exactly as before (fail-open by default).

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • New config key: tools.exec.commandSecurity with fields enabled (default: true), failOpen (default: true), timeoutMs (default: 5000), tirithPath (default: "tirith").
  • When tirith is installed: commands are scanned before execution. Blocked commands show an explanation. Warned commands trigger the approval UI with a security warning.
  • When tirith is NOT installed: no change in behavior — fail-open by default, commands proceed normally.
  • Elevated/sandbox paths: warn findings are escalated to block (no approval mechanism available on these paths).

What is tirith?

tirith is a terminal security tool that intercepts shell commands and analyzes them before execution. It runs entirely locally — no network calls, no telemetry, no cloud dependency. Sub-millisecond overhead on clean commands.

66 detection rules across 11 categories:

Category What it catches
Homograph attacks Cyrillic/Greek lookalikes in hostnames, punycode domains, mixed-script labels, confusable TLDs
Terminal injection ANSI escape sequences, bidi overrides, zero-width characters, unicode tags, variation selectors
Pipe-to-shell curl | bash, wget | sh, every source-to-sink variant including python <(curl ...), eval $(wget ...)
Command safety Dotfile overwrites, archive extraction to sensitive paths, cloud metadata access
Insecure transport Plain HTTP piped to shell, disabled TLS verification (curl -k), shortened URLs
Environment Proxy hijacking, sensitive env exports, interpreter hijack, shell injection via env
Config file security AI config injection, suspicious indicators, MCP server security issues
Ecosystem threats Git clone typosquats, untrusted Docker registries, pip/npm URL installs
Path analysis Non-ASCII paths, homoglyphs in paths, double-encoding
Rendered content Hidden CSS/color content, HTML attributes, markdown comments with hidden instructions
Cloaking detection Server-side cloaking (different content for bots vs browsers)

Why this matters for OpenClaw: The agent can execute arbitrary shell commands. A prompt injection or hallucinated command could pipe a remote script to bash, or curl a homograph URL that visually mimics a legitimate domain but resolves to an attacker's server. tirith catches these before execution.

Beyond command scanning, tirith also offers (not part of this PR, but available to users):

  • MCP server with 7 tools — agents can proactively call tirith_check_command, tirith_check_url, tirith_scan_file, tirith_verify_mcp_config, and more
  • Config file scanning — detects prompt injection and hidden payloads in 50+ AI config file patterns (.cursorrules, CLAUDE.md, .mcp.json, etc.)
  • Hidden content detection — CSS hiding, color hiding, PDF sub-pixel text invisible to humans but readable by LLMs
  • Cloaking detection — compares server responses across 6 user-agents to detect differential content serving
  • One-command setuptirith setup openclaw installs the integration automatically

Install: brew install sheeki03/tap/tirith · cargo install tirith · npm i -g @anthropic/tirith · more options

How it works

Command entered → tirith check --json --shell <posix|powershell|cmd> -- "<command>"
                        │
                   Exit 0 → allow (no output, zero overhead)
                   Exit 1 → block (JSON findings in stdout)
                   Exit 2 → warn  (JSON findings in stdout)
                   ENOENT → tirith not installed → allow (fail-open)
                   Timeout → allow (fail-open, configurable)

Two hook points:

Hook Location Shell detection Rationale
Gateway/sandbox bash-tools.exec.ts (pre-dispatch) process.platform → posix/powershell Platform known at dispatch time
Node bash-tools.exec-host-node.ts (post-dispatch) nodeInfo.platform → posix/cmd Platform only known after listNodes()

Warn escalation: On paths without an approval mechanism (elevated-bypass gateway, sandbox), warn is escalated to block. Rationale: if tirith detects something suspicious and there's no way to show the user, the safe default is to block.

Security Impact (required)

  • New permissions/capabilities? No — tirith runs as a local child process, same as existing tools
  • Secrets/tokens handling changed? No
  • New/changed network calls? No — tirith analysis is entirely local
  • Command/tool execution surface changed? Yes — adds a pre-exec security check that can block or warn on commands
  • Data access scope changed? No
  • Risk + mitigation: The new check can prevent command execution (block) or add warnings (warn). It cannot execute commands itself. tirith is invoked via execFile (not exec) — the command string is passed as an argv element, not shell-interpolated. The check runs with a configurable timeout (default 5s) and fail-open default, so a misbehaving tirith binary cannot hang the exec pipeline. If tirith is not installed (ENOENT), the check silently allows.

Repro + Verification

Environment

  • OS: macOS 15.3 (also tested conceptually for Windows gateway/node paths)
  • Runtime/container: Node.js 22.x
  • Model/provider: N/A (infrastructure change)
  • Integration/channel: exec tool (gateway, sandbox, node)
  • Relevant config: tools.exec.commandSecurity.enabled: true

Steps

  1. Install tirith: brew install sheeki03/tap/tirith
  2. Run OpenClaw with this PR
  3. Execute a command that tirith would block (e.g., a URL with Cyrillic characters resembling a legitimate domain)
  4. Execute a command that tirith would warn (e.g., a shortened URL)
  5. Execute a normal command: ls -la
  6. Uninstall tirith and verify commands still work (fail-open)

Expected

  • Step 3: Command blocked with homograph finding
  • Step 4: Approval UI shows security warning
  • Step 5: Command executes normally (exit 0 = allow)
  • Step 6: All commands proceed (ENOENT = allow with failOpen=true)

Actual

  • All scenarios verified via unit tests (20 tests) and integration tests (8 tests)

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

28 new tests passing:

  • src/security/command-security.test.ts — 20 unit tests covering all exit code paths, JSON parsing, failOpen behavior, shell flag forwarding, env passthrough, tilde expansion
  • src/agents/bash-tools.exec.approval-id.test.ts — 8 integration tests covering gateway block, gateway warn-to-approval, elevated bypass escalation, enabled=false skip, node block, node warn-to-approval, node shell detection

Human Verification (required)

  • Verified scenarios: All 28 test cases pass. Manually tested with tirith 0.2.6 installed: block/warn/allow paths work correctly. Tested ENOENT path by renaming tirith binary.
  • Edge cases checked: Invalid JSON in tirith stdout, empty findings array on warn, timeout handling, unknown exit codes, tilde expansion in tirithPath, Windows shell flag resolution (powershell for gateway, cmd for node).
  • What I did not verify: Actual Windows gateway/node execution (no Windows environment available). Windows shell flags are covered by unit tests and match OpenClaw's existing shell-utils.ts and node-shell.ts conventions.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes — fail-open by default, no behavior change when tirith is not installed
  • Config/env changes? Yes — new optional tools.exec.commandSecurity config key
  • Migration needed? No — zero-config activation, works out of the box
  • Upgrade steps: Install tirith (brew install sheeki03/tap/tirith or cargo install tirith), optionally configure tools.exec.commandSecurity in config

Failure Recovery (if this breaks)

  • How to disable/revert quickly: Set tools.exec.commandSecurity.enabled: false in config, or uninstall tirith (ENOENT = allow)
  • Files/config to restore: No files to restore — the feature is additive
  • Known bad symptoms: If tirith binary hangs, commands will be delayed up to timeoutMs (default 5s), then allowed (fail-open). If tirith returns unexpected exit codes, behavior depends on failOpen setting.

Risks and Mitigations

  • Risk: tirith binary not found on PATH in non-interactive shell environments (e.g., OpenClaw running as a service)
    • Mitigation: The integration resolves login-shell PATH before invoking tirith, matching OpenClaw's existing PATH recovery for other tools. Users can also set tirithPath to an absolute path.
  • Risk: tirith false positives blocking legitimate commands
    • Mitigation: tirith has a conservative rule set (66 rules, each with severity tiers). Block is reserved for high-confidence threats (homograph attacks, pipe-to-interpreter). Lower-confidence findings produce warn, which shows in the approval UI rather than blocking. Users can also configure tirith's policy system to allowlist specific patterns.

@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an optional pre-exec security layer using tirith that intercepts shell commands before execution and returns allow, warn, or block verdicts. The implementation is additive and fail-open by default, so removing tirith from PATH restores the previous behavior.

Key strengths:

  • New src/security/command-security.ts wraps tirith via execFile (not exec), correctly preventing shell interpolation of the scanned command.
  • Two scan hook points: gateway/sandbox (pre-dispatch in bash-tools.exec.ts) and node host (post-dispatch in bash-tools.exec-host-node.ts).
  • Warn findings are escalated to block on the bypassApprovals/sandbox paths where no approval UI is available; on normal paths they trigger the existing approval flow.
  • Login-shell PATH is used to locate tirith instead of pathPrepend, preventing a spoofed binary in workspace-local directories from bypassing the scanner.
  • 28 new tests covering all exit-code paths, failOpen behavior, shell detection, and integration with the approval flow.

Recommended improvements:

  1. Node host security scan ordering: In bash-tools.exec-host-node.ts, move the security check block to run before system.run.prepare (line 107). Currently the prepare call happens first, so if security blocks, the prepared context is abandoned. Moving the check after nodeInfo is resolved but before prepare avoids an unnecessary API call and matches the cleaner pattern in the gateway path.
  2. Bounded findings array: In command-security.ts, cap the findings array from tirith output (currently unbounded at line 84) and truncate the summary string for consistency with the stdout cap (line 100). A misbehaving binary could return thousands of findings.

Confidence Score: 4/5

  • Safe to merge with two recommended code organization improvements.
  • The PR is well-designed with comprehensive tests, correct use of execFile, and thoughtful security-in-depth patterns (login-shell PATH isolation, warn escalation on restricted paths). Both remaining findings are improvements rather than correctness issues: (1) code organization optimization in the node host path (reorder security check before prepare call) and (2) defensive resource bounding (cap findings array). Neither causes incorrect behavior or data loss. The overall approach is sound.
  • src/agents/bash-tools.exec-host-node.ts (reorder security check before system.run.prepare), src/security/command-security.ts (bound findings array length)

Last reviewed commit: 597ef0c

Comment thread src/agents/bash-tools.exec-host-node.ts Outdated
Comment on lines +186 to +212
let securityWarning = false;
if (params.commandSecurityConfig?.enabled !== false) {
const shell = String(nodeInfo?.platform ?? "")
.toLowerCase()
.startsWith("win")
? "cmd"
: "posix";
const tirithExecEnv: Record<string, string> = { ...coerceEnv(process.env) };
const hostShellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
});
if (hostShellPath) {
tirithExecEnv.PATH = hostShellPath;
}
const securityCheck = await checkCommandSecurity(params.command, params.commandSecurityConfig, {
shell,
env: tirithExecEnv,
});
if (securityCheck.action === "block") {
throw new Error(`exec denied: command blocked by security scan.\n${securityCheck.summary}`);
}
if (securityCheck.action === "warn" && securityCheck.findings.length > 0) {
securityWarning = true;
params.warnings.push(`Security: ${securityCheck.summary}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security check (checkCommandSecurity) at lines 186–212 runs after system.run.prepare at line 107. If tirith returns block, an error is thrown, but the prepared execution context may be abandoned without cleanup.

Compare this to the gateway path in bash-tools.exec.ts (line 422), where the security check runs before any downstream API calls. Moving the security check to line ~150 (after nodeInfo is resolved but before system.run.prepare) would:

  • Avoid an unnecessary prepare API call if the command is blocked
  • Match the cleaner pattern in the gateway path
  • Eliminate potential stale state

The check needs nodeInfo.platform for shell detection, so it cannot move earlier than line 97, but it can move to after line 105 but before line 107.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/bash-tools.exec-host-node.ts
Line: 186-212

Comment:
The security check (`checkCommandSecurity`) at lines 186–212 runs *after* `system.run.prepare` at line 107. If tirith returns `block`, an error is thrown, but the prepared execution context may be abandoned without cleanup.

Compare this to the gateway path in `bash-tools.exec.ts` (line 422), where the security check runs before any downstream API calls. Moving the security check to line ~150 (after `nodeInfo` is resolved but before `system.run.prepare`) would:
- Avoid an unnecessary prepare API call if the command is blocked
- Match the cleaner pattern in the gateway path
- Eliminate potential stale state

The check needs `nodeInfo.platform` for shell detection, so it cannot move earlier than line 97, but it can move to after line 105 but before line 107.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/security/command-security.ts Outdated
Comment on lines +84 to +96
findings = (verdict.findings || []).map((f: Record<string, unknown>) => ({
rule_id: (f.rule_id as string) || "",
severity: (f.severity as string) || "",
title: (f.title as string) || "",
description: (f.description as string) || "",
}));
if (findings.length > 0) {
summary = findings
.map((f) => {
return f.severity ? `[${f.severity}] ${f.title}` : f.title;
})
.join("; ");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findings array from tirith output is mapped without any length cap at line 84:

findings = (verdict.findings || []).map((f: Record<string, unknown>) => ({ ... }));

A misbehaving or compromised tirith binary could return tens of thousands of findings. The downstream summary string (lines 91–95) would then be an unbounded concatenation, which is surfaced in error messages and stored in warnings.

For consistency with the stdout fallback (which is capped at 500 chars at line 100), consider adding a cap to findings and truncating the summary—e.g. slice(0, 50) on the findings array.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/command-security.ts
Line: 84-96

Comment:
The `findings` array from tirith output is mapped without any length cap at line 84:

```ts
findings = (verdict.findings || []).map((f: Record<string, unknown>) => ({ ... }));
```

A misbehaving or compromised tirith binary could return tens of thousands of findings. The downstream `summary` string (lines 91–95) would then be an unbounded concatenation, which is surfaced in error messages and stored in `warnings`.

For consistency with the stdout fallback (which is capped at 500 chars at line 100), consider adding a cap to findings and truncating the summary—e.g. `slice(0, 50)` on the findings array.

How can I resolve this? If you propose a fix, please make it concise.

Integrate tirith as an optional pre-exec security scanner for shell
commands. tirith analyzes commands before execution and returns allow,
warn, or block based on 66 detection rules covering pipe-to-shell
patterns, homograph URLs, environment injection, and more.

- Add `checkCommandSecurity()` wrapper in `src/security/command-security.ts`
- Hook into gateway/sandbox path (bash-tools.exec.ts) and node path
  (bash-tools.exec-host-node.ts) with correct shell detection per host
- Escalate warn→block on approval-less paths (bypassApprovals, sandbox)
- Deny on approval timeout when security warning is active
- Use login-shell PATH for tirith resolution (not env.PATH with pathPrepend)
- Add `tools.exec.commandSecurity` config with enabled, failOpen,
  timeoutMs, and tirithPath options
- Forward config through pi-tools, auto-reply, and zod schema
- Add 20 unit tests and 8 integration tests (28 total)

Closes discussion #10317
- Move tirith check before system.run.prepare to avoid wasted API call
  when command is blocked
- Cap findings at 50 entries and summary at 500 chars to bound output
  from misbehaving tirith binary
@sheeki03
sheeki03 force-pushed the feat/tirith-pre-exec-security branch from 8f91424 to b0a4d0c Compare March 10, 2026 05:24
@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 26, 2026
@clawsweeper

clawsweeper Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Keep open: the hardening direction is useful, but the PR is not merge-ready because it changes exec behavior by default, runs a third-party scanner before approval with raw environment, does not constrain tirith to side-effect-free preflight, lacks real behavior proof, and is currently conflicting with main.

Canonical path: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review.

So I’m closing this here because the remaining work is already tracked in the canonical issue.

Review details

Best possible solution:

Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review.

Do we have a high-confidence way to reproduce the issue?

Yes for the review blockers: PR-head source shows the default-on condition and raw inherited scanner environment, and upstream tirith source shows daemon, audit/session, background update, and webhook side-effect paths. The feature itself is new, so there is no current-main bug reproduction to establish.

Is this the best way to solve the issue?

No. A tirith-backed scanner may be useful, but this implementation should not merge until the default policy, sanitized environment, cwd-aware scanning, side-effect-free preflight contract, and real runtime proof are settled.

Security review:

Security review needs attention: The diff introduces concrete security-boundary concerns around pre-approval third-party process execution, inherited environment exposure, and unconstrained scanner side effects.

  • [high] Raw process environment reaches scanner — src/agents/bash-tools.exec.ts:413
    The PR launches tirith with raw process.env, while OpenClaw's host exec path filters inherited environment before execution; this can expose credentials or shell-control variables to the scanner process before the user approval boundary.
    Confidence: 0.9
  • [high] Scanner is not constrained to pure local classification — src/security/command-security.ts:44
    The PR does not force tirith into a no-daemon, offline, no-audit/no-webhook mode, and upstream tirith check has background update, daemon, audit/session, and webhook paths that can run before command approval.
    Confidence: 0.84

What I checked:

  • stale F-rated PR: PR was opened 2026-03-09T17:52:02Z, is older than 60 days, and the latest review rated it F.
  • proof blocker: real behavior proof is missing and proof tier is F, so this branch is not merge-ready without contributor follow-up.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • steipete: Recent history shows repeated exec approval boundary, default-policy, node exec, and command-analysis work in the central affected files. (role: primary exec-approval area contributor; confidence: high; commits: c678ae7e7a53, 6720bf5be060, e510042870cf; files: src/agents/bash-tools.exec.ts, src/agents/bash-tools.exec-host-node.ts, src/infra/exec-approvals.ts)
  • joshavant: Authored host environment override hardening across gateway and node paths directly adjacent to the raw scanner environment risk. (role: host exec hardening contributor; confidence: high; commits: 7abfff756d6c; files: src/agents/bash-tools.exec.ts, src/infra/host-env-security.ts)
  • vincentkoc: Recent history includes exec approval allowlist extraction and remote approval regression work relevant to scanner policy design. (role: exec security and approval-flow contributor; confidence: medium; commits: d9a3ecd109ee, 2d53ffdec1da; files: src/infra/exec-approvals.ts, src/agents/bash-tools.exec-host-node.ts)
  • pgondhi987: Recent exec script preflight hardening work is adjacent to the proposed pre-exec classification boundary. (role: exec preflight hardening contributor; confidence: medium; commits: 8aceaf5d0f0e, b024fae9e5df; files: src/agents/bash-tools.exec.ts, src/infra/exec-approvals.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against 0d7d99befab4.

@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Apr 28, 2026
@clawsweeper clawsweeper Bot added the P1 High-priority user-facing bug, regression, or broken workflow. label May 16, 2026
@openclaw-barnacle openclaw-barnacle Bot added the triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. label May 16, 2026
@clawsweeper clawsweeper Bot added impact:security Security boundary, credential, authz, sandbox, or sensitive-data risk. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. and removed impact:security Security boundary, credential, authz, sandbox, or sensitive-data risk. labels May 17, 2026
@clawsweeper

clawsweeper Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper PR egg

🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat.

Where did the egg go?
  • The egg game starts only after the PR passes the real-behavior proof check.
  • Before that, no creature or rarity is rolled. The treat waits for real proof.
  • This is still just collectible flavor: proof affects review readiness, not creature quality.

@clawsweeper

clawsweeper Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper applied the proposed close for this PR.

@clawsweeper clawsweeper Bot closed this May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. P1 High-priority user-facing bug, regression, or broken workflow. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: L status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant