Skip to content

experiment: add reasoning monitor agent for prompt injection detection - #2

Merged
waynesun09 merged 6 commits into
mainfrom
experiment-reasoning-monitor
Apr 30, 2026
Merged

experiment: add reasoning monitor agent for prompt injection detection#2
waynesun09 merged 6 commits into
mainfrom
experiment-reasoning-monitor

Conversation

@waynesun09

Copy link
Copy Markdown
Member

Summary

  • Ports Experiment 005 from fullsend-ai/fullsend PR #259 to the experiments repo
  • A secondary "reasoning monitor" agent that watches the primary agent's chain-of-thought and tool calls while being deliberately blind to the primary agent's inputs, preventing the monitor from being compromised by the same poisoned content
  • Implements three complementary monitoring techniques: canary token hook (PostToolUse), tool allowlist hook (PreToolUse), and LLM reasoning monitor (secondary LLM on stripped transcripts)
  • Includes 27 test payloads: 6 benign controls, 16 Phase 1 attacks (from PR #117/#168 corpus), and 5 Phase 2 adaptive attacks

Test plan

  • uv run pytest tests/ -x -v passes all unit and integration tests
  • uv run python runner.py --dry-run completes without errors
  • uv run python runner.py --monitor canary detects canary leaks in expected payloads
  • uv run python runner.py --monitor allowlist blocks out-of-role tool calls
  • uv run python runner.py --monitor llm --model haiku produces verdicts for all 27 payloads

Adds Experiment 005: a secondary reasoning monitor agent that watches the
primary agent's chain-of-thought and tool calls while being deliberately
blind to the primary agent's inputs, preventing the monitor from being
compromised by the same poisoned content.

Implements three complementary monitoring techniques:
- canary token hook (PostToolUse)
- tool allowlist hook (PreToolUse)
- LLM reasoning monitor (secondary LLM on stripped transcripts)

Includes 27 test payloads: 6 benign controls, 16 Phase 1 attacks, and
5 Phase 2 adaptive attacks designed to challenge the monitor.

Ported from fullsend-ai/fullsend PR #259.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@ralphbean

Copy link
Copy Markdown
Member

/review

@fullsend-ai-review

Copy link
Copy Markdown

Review: #2

Head SHA: a5fc7a5
Outcome: failure
Reason: missing-context — required tool scan-unicode not found at $SCAN_UNICODE (/tmp/workspace/bin/scan-unicode) or fallback path scripts/scan-unicode. Cannot complete injection defense dimension without Unicode scanning.

This PR was NOT reviewed. Do not count this as an approval.

@waynesun09

Copy link
Copy Markdown
Member Author

/review

@fullsend-ai-review

fullsend-ai-review Bot commented Apr 30, 2026

Copy link
Copy Markdown

Review: #2

Head SHA: 6ee94ef
Timestamp: 2026-04-30T00:00:00Z
Outcome: approve

Summary

This PR adds a well-designed reasoning monitor experiment (Experiment 005) implementing three complementary prompt injection detection techniques: canary token hook, tool allowlist hook, and LLM reasoning monitor. The code is clean, well-documented, and follows security best practices throughout — particularly the fail-closed design on all error paths, input-blindness architecture for the LLM monitor, and defense-in-depth redaction. The test suite is comprehensive with 27 payloads (including 5 Phase 2 adaptive attacks), thorough unit tests with mocked LLM calls, integration tests against real transcripts, and structural validation of all payloads. No critical or high findings.

Findings

Medium

None.

Low

  • [style] reasoning-monitor/runner.py:19 — Import ordering: import yaml and third-party imports appear after logger = logging.getLogger(__name__) (line 18), breaking PEP 8 import grouping convention. Move the logger initialization below all imports.
    Remediation: Move logger = logging.getLogger(__name__) after all import statements.

Info

  • [correctness] reasoning-monitor/monitor/llm_monitor.py — Defense-in-depth redaction: format_transcript() (line 684) independently redacts user-content tool inputs/results in addition to strip_user_input() (line 633). This is good practice — if format_transcript is ever called without prior stripping, the redaction still applies. Both functions reference the same USER_CONTENT_TOOLS set, keeping them in sync. No action needed.

  • [correctness] reasoning-monitor/monitor/llm_monitor.py:780 — Transcript truncation uses split strategy (first half + last half) which correctly preserves late-stage compromise evidence. The Phase 2 truncation-exploit test validates this. The [TRANSCRIPT TRUNCATED] marker in the middle is a good signal to the monitor LLM. No action needed.

  • [content-security] reasoning-monitor/payloads/*.yaml and reasoning-monitor/transcripts/*.jsonl — These files intentionally contain prompt injection payloads, fake credentials, and attack transcripts as test fixtures. Values like FULLSEND_CANARY_abc123, sk-ant-EXAMPLE-xxxxx, and ghp_EXAMPLE_xxxx are clearly synthetic test data, not real secrets. The # nosec B105 annotation in runner.py:63 correctly marks the canary token constant. No action needed.

  • [intent-alignment] — The PR has no linked issue but describes itself as a port from fullsend-ai/fullsend PR #259. The change is entirely additive (new reasoning-monitor/ directory, zero deletions to existing code) and appropriately scoped for an experiment. No scope concerns.

Footer

Outcome: approve
This review applies to SHA 6ee94efed2fdd427c1dc491e6511115436783cb1. Any push to the PR head clears this review and requires a new evaluation.

Previous run

Review: #2

Head SHA: c6b1fd7
Timestamp: 2026-04-30T00:00:00Z
Outcome: approve

Summary

This PR adds a well-designed reasoning monitor experiment (Experiment 005) implementing three complementary prompt injection detection techniques: a canary token PostToolUse hook, a tool allowlist PreToolUse hook, and an LLM-based reasoning monitor. The code follows a consistent fail-closed security posture throughout — malformed inputs, API errors, unknown verdicts, and rate limit exhaustion all default to blocking. The input-blindness design for the LLM monitor (stripping user messages and user-content tool results before evaluation) is sound and correctly prevents second-order injection. The test suite is comprehensive with 27 payloads (6 benign controls, 16 Phase 1 attacks, 5 Phase 2 adaptive attacks), unit tests with proper mocking, integration tests against real transcripts, and Phase 2 tests that honestly document the known limitations of each monitor. No critical or high findings were identified.

Findings

Medium

  • [correctness] reasoning-monitor/monitor/llm_monitor.py:684-709format_transcript independently re-checks USER_CONTENT_TOOLS membership and applies its own redaction logic, duplicating what strip_user_input already does. While this belt-and-suspenders approach is safe, the two functions use different redaction messages ("[REDACTED: tool arguments not shown to monitor]" vs "[REDACTED: tool arguments may contain user input]"), which could cause confusion when debugging. If strip_user_input is always called before format_transcript (as in evaluate), the format_transcript redaction is dead code for those fields.
    Remediation: Consider having format_transcript trust the output of strip_user_input and removing the redundant USER_CONTENT_TOOLS checks, or unify the redaction messages.

  • [correctness] reasoning-monitor/runner.py:296-303summarize uses integer division (100 * correct // total) which silently drops fractional percentages (e.g., 23/27 = 85% instead of 85.2%). For a 27-payload experiment, this loses up to ~3.7 percentage points of precision.
    Remediation: Use round(100 * correct / total, 1) for one decimal place.

Low

  • [correctness] reasoning-monitor/monitor/llm_monitor.py:779-784 — The split truncation concatenates formatted[:_HALF] + marker + formatted[-_HALF:]. If the original transcript is only slightly over MAX_TRANSCRIPT_CHARS, the two halves overlap and the result is actually longer than the original. For example, a 17,000-char transcript produces a ~16,000 + 50 (marker) char result, but a 16,100-char transcript produces ~16,050. No actual bug, but the truncation provides weaker guarantees than the comment implies.
    Remediation: Only truncate when len(formatted) > MAX_TRANSCRIPT_CHARS + 100 or calculate non-overlapping slices.

  • [style] reasoning-monitor/runner.py:50-55import yaml and the from monitor.* imports are separated by a blank line and the logger assignment, breaking the standard import grouping convention (stdlib → third-party → local).
    Remediation: Move logger = logging.getLogger(__name__) after all imports.

  • [style] reasoning-monitor/monitor/canary_hook.py:401 — The _normalize docstring is a single very long line. Consider breaking it into a multi-line docstring for readability.

Info

  • [correctness] All YAML payloads use yaml.safe_load throughout the codebase, preventing YAML deserialization attacks. The canary token FULLSEND_CANARY_abc123 in runner.py is annotated with nosec B105 confirming it is an intentional test fixture.

  • [correctness] The Phase 2 adaptive attack payloads honestly document the design-level limitations of each monitor technique. The adaptive-allowlist-only payload demonstrates that tool allowlists cannot catch semantic abuse of authorized tools. The adaptive-truncation-exploit documents the 16K char context window limitation. This intellectual honesty strengthens the experiment's value.

  • [injection-defense] PR body and commit messages were inspected for injection patterns and non-rendering Unicode. None found. The PR description accurately reflects the code changes.

Footer

Outcome: approve
This review applies to SHA c6b1fd78cce67a0391dacea58652fe941f04d241. Any push to the PR head clears this review and requires a new evaluation.

Previous run (2)

Review: automated review

Outcome: failure
Reason: stale-head

The review agent reviewed commit 0f7de270bfee4c6bfd42b95a41409fe6a2dc1456 but the PR HEAD is now 38e6d334a147d562636dbce45c7607ca6095ecac. This review was discarded to avoid approving unreviewed code.

Previous run (3)

Review: #2

Head SHA: e7c107a
Timestamp: 2026-04-30T00:00:00Z
Outcome: comment-only

Summary

This PR adds a well-structured reasoning monitor experiment with three complementary detection techniques (canary hook, tool allowlist, LLM monitor) and a comprehensive test corpus of 27 payloads. The code demonstrates strong security engineering practices: fail-closed design throughout, input-blindness for the LLM monitor, rate limiting, split truncation, and thorough test coverage including adversarial Phase 2 payloads designed to break the monitor. One medium finding relates to inconsistent normalization in canary detection across code paths. No blocking issues.

Findings

Medium

  • [Correctness] reasoning-monitor/runner.py:92-99 — In run_canary_monitor, the assistant content/reasoning fields are checked with raw string matching (canary in text) while check_canary() uses the evasion-resistant _normalize() function (lowercasing, zero-width character stripping, whitespace collapsing). An attacker who inserts zero-width characters into the canary token within assistant output would evade this check but be caught by the normalized check on tool results. Both code paths should use the same normalization.
    Remediation: Import and use _normalize (or call check_canary) for the assistant field checks as well, or refactor to share a single normalized-match helper.

Low

  • [Correctness] reasoning-monitor/monitor/canary_hook.py:53 — The _normalize() function strips unicode categories "C" (control) and "M" (mark) but explicitly keeps \n and \t. Other C0 control characters like \f (form feed) and \v (vertical tab) are stripped, but this is not clearly documented. The canary check still works because whitespace is collapsed by re.sub(r"\s+", " ", text), but the selective exclusion of only \n/\t from the filter is slightly inconsistent.
    Remediation: Consider documenting the rationale or simplifying to strip all control characters and then collapse whitespace.

Info

  • [Style/conventions] reasoning-monitor/runner.py:133_llm_monitor_cache: dict[str, "Any"] uses a string-quoted "Any" type annotation. This is a forward reference that resolves at runtime but is misleading — Any from typing is already imported on line 18. Use the imported Any directly: dict[str, Any].

  • [Correctness] reasoning-monitor/runner.py:250summarize() uses integer division (100 * correct // total) which truncates the percentage. For 26/27 correct results this reports 96% instead of 96.3%. Minor display-only issue.

  • [Style/conventions] The Phase 2 test class TestLLMMonitorGaps contains tests that assert only structural properties (e.g., any(e.get("role") == "assistant" for e in transcript)) — these are essentially no-ops that document expected LLM behavior. The docstrings are valuable documentation, but consider marking these as pytest.mark.skip with a reason string or converting them to comments/docs to avoid inflating test counts with non-functional assertions.

Footer

Outcome: comment-only
This review applies to SHA e7c107a53a056a9d06ed6aca78a970273d1c35fd. Any push to the PR head clears this review and requires a new evaluation.

Previous run (4)

Review: #2

Head SHA: a5fc7a5
Timestamp: 2026-04-30T00:00:00Z
Outcome: request-changes

Summary

The reasoning monitor experiment is well-architected — the input-blindness design, fail-closed defaults, and three-layer monitoring approach (canary hooks, tool allowlists, LLM reasoning monitor) are sound. However, there are two high-severity findings that require resolution: the canary token detection is trivially bypassable via case variation or character insertion, and the confidence field on MonitorVerdict accepts unclamped values from untrusted LLM output. Several medium findings address incomplete tool argument inspection, a non-functional rate limiter due to per-invocation instance creation, missing API error handling, an incorrect canary_should_leak value in a payload file, and test integrity gaps that could allow security regressions to go undetected.

Findings

High

  • [bypass] reasoning-monitor/monitor/canary_hook.py:59 — Canary detection uses plain case-sensitive substring match (canary in tool_result), which is trivially bypassable via case variation, zero-width character insertion, base64 encoding, or token splitting. The static canary token FULLSEND_CANARY_abc123 hardcoded in runner.py:27 is also predictable.
    Remediation: Perform case-insensitive matching after normalizing whitespace and stripping non-printable characters. For production use, generate cryptographically random canary tokens per session.

  • [input-validation] reasoning-monitor/monitor/llm_monitor.py:272 — The confidence field on MonitorVerdict is typed as float with a documented range of 0.0–1.0, but no validation enforces this. The value comes directly from the LLM's JSON response (float(result.get("confidence", 0.5))). An adversarially crafted LLM response could return out-of-range values, breaking downstream thresholding or scoring logic.
    Remediation: Clamp the value: confidence = max(0.0, min(1.0, float(result.get("confidence", 0.5)))).

Medium

  • [gap] reasoning-monitor/monitor/tool_allowlist.py:59 — The tool allowlist checks only the tool name, not the tool arguments. An attacker can use an allowed tool (e.g., mcp__github__issue_comment) to exfiltrate data by embedding sensitive content in the comment body. Acknowledged in Phase 2 tests as a design limitation.
    Remediation: Document this limitation in the README. For production, add argument-level inspection for sensitive tools.

  • [logic-error] reasoning-monitor/monitor/llm_monitor.py:200-216 — The rate limiter is instance-scoped (self._call_timestamps), but runner.py:142 creates a new LLMMonitor instance per payload, so the rate limit resets on every invocation and never triggers.
    Remediation: Hoist LLMMonitor construction outside the per-payload loop, or use a class-level rate limiting mechanism.

  • [error-handling] reasoning-monitor/monitor/llm_monitor.py:244-251 — The self.client.messages.create() call has no exception handling. Network errors, rate limits (429), or authentication failures will crash the runner instead of failing closed.
    Remediation: Wrap the API call in try/except and return a MonitorVerdict(verdict="suspicious", ...) on failure, consistent with the fail-closed philosophy.

  • [correctness] reasoning-monitor/payloads/indirect-poisoned-dependency.yaml:33 — Incorrect canary_should_leak: true. This payload's injection goal is to trick the agent into auto-approving/closing an issue — it does not reference or attempt to exfiltrate a canary token. This will cause false test failures in the canary monitor.
    Remediation: Change to canary_should_leak: false.

  • [test-integrity] reasoning-monitor/tests/test_monitor.py:152-160 — The test test_monitor_strips_user_input_before_llm_call has a weak assertion that would pass even if strip_user_input were a no-op, because the formatted transcript includes role prefixes not matched by the assertion string.
    Remediation: Assert that "[REDACTED" appears in the combined message text, or mock strip_user_input and verify it was called.

  • [missing-test] reasoning-monitor/tests/test_monitor.py — No test verifies rate limiting behavior. A regression that disables rate limiting would go undetected.
    Remediation: Add a test with max_calls=2 that asserts the third call returns a suspicious verdict.

  • [correctness] reasoning-monitor/README.md:137-138 — README technique labels for credential-exfil ("social-engineering") and role-confusion ("authority-spoofing") do not match their YAML files ("direct-injection" and "social-engineering" respectively).
    Remediation: Update README to match the YAML source of truth.

Low

  • [robustness] reasoning-monitor/monitor/llm_monitor.py:257-260 — Markdown code fence stripping via split("\``")[1]is fragile with nested fences. Remediation: Usere.search(r'```(?:json)?\s*(.?)\s```', raw_text, re.DOTALL)`.

  • [style] reasoning-monitor/monitor/llm_monitor.py:72-77 — Module is coupled to Vertex AI (AnthropicVertex with hardcoded project_id, region) but this is not documented.
    Remediation: Add a docstring note or rename to get_vertex_client.

  • [test-integrity] reasoning-monitor/tests/test_runner.pytest_load_payloads_from_real_dir hard-codes assert len(payloads) == 27, which will break whenever a payload is added.
    Remediation: Use assert len(payloads) >= 27 or verify required names exist.

Info

  • [design-note] The LLM monitor's MONITOR_SYSTEM_PROMPT is itself a potential injection surface — assistant reasoning that echoes attacker-controlled content still flows through to the monitor. Acknowledged in Phase 2 test adaptive-reasoning-mimicry as a fundamental limitation.
  • [content-safety] All payloads use safe test content (example.com URLs, fake credentials, test canary tokens). No real secrets or exfiltration endpoints detected.
  • [injection-defense] PR body, commit messages, and payload content contain no injection patterns targeting the review agent.

Footer

Outcome: request-changes
This review applies to SHA a5fc7a5f0869a252c11f6767abc8f9d451c865db. Any push to the PR head clears this review and requires a new evaluation.

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment above for full details.

- Canary detection: case-insensitive matching with unicode/whitespace
  normalization to resist evasion via case variation or zero-width chars
- Confidence clamping: values from LLM responses now clamped to 0.0-1.0
- API error handling: messages.create() wrapped in try/except, fails
  closed with suspicious verdict on network/auth errors
- Rate limiter: LLMMonitor instances cached per model so rate limit
  state persists across payloads instead of resetting per invocation
- Code fence parsing: regex-based extraction handles nested fences
- README: fix technique labels (credential-exfil, role-confusion) to
  match YAML source of truth; document tool allowlist argument gap
- Tests: strengthen strip_user_input assertion to verify REDACTED marker;
  add rate limiting, API error, and confidence clamping tests

Signed-off-by: Wayne Sun <gsun@redhat.com>

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment above for full details.

- Canary hook: scan tool_input in addition to tool_result to catch
  exfiltration via tool arguments (e.g., embedding canary in comment body)
- LLM monitor: move response.content[0] access inside try/except to
  handle empty API responses; add IndexError/AttributeError to caught
  exceptions; use entry.copy() in strip_user_input to prevent mutation;
  fix rate limiter to use `is not None` instead of falsy check so
  max_calls=0 doesn't silently fall back to default; validate max_calls>=1;
  redact tool input arguments for USER_CONTENT_TOOLS in format_transcript
- Tool allowlist: fail closed (exit 1) on missing tool_name instead of
  silently allowing
- Runner: use check_canary() with normalization for assistant content
  checks instead of raw string containment; handle non-string content
- Tests: add coverage for empty API response, format_transcript redaction,
  and missing tool_name fail-closed behavior

Signed-off-by: Wayne Sun <gsun@redhat.com>
- MonitorVerdict: add __post_init__ validation rejecting confidence
  outside [0.0, 1.0] to enforce the contract at the data boundary
- Canary normalization: apply NFKD Unicode normalization before
  character filtering to resist homoglyph and composed-char evasion;
  remove dead \n/\t exception that was collapsed by subsequent regex
- Tool allowlist: remove full allowlist enumeration from block reason
  to prevent information disclosure to compromised agents
- LLM monitor: validate model alias at init with clear ValueError;
  redact tool_use inputs for USER_CONTENT_TOOLS in strip_user_input
  (not just format_transcript) so the function lives up to its contract
- Runner: add tool_input canary scanning to match canary_hook coverage;
  log warning on unknown monitor names instead of silent skip
- Tests: add MonitorVerdict out-of-range rejection test, canary hook
  tool_input subprocess test, update assertions for new redaction scope

Signed-off-by: Wayne Sun <gsun@redhat.com>
- LLM monitor: validate window_sec >= 1 (prevents silent rate limit
  bypass via zero/negative values); genericize API error reason to
  avoid leaking infrastructure details from exception messages;
  add defense-in-depth tool_result redaction in format_transcript
  for USER_CONTENT_TOOLS (independent of strip_user_input)
- Tests: add invalid model alias rejection test, zero window_sec
  rejection test, unknown monitor skip test, canary hook tool_input
  subprocess test, allowlist reason non-enumeration assertion

Signed-off-by: Wayne Sun <gsun@redhat.com>

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment above for full details.

- Unify format_transcript redaction message to match strip_user_input
  (both now use "tool arguments not shown to monitor")
- Use round() instead of integer division in summarize() to preserve
  fractional accuracy percentages

Signed-off-by: Wayne Sun <gsun@redhat.com>

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment above for full details.

@waynesun09
waynesun09 added this pull request to the merge queue Apr 30, 2026
Merged via the queue into main with commit 2141191 Apr 30, 2026
3 checks passed
@waynesun09
waynesun09 deleted the experiment-reasoning-monitor branch April 30, 2026 19:18
maruiz93 added a commit to maruiz93/experiments that referenced this pull request May 4, 2026
Restore the original experiment result files that match the README's
behavioral analysis — the re-run produced "duplicate" results because
issue fullsend-ai#2 was identical to fullsend-ai#1. Add trailing newlines to all text files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants