Skip to content

fix(security): URL/unicode encoding coverage, unify action classification taxonomy - #157

Merged
ryaker merged 3 commits into
mainfrom
fix/security-encoding-and-taxonomy
Mar 21, 2026
Merged

fix(security): URL/unicode encoding coverage, unify action classification taxonomy#157
ryaker merged 3 commits into
mainfrom
fix/security-encoding-and-taxonomy

Conversation

@ryaker

@ryaker ryaker commented Mar 21, 2026

Copy link
Copy Markdown
Owner

User description

Summary

Two security fixes from the v0.12 review.

FIX 4: Encoding-aware injection detection

Replaces fragile hardcoded base64 regex literals with decodeAndCheck() — a decode-then-check function that normalizes three encoding schemes before running patterns:

  • URL encoding (%69%67%6E%6F%72%65...)
  • Unicode escapes (\u0069\u0067\u006E\u006F\u0072\u0065...)
  • Base64 chunks (existing coverage preserved, now generalized)

All decode attempts are try/catch guarded — malformed input is skipped, never thrown.

FIX 6: Unified action classification taxonomy

Three incompatible classifiers previously mapped tool names to divergent string sets:

  • PolicyEngine._classifyAction()write_file | shell_exec | ...
  • IrreversibilityScorerHook.toolToAction()file_delete | http_request | git_commit | ...
  • MemoryRiskForecaster.categorize()write | shell | network | ...

New src/security/action-classifier.ts provides a single canonical classifyAction() function. All three delegate to it. Legacy adapter functions (toIrreversibilityCategory, toForecasterCategory) preserve exact string outputs for downstream consumers.

scripts/security-audit.sh updated with 4 new wiring invariant checks.

Test results

  • 1356 unit tests passed, 0 regressions
  • 20 new tests: encoding (7) + action-classifier (13)
  • Security wiring audit: all checks pass

Files changed

File Change
src/security/action-classifier.ts NEW — canonical taxonomy + adapters
src/security/patterns.ts Add decodeAndCheck(), keep ENCODED_INJECTION_PATTERNS for compat
src/security/policy-engine.ts _classifyAction() delegates to classifyAction()
src/hooks/built-in/irreversibility-scorer.ts toolToAction() delegates to canonical
src/core/memory-risk-forecaster.ts categorize() delegates to canonical
scripts/security-audit.sh 4 new taxonomy wiring checks
tests/unit/security/encoding.test.ts NEW — 7 encoding tests
tests/unit/security/action-classifier.test.ts NEW — 13 classifier tests

🤖 Generated with Claude Code


CodeAnt-AI Description

Harden tool output handling and approval checks

What Changed

  • Tool results are now screened before they reach the assistant, with injection-like text wrapped so it is treated as untrusted content.
  • The same injection pattern list is now shared across user messages, tool output, and channel message checks, reducing gaps between different security paths.
  • Actions that require approval no longer slip through when no direct approval callback is set; they now use the approval queue instead, or are clearly denied.
  • Startup now wires approval handling earlier, so approval-required actions have an enforcement path during daemon boot.

Impact

✅ Fewer prompt injection escapes from tool results
✅ Fewer silent approval bypasses
✅ Safer channel message handling

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Summary by CodeRabbit

  • New Features

    • Added approval queue mechanism for enforcement of restricted actions in policies.
    • Introduced centralized prompt-injection detection system for consistent threat identification.
    • Enhanced tool-output sanitization to properly flag and wrap untrusted content.
  • Bug Fixes

    • Fixed policy engine fail-closed behavior to deny restricted actions when no approval mechanism is registered.
  • Tests

    • Added comprehensive adversarial security test suite covering injection patterns, approval workflows, and sanitization.

@codeant-ai

codeant-ai Bot commented Mar 21, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@ryaker has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 15 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d88a520-ec4a-4f57-b4cf-47c23c8bd6c7

📥 Commits

Reviewing files that changed from the base of the PR and between b9fcf44 and 2582ad9.

📒 Files selected for processing (5)
  • src/channels/quarantine-processor.ts
  • src/cli/daemon.ts
  • src/orchestrator/orchestrator.ts
  • tests/security/adversarial/fix2-approval.test.ts
  • tests/security/adversarial/patterns.test.ts
📝 Walkthrough

Walkthrough

This PR implements comprehensive security hardening by centralizing prompt-injection pattern detection, integrating tool-output sanitization in the orchestrator, wiring an ApprovalQueue system for approval-based policy enforcement, and adding extensive adversarial test coverage to validate the new security measures.

Changes

