← Home

ToolShield LLM Security Analyzer PR map

Higher-level notes for OpenHands/software-agent-sdk PR #2911, which adds ToolShieldLLMSecurityAnalyzer as an opt-in security analyzer for OpenHands SDK. Studied from the PR branch and the CHATS-Lab/ToolShield package, July 2026.

Short version

The PR splits the agent into two roles: an actor LLM proposes a tool call, and a separate guardrail LLM classifies the action before execution. The guardrail sees the current action, recent action history, and optional ToolShield safety experiences.

Nothing becomes the default automatically. Existing agents keep using the current analyzer unless callers opt into this one and wire it to a confirmation policy such as ConfirmRisky.

1new analyzer class
3experience-loading helpers
0default behavior changes

Why it exists

01
Self-policing is biased

The current LLMSecurityAnalyzer trusts the same model that wants to run the tool call to self-label its risk.

02
Multi-turn attacks hide intent

Reconnaissance, staging, and final execution can each look harmless in isolation.

03
Tool-specific memory helps

ToolShield supplies distilled safety experiences from sandbox red-teaming so the guardrail can recognize known bad tool patterns.

Data flow

The analyzer sits between proposed action and execution. It assigns a risk level; the existing confirmation policy still decides whether to pause.

flowchart TD
  User["User task"] --> Actor["Actor LLM proposes tool call"]
  Actor --> Event["ActionEvent: tool, summary, thought, arguments"]
  Event --> Render["Render current action + recent history"]
  Experiences["Optional safety_experiences"] --> Render
  Render --> Guard["Separate guardrail LLM"]
  Guard --> Risk{"SecurityRisk"}
  Risk -->|LOW / MEDIUM| Allow["Default ConfirmRisky allows"]
  Risk -->|HIGH| Pause["Pause for confirmation"]
  Risk -->|UNKNOWN| Unknown["ConfirmRisky(confirm_unknown=True) pauses"]
  Allow --> Tool["Tool executor"]
    

What ToolShield contributes

ToolShield is not embedded into the SDK as a hard dependency. The SDK accepts safety_experiences as a plain string. If the optional extra is installed, helper functions load and format ToolShield's bundled JSON guidelines.

bare guardrail

safety_experiences=""

The default for the new analyzer. It still separates actor and judge, but no tool-specific experience text is injected.

default seed

default_safety_experiences()

Loads terminal and filesystem experiences from the optional package. This is explicit opt-in, not construction-time magic.

explicit list

load_safety_experiences([...])

Callers choose the tool experience names, such as postgres-mcp, playwright-mcp, or notion-mcp.

local discovery

auto_detect_safety_experiences()

Uses ToolShield's MCP scanner to inspect localhost ports and load matching bundled experiences for discovered MCP servers.

Risk classification contract

Guardrail resultSDK resultWhy it matters
RISK: LOWSecurityRisk.LOWNormal safe path; no confirmation under default ConfirmRisky.
RISK: MEDIUMSecurityRisk.MEDIUMAllows callers to configure a stricter threshold without forcing all users to pause.
RISK: HIGHSecurityRisk.HIGHDefault ConfirmRisky pauses before execution.
No parseable label, conflicting labels, or LLM infrastructure errorSecurityRisk.UNKNOWNLets ConfirmRisky(confirm_unknown=True) pause without making UNKNOWN dominate ensemble max-risk fusion.

Issues this catches better than self-assessment

Distributed harmful goal

A sequence such as inspect permissions, stage a helper, then delete protected data may not look like one obvious bad action until the history is evaluated together.

Tool-specific footguns

Filesystem, terminal, database, browser, and Notion tools fail differently. ToolShield experiences give the guardrail a compact memory of those failure modes.

Actor prompt injection into risk output

The implementation wraps summary, thought, and arguments in structural tags, HTML-escapes field content, and strips untrusted spans before parsing labels.

Ambiguous guardrail responses

If the guardrail says both RISK: LOW and RISK: HIGH on standalone lines, the parser returns UNKNOWN instead of letting the last label win.

Implementation map

flowchart LR
  subgraph SDK[software-agent-sdk]
    A[toolshield_llm_analyzer.py]
    H[toolshield_helpers.py]
    I[security/__init__.py exports]
    P[pyproject optional extra]
    T[test_toolshield_llm_analyzer.py]
  end

  subgraph Package[toolshield optional package]
    E[ExperienceStore]
    J[experience JSON files]
    S[mcp_scan.py]
  end

  H -->|lazy import| E
  H -->|loads| J
  H -->|auto-detect| S
  H -->|returns prompt string| A
  A -->|returns| R[SecurityRisk]
  I --> A
  I --> H
  P --> Package
  T --> A
  T --> H
    

Review notes

Package provenance: Earlier review found ToolShield wheels did not reproduce from source and toolshield auto was broken in PyPI 0.1.2. The PR now pins toolshield>=0.1.3,<0.2. The `.pr/` evidence bundle records PyPI metadata, wheel contents, source-to-package hash matches, and a fresh-venv smoke test.

Prompt/output hardening: The branch went through multiple review rounds: parse failures now return UNKNOWN, untrusted fields are tagged, summary is stripped like thought and arguments, closing-tag injection is neutralized with HTML escaping, and conflicting labels become UNKNOWN.

Edges to watch: The helper that auto-detects MCP tools probes localhost and currently depends on ToolShield's scanner behavior. Reviewers should verify the no-extra fallback contract, stdout/logging behavior, and whether a server process should ever run broad local probing implicitly.

What this is not

Useful links

OpenHands Client-Defined Tools →  ·  PR #2911 →  ·  ← Home