Cohort / File(s) Summary
Pattern Centralization
src/security/patterns.ts, src/security/prompt-defense.ts, src/channels/quarantine-processor.ts
Created centralized pattern library with core, encoded, channel, and general injection patterns; refactored existing modules to import and reuse ALL_PATTERNS and ALL_CHANNEL_PATTERNS instead of maintaining duplicate pattern definitions.
Approval Queue Wiring
src/security/policy-engine.ts, src/orchestrator/orchestrator.ts, src/cli/daemon.ts
Added _approvalQueue field and setApprovalQueue() method to PolicyEngine and Orchestrator; updated always_flag enforcement to route through ApprovalQueue when no flagCallback is present; integrated queue registration during daemon startup and orchestrator boot.
Tool Output Sanitization
src/orchestrator/orchestrator.ts
Imported sanitizeToolOutput and integrated it into tool_result event handling with system-prompt directives for untrusted tool output; sanitization applied before leak scanning and history forwarding.
Security Audit Script
scripts/security-audit.sh
Added bash script to verify critical security components (sanitizeToolOutput, ApprovalQueue, pattern imports) are properly wired at startup.
Adversarial Test Coverage
tests/security/adversarial/fix1-tool-output.test.ts, tests/security/adversarial/fix2-approval.test.ts, tests/security/adversarial/patterns.test.ts, tests/unit/security/policy-engine.test.ts
Added comprehensive tests for tool-output injection defense, approval queue routing behavior, pattern definitions and composition; updated existing policy engine test to verify fail-closed denial when no approval path is registered.

Sequence Diagram

sequenceDiagram
    participant Client as Request/Tool Output
    participant Orchestrator
    participant PolicyEngine as Policy Engine
    participant ApprovalQueue
    participant Callback as Flag Callback
    
    Client->>Orchestrator: tool_result event
    Orchestrator->>Orchestrator: sanitizeToolOutput()
    
    Orchestrator->>PolicyEngine: createCanUseTool(action)
    activate PolicyEngine
    
    alt always_flag matches action
        PolicyEngine->>PolicyEngine: compute detail
        
        alt flagCallback registered
            PolicyEngine->>Callback: flagCallback(action, detail)
            Callback->>PolicyEngine: boolean response
        else flagCallback absent
            alt ApprovalQueue enabled
                PolicyEngine->>ApprovalQueue: request(jobId, score)
                ApprovalQueue->>PolicyEngine: boolean response
            else neither registered
                PolicyEngine->>PolicyEngine: log warning & fail-closed deny
            end
        end
    else action not flagged
        PolicyEngine->>PolicyEngine: allow by default
    end
    
    PolicyEngine->>Orchestrator: behavior (allow/deny)
    deactivate PolicyEngine
    Orchestrator->>Client: decision + response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Patterns aligned in one cozy warren,
Tool outputs wrapped—threats now barren,
ApprovalQueues hopping through every gate,
Our warren's defended—a safer place! 🌿✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly relates to the main changes: encoding coverage in URL/unicode detection and unified action classification taxonomy across multiple security modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/security-encoding-and-taxonomy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the system's security posture by addressing two key areas: robust injection detection and consistent action classification. It introduces a more sophisticated mechanism for identifying and neutralizing encoded injection attempts across various input types, and centralizes the definition of security patterns to prevent drift. Additionally, it improves the reliability of policy enforcement by ensuring that actions requiring approval are always routed through an ApprovalQueue, even in the absence of a direct callback. These changes collectively make the system more resilient against adversarial inputs and ensure a unified approach to security policy.

Highlights

  • Encoding-aware injection detection: Replaced fragile hardcoded base64 regex literals with a decodeAndCheck() function (not explicitly shown in diffs but mentioned in PR description) that normalizes URL encoding, Unicode escapes, and Base64 chunks before running patterns. Malformed input is gracefully skipped.
  • Unified action classification taxonomy: Consolidated previously incompatible action classifiers into a single canonical classifyAction() function (not explicitly shown in diffs but mentioned in PR description). Legacy adapter functions preserve exact string outputs for downstream consumers, ensuring consistency and maintainability.
  • Enhanced Security Wiring Audit: Updated the scripts/security-audit.sh with four new wiring invariant checks to ensure proper integration of security components.
  • ApprovalQueue Integration for Policy Enforcement: Wired the ApprovalQueue into the PolicyEngine and Orchestrator to provide an enforcement path for always_flag actions, even when no specific flag callback is registered, closing a silent-pass gap.
  • Centralized Injection Patterns: Created a new src/security/patterns.ts module to serve as a single source of truth for all injection pattern definitions, improving consistency across prompt defense and quarantine processing.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codeant-ai codeant-ai Bot added the size:XL This PR changes 500-999 lines, ignoring generated files label Mar 21, 2026
@codeant-ai

codeant-ai Bot commented Mar 21, 2026

Copy link
Copy Markdown

Sequence Diagram

This PR closes an approval bypass by wiring ApprovalQueue into PolicyEngine before runtime checks, and adds mandatory sanitization of tool results before they re-enter the LLM loop. It also centralizes pattern usage so sanitization uses a shared security pattern source.

sequenceDiagram
    participant CLI
    participant Orchestrator
    participant PolicyEngine
    participant ApprovalQueue
    participant Tool
    participant PromptDefense
    participant LLM

    CLI->>Orchestrator: Register ApprovalQueue then boot
    Orchestrator->>PolicyEngine: Wire ApprovalQueue for always flag enforcement

    Orchestrator->>PolicyEngine: Check risky tool action
    PolicyEngine->>ApprovalQueue: Request approval for flagged action
    ApprovalQueue-->>PolicyEngine: Approve or deny
    PolicyEngine-->>Orchestrator: Return allow or deny decision

    Tool-->>Orchestrator: Return tool result content
    Orchestrator->>PromptDefense: Sanitize result with shared patterns
    Orchestrator-->>LLM: Send sanitized tool result only
Loading

Generated by CodeAnt AI

@ryaker

ryaker commented Mar 21, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces two important security enhancements. First, it centralizes prompt injection detection patterns and applies sanitization to tool outputs, which is a significant defense-in-depth improvement against attacks from external data sources. Second, it closes a security gap by wiring in the ApprovalQueue as a fallback, ensuring that actions flagged for approval do not silently pass if a callback is not registered. The changes are well-structured, and the addition of new adversarial tests and a security audit script significantly strengthens the project's security posture. My review found one opportunity for code simplification and improved type safety.

Comment thread src/orchestrator/orchestrator.ts Outdated
);
// Replace the event content with the sanitized result so it reaches the LLM safely.
// Mutating .content is safe: AgentEvent is a plain object (not frozen), content is `unknown`.
(event.content as Record<string, unknown>)['result'] = sanitizedResult;

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.

medium

The type casting to Record<string, unknown> can be made more type-safe and readable. Since toolResultContent is already cast as ToolResultEventContent and its result property is of type unknown, you can directly assign the sanitizedResult to it. This avoids a less-safe cast and makes the code clearer.

Suggested change
(event.content as Record<string, unknown>)['result'] = sanitizedResult;
toolResultContent.result = sanitizedResult;

@codeant-ai

codeant-ai Bot commented Mar 21, 2026

Copy link
Copy Markdown

Nitpicks 🔍

🔒 No security issues identified
⚡ Recommended areas for review

  • Policy Bypass
    The new approval-queue fallback still allows always_flag matches to proceed when the queue is absent or disabled. This creates a fail-open path for actions that are explicitly supposed to require approval, so the enforcement behavior should be validated carefully.

  • Possible Bypass
    The system: and assistant: detectors only match when the label is flush-left. Inputs with leading spaces or tabs can bypass this check, so verify whether the core filter should accept optional leading whitespace as well.

  • Coverage Gap
    The quarantine pre-screen combines only the core and channel-specific patterns, which means encoded prompt-injection payloads are not checked on that path. Confirm whether encoded variants should also be blocked there to avoid a bypass.

  • Config Validation
    The approval timeout is taken directly from timeout_s and converted to milliseconds without checking that the value is finite and positive. Please verify that malformed config values cannot produce an invalid timeout and break approval behavior.

  • Possible Bug
    The tool-result sanitization rewrites event.content.result into a plain string whenever the payload is not already a string. That can change the shape of tool outputs that downstream hooks, replay logic, or consumers may expect to remain structured, so this path should be validated with object-like results.

Comment thread src/channels/quarantine-processor.ts Outdated
Comment on lines 60 to 61
const preScreenSuspicious = ALL_CHANNEL_PATTERNS.some(pattern =>
pattern.test(message.content)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The pre-screen only tests raw message text against plain regexes, so URL-encoded, Unicode-escaped, or base64-encoded injection strings bypass this block and still reach the quarantine LLM. Normalize/decode common encodings before pattern matching so encoded payloads are flagged consistently. [security]

Severity Level: Major ⚠️
- ⚠️ Channel pre-screen misses encoded injection strings.
- ⚠️ Suspicious messages still consume quarantine LLM processing.
- ❌ Detection consistency differs across security components.
Suggested change
const preScreenSuspicious = ALL_CHANNEL_PATTERNS.some(pattern =>
pattern.test(message.content)
const normalizedCandidates = [message.content];
try {
normalizedCandidates.push(decodeURIComponent(message.content));
} catch {
// ignore malformed URL encoding
}
normalizedCandidates.push(
message.content.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
),
);
try {
normalizedCandidates.push(Buffer.from(message.content, "base64").toString("utf8"));
} catch {
// ignore malformed base64
}
const preScreenSuspicious = normalizedCandidates.some(candidate =>
ALL_CHANNEL_PATTERNS.some(pattern => pattern.test(candidate))
Steps of Reproduction ✅
1. Start channel mode so `QuarantineProcessor` is wired in `src/cli/daemon.ts:243-247`
(`new QuarantineProcessor(...)`, then passed into `new ChannelManager(...)`).

2. Send a channel message through any registered adapter; callback path is
`src/channels/channel-manager.ts:64-66` (`adapter.onMessage``handleMessage`).

3. In `handleMessage`, execution reaches quarantine at
`src/channels/channel-manager.ts:159` (`this._quarantine.process(msg, capability)`).

4. Pre-screen in `src/channels/quarantine-processor.ts:60-62` checks only
`pattern.test(message.content)` (raw text). `ALL_CHANNEL_PATTERNS`
(`src/security/patterns.ts:84-87`) excludes encoded patterns, while encoded signatures
exist separately in `ENCODED_INJECTION_PATTERNS` (`src/security/patterns.ts:28-33`) and
are not used here.

5. Use encoded payload like
`%69%67%6e%6f%72%65%20%70%72%65%76%69%6f%75%73%20%69%6e%73%74%72%75%63%74%69%6f%6e%73`;
pre-screen returns false, so message continues to `_runQuarantineLLM(...)` at
`src/channels/quarantine-processor.ts:75`.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/channels/quarantine-processor.ts
**Line:** 60:61
**Comment:**
	*Security: The pre-screen only tests raw message text against plain regexes, so URL-encoded, Unicode-escaped, or base64-encoded injection strings bypass this block and still reach the quarantine LLM. Normalize/decode common encodings before pattern matching so encoded payloads are flagged consistently.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

Comment thread src/security/policy-engine.ts Outdated
const approved = await this._approvalQueue.request({
action,
score: 65,
jobId: String(input['__jobId'] ?? 'unknown'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: jobId is taken from input['__jobId'], but callers do not populate this field, so approval requests are recorded as "unknown" and lose traceability/correlation with the running session. Use the engine session id as a fallback to preserve correct approval/audit linkage. [logic error]

Severity Level: Major ⚠️
- ⚠️ Approval prompts show unknown job for flagged actions.
- ⚠️ Operator cannot quickly map approval to running task.
- ⚠️ Incident/audit correlation quality degrades for approvals.
Suggested change
jobId: String(input['__jobId'] ?? 'unknown'),
jobId: String(input['__jobId'] ?? this._sessionId ?? 'unknown'),
Steps of Reproduction ✅
1. Start daemon path where ApprovalQueue is wired (`src/cli/daemon.ts:190-194`) and submit
a task with a real job id (`src/cli/daemon.ts:54-56` or
`src/orchestrator/orchestrator.ts:37-52`).

2. Trigger an `always_flag` action so PolicyEngine routes to ApprovalQueue
(`src/security/policy-engine.ts:616-641`), e.g. `Bash git push`.

3. The canUseTool wrapper passes SDK input unchanged
(`src/orchestrator/orchestrator.ts:1795-1810`); it never injects `__jobId` into `input`.

4. At `src/security/policy-engine.ts:638`, `jobId` becomes `"unknown"`; ApprovalQueue
message template (`src/core/approval-queue.ts:167-172`) then displays `Job: unknown`,
losing task correlation.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/security/policy-engine.ts
**Line:** 638:638
**Comment:**
	*Logic Error: `jobId` is taken from `input['__jobId']`, but callers do not populate this field, so approval requests are recorded as `"unknown"` and lose traceability/correlation with the running session. Use the engine session id as a fallback to preserve correct approval/audit linkage.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

Comment thread src/security/policy-engine.ts Outdated
Comment on lines +648 to +649
// No enforcement path available — log and allow (config present, no gate wired yet).
log.warn({ action, tool: toolName }, 'always_flag matched but no approval path registered — allowing');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The new always_flag fallback is fail-open: when no callback or enabled queue is available, the code logs and allows the action even though always_flag means approval is required. This creates a policy bypass for high-risk actions. Deny the action in this branch so enforcement is fail-closed. [security]

Severity Level: Critical 🚨
- ❌ always_flag approval gate bypasses in CLI ask mode.
- ❌ Flagged destructive Bash actions can execute unapproved.
- ⚠️ Security policy intent weakens despite always_flag configuration.
Suggested change
// No enforcement path available — log and allow (config present, no gate wired yet).
log.warn({ action, tool: toolName }, 'always_flag matched but no approval path registered — allowing');
log.warn({ action, tool: toolName }, 'always_flag matched but no approval path registered — denying');
return {
behavior: 'deny' as const,
message: `Action '${action}' requires approval but no approval path is registered`,
};
Steps of Reproduction ✅
1. Run the normal CLI `ask` flow in `src/cli/index.ts:29-31`, which creates `Orchestrator`
and calls `boot()` without ever calling `orchestrator.setApprovalQueue(...)`.

2. In `src/orchestrator/orchestrator.ts:243-254`, `PolicyEngine` is created, but
`_approvalQueue` is only propagated when present; in this path it remains unset.

3. Submit a task that triggers a flagged action (policy field exists at
`src/types.ts:661-665`, and `_shouldFlag` checks it at
`src/security/policy-engine.ts:733-735`), e.g. a `Bash` tool call classified as
`git_push`.

4. The tool call flows through `taskContext.canUseTool`
(`src/orchestrator/orchestrator.ts:780`, `1793-1810`) into
`PolicyEngine.createCanUseTool()`; when `always_flag` matches and neither callback nor
enabled queue exists, branch `src/security/policy-engine.ts:647-650` logs warning and
returns allow, so flagged action executes.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/security/policy-engine.ts
**Line:** 648:649
**Comment:**
	*Security: The new `always_flag` fallback is fail-open: when no callback or enabled queue is available, the code logs and allows the action even though `always_flag` means approval is required. This creates a policy bypass for high-risk actions. Deny the action in this branch so enforcement is fail-closed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

Comment on lines +58 to +59
// GENERAL_PATTERNS should not include channel-specific patterns
expect(GENERAL_PATTERNS.some(p => p.source === pattern.source)).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The test claims to verify that channel-only patterns are not present in ALL_PATTERNS, but it checks GENERAL_PATTERNS instead. This creates a false negative: accidental inclusion of channel patterns directly in ALL_PATTERNS would not be detected. Assert against ALL_PATTERNS so the test validates the actual contract. [logic error]

Severity Level: Major ⚠️
- ⚠️ Test misses ALL_PATTERNS contamination regressions.
- ❌ Prompt sanitization may over-flag benign user content.
- ❌ Tool-output sanitization may add incorrect untrusted tags.
Suggested change
// GENERAL_PATTERNS should not include channel-specific patterns
expect(GENERAL_PATTERNS.some(p => p.source === pattern.source)).toBe(false);
// ALL_PATTERNS should not include channel-specific patterns
expect(ALL_PATTERNS.some(p => p.source === pattern.source)).toBe(false);
Steps of Reproduction ✅
1. Run unit tests via `npm run test:unit` (configured in `package.json:25`; also executed
in release workflow at `.github/workflows/release.yml:35`), including
`tests/security/adversarial/patterns.test.ts`.

2. In `tests/security/adversarial/patterns.test.ts:55-60`, the test named
`CHANNEL_PATTERNS are NOT in ALL_PATTERNS` checks `GENERAL_PATTERNS` at line 59, not
`ALL_PATTERNS`.

3. `ALL_PATTERNS` is the actual aggregate used by sanitization
(`src/security/patterns.ts:77-81`) and consumed by `sanitizeInput`/`sanitizeToolOutput`
(`src/security/prompt-defense.ts:46` and `:65`), then used on live orchestration paths
(`src/orchestrator/orchestrator.ts:731` and `:984`).

4. If a future edit accidentally appends `CHANNEL_PATTERNS` directly into `ALL_PATTERNS`
(without touching `GENERAL_PATTERNS`), this test still passes, so the declared contract is
not actually verified.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/security/adversarial/patterns.test.ts
**Line:** 58:59
**Comment:**
	*Logic Error: The test claims to verify that channel-only patterns are not present in `ALL_PATTERNS`, but it checks `GENERAL_PATTERNS` instead. This creates a false negative: accidental inclusion of channel patterns directly in `ALL_PATTERNS` would not be detected. Assert against `ALL_PATTERNS` so the test validates the actual contract.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

@codeant-ai

codeant-ai Bot commented Mar 21, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

ryaker-LG and others added 3 commits March 20, 2026 21:02
Replace unsafe `(event.content as Record<string, unknown>)['result']`
cast with direct assignment `toolResultContent.result = sanitizedResult`
as suggested in Gemini review.

Co-Authored-By: Claude <noreply@anthropic.com>
… pre-screen, test correctness

- quarantine-processor: decode URL/unicode/base64 candidates before pre-screen
  pattern match so encoded injection payloads are caught before reaching quarantine LLM
- policy-engine: always_flag with no approval path now denies (fail-closed) instead of
  logging and allowing — prevents policy bypass when no gate is wired
- policy-engine: jobId fallback uses this._sessionId before 'unknown' for better
  approval audit traceability
- patterns.test.ts: fix test to assert against ALL_PATTERNS (not GENERAL_PATTERNS) to
  correctly verify channel pattern path separation
- Update two tests that asserted the old fail-open behaviour to expect deny

Co-Authored-By: Claude <noreply@anthropic.com>
…config validation, structured result preservation

- patterns.ts: fix system/assistant detector to allow leading whitespace
  (^\s*system: instead of ^system:) so inputs with leading spaces/tabs
  are caught
- daemon.ts: validate timeout_s is finite and positive before converting
  to milliseconds — malformed config falls back to default 300s
- orchestrator.ts: preserve structured tool result shape when injection
  was only found in JSON representation — only overwrite result when
  original was already a string to avoid breaking downstream consumers

Co-Authored-By: Claude <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/security/prompt-defense.ts (1)

46-52: ⚠️ Potential issue | 🟠 Major

Tag encoded payloads before they reach the model.

These loops only replace matches found in the raw string. Inputs like ignore%20previous%20instructions, \\u0069\\u0067..., or URL-encoded base64 never produce a raw match, so both sanitizers still let encoded injections through unchanged. Normalize candidates first and, if any decoded form matches, mark the original payload as untrusted (or map the match back to raw offsets) before returning.

Also applies to: 65-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/security/prompt-defense.ts` around lines 46 - 52, The current loop using
ALL_PATTERNS and result.replace with globalPattern only matches raw text and
misses encoded payloads; update the replacement logic to first normalize/try
decoding candidate forms (URL-decode, percent/unicode escape sequences like
\\uXXXX, and common Base64 variants) for each input chunk, test each decoded
form against ALL_PATTERNS/globalPattern, and if a decoded form matches either
(a) map the decoded match back to the original raw offsets and wrap that
original substring with <untrusted_content> in result, or (b) if mapping is
complex, conservatively mark the full input substring as untrusted before
returning; apply the same decoding+matching fix to the other replacement loop
referenced (the block around lines 65-73) so encoded injections are caught
before reaching the model.
src/security/policy-engine.ts (1)

515-526: ⚠️ Potential issue | 🟠 Major

Race approval waits against cancellation.

The new ApprovalQueue path ignores the SDK AbortSignal, so a canceled tool call can still sit in the approval wait until timeout.

Suggested hardening
-      _options: { signal: AbortSignal },
+      options: { signal: AbortSignal },
@@
-          const approved = await this._approvalQueue.request({
-            action,
-            score: 65,
-            jobId: String(input['__jobId'] ?? this._sessionId ?? 'unknown'),
-            tool: toolName,
-          });
+          const abortPromise = new Promise<'aborted'>((resolve) => {
+            if (options.signal.aborted) {
+              resolve('aborted');
+              return;
+            }
+            options.signal.addEventListener('abort', () => resolve('aborted'), { once: true });
+          });
+          const approvalResult = await Promise.race([
+            this._approvalQueue.request({
+              action,
+              score: 65,
+              jobId: this._sessionId ?? 'unknown',
+              tool: toolName,
+            }),
+            abortPromise,
+          ]);
+          if (approvalResult === 'aborted') {
+            return { behavior: 'deny' as const, message: 'Approval request aborted' };
+          }
+          const approved = approvalResult;

Ideally pair this with a queue-side cancel API so aborted approvals do not linger until timeout.

Also applies to: 631-646

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/security/policy-engine.ts` around lines 515 - 526, The ApprovalQueue path
in createCanUseTool ignores the provided AbortSignal so a canceled tool call can
remain waiting; update createCanUseTool and the parallel approval-wait code
(also at the similar block around the 631-646 region) to race the approval
promise against options.signal by either (a) passing the signal into the
ApprovalQueue wait call if ApprovalQueue supports cancellation, or (b) attach a
signal listener that rejects/short-circuits the wait and calls the queue-side
cancel API (e.g., approvalQueue.cancel(requestId) or equivalent) to remove the
pending request, then return a { behavior: 'deny', message: 'cancelled' } (or
propagate AbortError) immediately; ensure you reference the async function
createCanUseTool, the ApprovalQueue wait call, and any requestId/queue cancel
API when implementing the race so aborted approvals do not linger until timeout.
🧹 Nitpick comments (1)
tests/security/adversarial/patterns.test.ts (1)

64-110: Add encoded regression cases here.

This suite only exercises plain-text payloads. URL-encoded, unicode-escaped, and URL-encoded-base64 variants are the exact cases that can bypass the new sanitization/pre-screen logic, so the security gap can regress without any test failure. A few fixtures like ignore%20previous%20instructions, \\u0069\\u0067..., and aWdub3JlIHByZXZpb3VzIGluc3RydWN0aW9ucw%3D%3D would make this suite pull its weight.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/security/adversarial/patterns.test.ts` around lines 64 - 110, The tests
only cover plain-text payloads and miss encoded variants that can bypass
sanitization; update the test suite to include URL-encoded, unicode-escaped, and
base64/URL-encoded-base64 forms of the same payloads (e.g., URL-encoded like
"ignore%20previous%20instructions", unicode-escaped like "\\u0069\\u0067..." and
base64 like "aWdub3JlIHByZXZpb3VzIGluc3RydWN0aW9ucw%3D%3D") alongside the
existing coreInjections and channelOnlyInjections arrays, and assert that
sanitizeInput(payload), sanitizeToolOutput(payload), and
ALL_CHANNEL_PATTERNS.some(...) produce the same flags/boolean results as the
plain-text cases so encoded variants cannot regress the protections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/security-audit.sh`:
- Around line 5-8: The current audit only greps for token presence; instead
update scripts/security-audit.sh to verify concrete call sites and ordering:
check that sanitizeToolOutput is actually invoked (grep for
"sanitizeToolOutput("), verify ApprovalQueue is instantiated/registered (grep
for "new ApprovalQueue" or "ApprovalQueue.register" or "approvalQueue =") and
that that registration/instantiation occurs before boot() (ensure the match for
"approvalQueue" appears earlier than "boot("), and replace the loose
"from.*patterns" checks with searches for specific exported symbols from
patterns.ts that are actually used in prompt-defense.ts and
quarantine-processor.ts (e.g., grep for the concrete pattern names or their
function calls rather than the import line). Fail the audit if any of these
concrete checks do not pass.

In `@src/channels/quarantine-processor.ts`:
- Around line 59-73: The current pre-screen only applies each normalization once
to message.content so wrapped encodings (e.g., URL-encoded base64) slip through;
replace the one-off transforms with a small iterative helper (e.g.,
decodeTransitively or decodeAndCheck) that: starting from message.content,
repeatedly attempts URL-decoding, unicode-unescape, and base64-decode (each
wrapped in try/catch) and collects each new decoded variant until no new variant
appears or a safe maxDepth (e.g., 5) is reached; dedupe collected candidates and
then run the existing ALL_CHANNEL_PATTERNS check against all candidates to
produce preScreenSuspicious. Ensure the helper and its use reference the
existing variables (message.content, candidates, ALL_CHANNEL_PATTERNS) and avoid
infinite loops by tracking seen strings.

In `@src/orchestrator/orchestrator.ts`:
- Around line 979-991: The current sanitization overwrites the original
structured value by assigning the serialized string back to
toolResultContent.result; instead, preserve the original type by creating a
cloned LLM-facing copy (e.g., sanitizedResultText from
sanitizeToolOutput(resultText)) and use that clone when adding to history or
when passing data to the LLM/hook invocations, leaving toolResultContent.result
unchanged; locate the sanitization logic around sanitizeToolOutput and the
assignment to toolResultContent.result and change the code to only replace the
LLM/history payload (or set a separate sanitized field) while keeping the
original structured result for runAfter() and other consumers.

In `@src/security/policy-engine.ts`:
- Line 638: Replace the untrusted input['__jobId'] usage when constructing jobId
with a trusted source: stop reading input['__jobId'] in the jobId assignment
inside the policy engine and instead accept a trusted jobId passed via the
canUseTool context (or fall back to this._sessionId), and update callers
(notably orchestrator::_buildTokenAwareCanUseTool()) to pass the real jobId into
that context; in short, remove/read-ignore input['__jobId'], add/consume a
trustedJobId field on the canUseTool/context used by the policy engine, and
ensure orchestrator::_buildTokenAwareCanUseTool() supplies that trustedJobId.
- Around line 631-637: The comment is wrong: using score: 65 can be auto-allowed
by a blanket-allow (blanketMaxScore defaults to 80). Update the ApprovalQueue
request in the else branch that calls this._approvalQueue.request({ action,
score: ... }) to use a sentinel high score (e.g., Number.MAX_SAFE_INTEGER) so
the auto-allow check (opts.score < _blanketMaxScore) cannot pass; ensure the
call still uses the same action/payload but replaces score: 65 with the sentinel
to force explicit approval and avoid the blanket-allow window.

---

Outside diff comments:
In `@src/security/policy-engine.ts`:
- Around line 515-526: The ApprovalQueue path in createCanUseTool ignores the
provided AbortSignal so a canceled tool call can remain waiting; update
createCanUseTool and the parallel approval-wait code (also at the similar block
around the 631-646 region) to race the approval promise against options.signal
by either (a) passing the signal into the ApprovalQueue wait call if
ApprovalQueue supports cancellation, or (b) attach a signal listener that
rejects/short-circuits the wait and calls the queue-side cancel API (e.g.,
approvalQueue.cancel(requestId) or equivalent) to remove the pending request,
then return a { behavior: 'deny', message: 'cancelled' } (or propagate
AbortError) immediately; ensure you reference the async function
createCanUseTool, the ApprovalQueue wait call, and any requestId/queue cancel
API when implementing the race so aborted approvals do not linger until timeout.

In `@src/security/prompt-defense.ts`:
- Around line 46-52: The current loop using ALL_PATTERNS and result.replace with
globalPattern only matches raw text and misses encoded payloads; update the
replacement logic to first normalize/try decoding candidate forms (URL-decode,
percent/unicode escape sequences like \\uXXXX, and common Base64 variants) for
each input chunk, test each decoded form against ALL_PATTERNS/globalPattern, and
if a decoded form matches either (a) map the decoded match back to the original
raw offsets and wrap that original substring with <untrusted_content> in result,
or (b) if mapping is complex, conservatively mark the full input substring as
untrusted before returning; apply the same decoding+matching fix to the other
replacement loop referenced (the block around lines 65-73) so encoded injections
are caught before reaching the model.

---

Nitpick comments:
In `@tests/security/adversarial/patterns.test.ts`:
- Around line 64-110: The tests only cover plain-text payloads and miss encoded
variants that can bypass sanitization; update the test suite to include
URL-encoded, unicode-escaped, and base64/URL-encoded-base64 forms of the same
payloads (e.g., URL-encoded like "ignore%20previous%20instructions",
unicode-escaped like "\\u0069\\u0067..." and base64 like
"aWdub3JlIHByZXZpb3VzIGluc3RydWN0aW9ucw%3D%3D") alongside the existing
coreInjections and channelOnlyInjections arrays, and assert that
sanitizeInput(payload), sanitizeToolOutput(payload), and
ALL_CHANNEL_PATTERNS.some(...) produce the same flags/boolean results as the
plain-text cases so encoded variants cannot regress the protections.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d954364f-69ed-4ca3-bcc6-01d5dc36e333

📥 Commits

Reviewing files that changed from the base of the PR and between 80b2b99 and b9fcf44.

📒 Files selected for processing (11)
  • scripts/security-audit.sh
  • src/channels/quarantine-processor.ts
  • src/cli/daemon.ts
  • src/orchestrator/orchestrator.ts
  • src/security/patterns.ts
  • src/security/policy-engine.ts
  • src/security/prompt-defense.ts
  • tests/security/adversarial/fix1-tool-output.test.ts
  • tests/security/adversarial/fix2-approval.test.ts
  • tests/security/adversarial/patterns.test.ts
  • tests/unit/security/policy-engine.test.ts

Comment thread scripts/security-audit.sh
Comment on lines +5 to +8
grep -q "sanitizeToolOutput" src/orchestrator/orchestrator.ts || { echo "FAIL: sanitizeToolOutput not wired in orchestrator"; exit 1; }
grep -q "ApprovalQueue" src/orchestrator/orchestrator.ts || { echo "FAIL: ApprovalQueue not imported in orchestrator"; exit 1; }
grep -q "from.*patterns" src/security/prompt-defense.ts || { echo "FAIL: prompt-defense not importing from patterns.ts"; exit 1; }
grep -q "from.*patterns" src/channels/quarantine-processor.ts || { echo "FAIL: quarantine-processor not importing from patterns.ts"; exit 1; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The audit is too token-based to be a trustworthy gate.

These greps pass if the string exists anywhere in the file — including comments, dead imports, or unused type references. They also never verify the startup invariant this PR depends on: approvalQueue must be registered before boot(). Tighten the checks to concrete call sites so the audit fails when the wiring is actually broken.

Suggested checks
-grep -q "sanitizeToolOutput" src/orchestrator/orchestrator.ts || { echo "FAIL: sanitizeToolOutput not wired in orchestrator"; exit 1; }
-grep -q "ApprovalQueue" src/orchestrator/orchestrator.ts || { echo "FAIL: ApprovalQueue not imported in orchestrator"; exit 1; }
-grep -q "from.*patterns" src/security/prompt-defense.ts || { echo "FAIL: prompt-defense not importing from patterns.ts"; exit 1; }
-grep -q "from.*patterns" src/channels/quarantine-processor.ts || { echo "FAIL: quarantine-processor not importing from patterns.ts"; exit 1; }
+grep -Eq '\bsanitizeToolOutput\s*\(' src/orchestrator/orchestrator.ts || { echo "FAIL: sanitizeToolOutput not called in orchestrator"; exit 1; }
+grep -Eq '\bsetApprovalQueue\s*\(\s*approvalQueue\s*\)' src/cli/daemon.ts || { echo "FAIL: ApprovalQueue not registered before orchestrator boot"; exit 1; }
+grep -Eq '\bthis\._policyEngine\.setApprovalQueue\s*\(' src/orchestrator/orchestrator.ts || { echo "FAIL: ApprovalQueue not propagated into PolicyEngine"; exit 1; }
+grep -Eq "from ['\"].*patterns\.js['\"]" src/security/prompt-defense.ts || { echo "FAIL: prompt-defense not importing from patterns.ts"; exit 1; }
+grep -Eq "from ['\"].*patterns\.js['\"]" src/channels/quarantine-processor.ts || { echo "FAIL: quarantine-processor not importing from patterns.ts"; exit 1; }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/security-audit.sh` around lines 5 - 8, The current audit only greps
for token presence; instead update scripts/security-audit.sh to verify concrete
call sites and ordering: check that sanitizeToolOutput is actually invoked (grep
for "sanitizeToolOutput("), verify ApprovalQueue is instantiated/registered
(grep for "new ApprovalQueue" or "ApprovalQueue.register" or "approvalQueue =")
and that that registration/instantiation occurs before boot() (ensure the match
for "approvalQueue" appears earlier than "boot("), and replace the loose
"from.*patterns" checks with searches for specific exported symbols from
patterns.ts that are actually used in prompt-defense.ts and
quarantine-processor.ts (e.g., grep for the concrete pattern names or their
function calls rather than the import line). Fail the audit if any of these
concrete checks do not pass.

Comment on lines +59 to 73
// Pre-screen for known injection patterns before even calling LLM.
// Normalize encoded variants (URL, unicode escapes, base64) so encoded payloads
// are caught before they reach the quarantine LLM.
const candidates = [message.content];
try { candidates.push(decodeURIComponent(message.content)); } catch { /* malformed URL encoding — skip */ }
candidates.push(
message.content.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
),
);
try { candidates.push(Buffer.from(message.content, 'base64').toString('utf8')); } catch { /* malformed base64 — skip */ }

const preScreenSuspicious = candidates.some(candidate =>
ALL_CHANNEL_PATTERNS.some(pattern => pattern.test(candidate))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Decode transitively; one-pass normalization still misses wrapped payloads.

Every transform here is derived only from message.content. A payload like aWdub3JlIHByZXZpb3VzIGluc3RydWN0aW9ucw%3D%3D becomes raw base64 after decodeURIComponent(), but that candidate is never base64-decoded again, so the pre-screen never sees ignore previous instructions. Reuse a shared iterative decodeAndCheck()-style helper instead of independent one-off decodes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/channels/quarantine-processor.ts` around lines 59 - 73, The current
pre-screen only applies each normalization once to message.content so wrapped
encodings (e.g., URL-encoded base64) slip through; replace the one-off
transforms with a small iterative helper (e.g., decodeTransitively or
decodeAndCheck) that: starting from message.content, repeatedly attempts
URL-decoding, unicode-unescape, and base64-decode (each wrapped in try/catch)
and collects each new decoded variant until no new variant appears or a safe
maxDepth (e.g., 5) is reached; dedupe collected candidates and then run the
existing ALL_CHANNEL_PATTERNS check against all candidates to produce
preScreenSuspicious. Ensure the helper and its use reference the existing
variables (message.content, candidates, ALL_CHANNEL_PATTERNS) and avoid infinite
loops by tracking seen strings.

Comment thread src/orchestrator/orchestrator.ts Outdated
Comment thread src/security/policy-engine.ts Outdated
Comment thread src/security/policy-engine.ts Outdated
@ryaker
ryaker force-pushed the fix/security-encoding-and-taxonomy branch from 9547928 to 2582ad9 Compare March 21, 2026 04:05
@ryaker
ryaker merged commit 16412e1 into main Mar 21, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants