Skip to content

feat(rules): external rule-pack loader (--rule-pack) - #107

Open
affaan-m wants to merge 1 commit into
mainfrom
feat/external-rule-pack-loader
Open

feat(rules): external rule-pack loader (--rule-pack)#107
affaan-m wants to merge 1 commit into
mainfrom
feat/external-rule-pack-loader

Conversation

@affaan-m

@affaan-m affaan-m commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Implements the external rule-pack loader proposed in #101. Turns AgentShield into a platform: scans can run community or private detection rules alongside the built-ins without recompiling the binary.

What

agentshield scan --rule-pack ./pack.json
agentshield scan --rule-pack a.json --rule-pack b.json   # repeatable
  • src/rules/external.tsloadRulePack / loadRulePacks. A pack is { version: 1, name?, rules: [{ id, name, description?, severity, category, patterns, flags?, fileTypes? }] }, validated with zod exactly like the --policy loader. Fails closed on missing file, invalid JSON, schema violation, duplicate id, or an uncompilable regex. Each entry maps to a Rule whose check() runs its patterns (global flag forced for matchAll) against file content; optional fileTypes scopes a rule; findings are capped per rule per file.
  • src/scanner/index.tsscan(targetPath, { extraRules }) appends pack rules to the built-in set through the existing generic runRules loop (a few lines, as the issue predicted).
  • CLIscan --rule-pack <path> (repeatable); loads before the scan, prints Loaded N external rules from <pack> to stderr, exits 1 on load error.
  • README — new "External Rule Packs" section + CLI Reference entry.

Design notes (from the issue)

  • The loader is generic — anyone with a JSON pack in this shape can plug in, not just ATR.
  • External findings count toward the overall grade; the five fixed ScoreBreakdown buckets are unchanged (acceptable for v1, as discussed).
  • Patterns are trusted input (loaded only when the operator passes --rule-pack); ReDoS-hardening of arbitrary external regexes is a documented v1 limitation, same as any regex linter.

Verification

  • 20 new tests (tests/rules/external.test.ts + a scanner integration test): valid load + detect, benign no-match, fileTypes scoping, missing-file / invalid-JSON / bad-category / uncompilable-regex / duplicate-id failures, forced global flag with unique ids, multi-pack concat, and partial-failure handling.
  • Full suite green (1875 tests), tsc --noEmit + eslint clean.
  • CLI smoke: a sample pack loads, detects, and JSON output stays machine-clean.

Closes #101.

Greptile Summary

This PR adds an external rule-pack loader (--rule-pack) that lets operators supply community or private JSON detection rules to run alongside AgentShield's built-ins, without recompiling the binary. The loader validates packs with zod (fail-closed on bad JSON, schema violations, duplicate IDs within a pack, or uncompilable regexes) and the scanner integration is a clean one-liner.

  • src/rules/external.ts: New loader that compiles pack entries into Rule objects; includes findings cap, fileTypes scoping, and flag normalization. Two correctness issues exist: finding IDs reset per check() call (non-unique across files, breaking baseline/remediation-plan fingerprinting), and no duplicate rule ID check across multiple packs.
  • src/scanner/index.ts / src/index.ts: Minimal, correct wiring — ScanOptions.extraRules appended to built-ins, CLI accumulates --rule-pack paths and exits 1 on load failure.
  • Tests: 20 new unit tests cover the main paths, but no test exercises cross-file ID uniqueness or cross-pack duplicate rule IDs.

Confidence Score: 3/5

The new external rule-pack loader is broadly well-constructed, but two correctness issues in finding ID generation will produce duplicate IDs across files — directly undermining the baseline comparison and remediation-plan features that operators rely on for tracking regressions.

Finding IDs are scoped only within a single check() call, so the same ID (e.g. external-foo-0) is emitted for every matched file. Baseline diffs and stable-fingerprint remediation plans both depend on IDs being unique within a scan; collisions here mean findings can be incorrectly matched or dropped across runs. The second issue — no cross-pack duplicate rule ID check — compounds the same problem when two packs share a rule name. Both issues are confined to src/rules/external.ts and have clear, targeted fixes, but they affect a correctness-sensitive path that the rest of the feature depends on.

src/rules/external.ts — the finding ID generation and cross-pack deduplication logic both need fixes before the feature is reliable with --baseline or --remediation-plan.

Important Files Changed

Filename Overview
src/rules/external.ts New external rule-pack loader. Two correctness issues: finding IDs reset to 0 per file (non-unique across files, breaks baselines) and no cross-pack duplicate rule ID detection.
src/scanner/index.ts Adds ScanOptions with optional extraRules; appends them to built-in rules before the existing runRules loop. Minimal, correct change.
src/index.ts Adds --rule-pack CLI option with correct Commander.js accumulator pattern; loads packs before scan, exits 1 on load error, logs loaded rule count per pack to stderr.
tests/rules/external.test.ts 20 new unit tests covering valid load, detection, fileTypes scoping, all fail-closed paths, global flag, multi-pack concat. No test for cross-file ID uniqueness or cross-pack duplicate rule IDs.
tests/scanner/scanner.test.ts Adds one scanner integration test verifying external rules fire alongside built-ins; uses an inline Rule object rather than a full pack, correctly exercises the extraRules code path.
README.md New "External Rule Packs" section with usage examples, pack JSON schema, and CLI reference entry. Accurate and consistent with the implementation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CLI as CLI (src/index.ts)
    participant Loader as loadRulePacks (external.ts)
    participant Schema as Zod Schema
    participant Scanner as scan() (scanner/index.ts)
    participant Rules as runRules()

    CLI->>Loader: loadRulePacks([path1, path2])
    loop each path
        Loader->>Loader: existsSync(path)
        Loader->>Schema: RulePackSchema.parse(JSON.parse(file))
        Schema-->>Loader: validated RulePack
        Loader->>Loader: check duplicate IDs (within pack only)
        Loader->>Loader: compileEntryPatterns → new RegExp[]
        Loader->>Loader: entryToRule() → Rule[]
    end
    Loader-->>CLI: "{ success, rules, packs }"
    CLI->>CLI: stderr: Loaded N rules from pack
    CLI->>Scanner: "scan(targetPath, { extraRules })"
    Scanner->>Scanner: [...getBuiltinRules(), ...extraRules]
    Scanner->>Rules: runRules(files, allRules)
    loop each file x each rule
        Rules->>Rules: rule.check(file) → findings
        Note right of Rules: seq resets to 0 per call, IDs non-unique across files
    end
    Rules-->>Scanner: sorted findings
    Scanner-->>CLI: ScanResult
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CLI as CLI (src/index.ts)
    participant Loader as loadRulePacks (external.ts)
    participant Schema as Zod Schema
    participant Scanner as scan() (scanner/index.ts)
    participant Rules as runRules()

    CLI->>Loader: loadRulePacks([path1, path2])
    loop each path
        Loader->>Loader: existsSync(path)
        Loader->>Schema: RulePackSchema.parse(JSON.parse(file))
        Schema-->>Loader: validated RulePack
        Loader->>Loader: check duplicate IDs (within pack only)
        Loader->>Loader: compileEntryPatterns → new RegExp[]
        Loader->>Loader: entryToRule() → Rule[]
    end
    Loader-->>CLI: "{ success, rules, packs }"
    CLI->>CLI: stderr: Loaded N rules from pack
    CLI->>Scanner: "scan(targetPath, { extraRules })"
    Scanner->>Scanner: [...getBuiltinRules(), ...extraRules]
    Scanner->>Rules: runRules(files, allRules)
    loop each file x each rule
        Rules->>Rules: rule.check(file) → findings
        Note right of Rules: seq resets to 0 per call, IDs non-unique across files
    end
    Rules-->>Scanner: sorted findings
    Scanner-->>CLI: ScanResult
Loading

Reviews (1): Last reviewed commit: "feat(rules): external rule-pack loader (..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Summary by CodeRabbit

  • New Features

    • Added support for external rule packs via the --rule-pack CLI option, allowing custom detection rules to be loaded without recompiling.
    • External rules support pattern matching, severity levels, categories, and file-type scoping.
    • Multiple rule packs can be loaded in a single scan.
  • Documentation

    • Updated README with guidance on creating and using external rule packs in JSON format.

Lets scans run community/private detection rules alongside the built-ins without
recompiling. Turns AgentShield into a platform: anyone with a JSON pack in this
shape can plug in (e.g. the MIT Agent Threat Rules pack, 464 rules).

- src/rules/external.ts: loadRulePack/loadRulePacks. Zod-validated pack
  ({version,name?,rules:[{id,name,description?,severity,category,patterns,
  flags?,fileTypes?}]}), mirroring the --policy loader. Fails closed on missing
  file, invalid JSON, schema violation, duplicate id, or uncompilable regex.
  Compiles patterns to RegExp (forces global flag), maps each entry to a Rule
  whose check() runs them via matchAll; optional fileTypes scoping; findings
  capped per rule per file.
- src/scanner/index.ts: scan(targetPath, { extraRules }) appends pack rules to
  the builtin set through the existing generic runRules loop.
- CLI: scan --rule-pack <path> (repeatable). Loads before the scan, prints a
  per-pack 'Loaded N external rules' note to stderr, exits 1 on load error.
- README: External Rule Packs section + CLI Reference entry.

20 new tests (loader valid/invalid/scoping/dup/regex/global-flag + multi-pack +
scanner integration). Full suite 1875 green, tsc + eslint clean, CLI smoke
verified (loads pack, detects, JSON output stays clean).

Per the issue's design note: external findings count toward the total grade but
the 5 fixed ScoreBreakdown buckets are unchanged (acceptable for v1).
@ecc-tools

ecc-tools Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "tools"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

Adds an external rule-pack loader to AgentShield. A new src/rules/external.ts module defines Zod schemas, validates JSON packs fail-closed, compiles patterns into RegExp instances, and emits capped findings. The scan function gains a ScanOptions.extraRules parameter. The CLI gains a repeatable --rule-pack <path> option that loads, validates, and passes external rules into the scan.

Changes

External Rule Pack Feature

Layer / File(s) Summary
Rule pack schema, types, and loader
src/rules/external.ts
Defines RulePackSchema (Zod), inferred types (RulePackEntry, RulePack), and LoadRulePackResult. Implements pattern compilation with global-flag normalization, per-rule check() with a findings cap, and loadRulePack/loadRulePacks with fail-closed validation (missing file, bad JSON, schema mismatch, duplicate IDs, invalid regex).
ScanOptions and extraRules integration
src/scanner/index.ts
Exports ScanOptions { extraRules? }, updates scan(targetPath, options = {}) to merge getBuiltinRules() with options.extraRules, and passes target.path as scanRoot to runRules.
CLI --rule-pack option and scan invocation
src/index.ts
Adds loadRulePacks import, a repeatable --rule-pack <path> CLI option, fatal error handling on load failure, per-pack rule-count logging, and calls scan(targetPath, { extraRules }).
Tests and documentation
tests/rules/external.test.ts, tests/scanner/scanner.test.ts, README.md
Vitest suites cover loadRulePack (success, fileTypes scoping, global match, all failure modes) and loadRulePacks (multi-pack concatenation, first-error fail-closed); integration test asserts exact finding-count delta with extraRules; README documents the feature with JSON schema and CLI reference.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature: external rule-pack loader with CLI support via --rule-pack flag.
Linked Issues check ✅ Passed PR implementation fully addresses all coding objectives from issue #101: external rule loader [src/rules/external.ts], scanner extension [src/scanner/index.ts], CLI flag [--rule-pack], zod validation, error handling, and comprehensive testing.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the linked issue scope: rule loader, scanner extension, CLI integration, documentation, and tests supporting external rule-pack loading.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/external-rule-pack-loader

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.

Comment thread src/rules/external.ts
Comment on lines +97 to +112
const findings: Finding[] = [];
let seq = 0;
for (const pattern of compiled) {
for (const match of findAllMatches(file.content, pattern)) {
findings.push({
id: `external-${entry.id}-${seq}`,
severity: entry.severity as Severity,
category: entry.category as FindingCategory,
title: entry.name,
description: `${entry.description ?? entry.name} (external rule ${entry.id}).`,
file: file.path,
line: findLineNumber(file.content, match.index ?? 0),
evidence: match[0].substring(0, 100),
});
seq += 1;
if (findings.length >= MAX_FINDINGS_PER_RULE_PER_FILE) return findings;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Non-unique finding IDs across files

seq resets to 0 at the start of every check() call, so every file that matches the same rule produces findings with the same IDs (external-foo-0, external-foo-1, …). When a rule fires on two different files, both scans emit external-foo-0 — a collision that breaks baseline comparison and remediation-plan fingerprinting, since those features rely on stable, unique IDs to correlate findings across runs. The fix is to incorporate the file path into the ID (e.g. external-${entry.id}-${file.path}-${seq} or a hash thereof) so the ID is globally unique within a single scan.

Comment thread src/rules/external.ts
Comment on lines +167 to +183
export function loadRulePacks(paths: ReadonlyArray<string>): {
readonly success: boolean;
readonly rules: ReadonlyArray<Rule>;
readonly packs: ReadonlyArray<{ name: string; ruleCount: number }>;
readonly error?: string;
} {
const rules: Rule[] = [];
const packs: Array<{ name: string; ruleCount: number }> = [];
for (const path of paths) {
const result = loadRulePack(path);
if (!result.success || !result.rules || !result.meta) {
return { success: false, rules: [], packs: [], error: result.error };
}
rules.push(...result.rules);
packs.push(result.meta);
}
return { success: true, rules, packs };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 No cross-pack duplicate rule ID check

loadRulePack correctly rejects duplicate rule IDs within a single pack, but loadRulePacks never checks whether the same rule ID appears in multiple packs. If pack-a.json and pack-b.json both declare id: "tool-001", the loader silently produces two rules with Rule.id = "external-tool-001" and finding IDs that are entirely indistinguishable (external-tool-001-0, etc.). The PR description says the loader "fails closed on … duplicate id", but that guarantee only holds within a pack. A global seenRuleIds set checked after merging each pack's rules would close this gap.

@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: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 391: The README.md documentation at line 391 describes how patterns
execute as JS regexes but omits that the loader enforces global matching on
external regexes. Add documentation clarifying that external regex patterns are
normalized to global matching by the loader, and explain how this normalization
affects match cardinality and the number of expected findings, ensuring users
understand the behavior of their patterns when using external regex
configurations.

In `@src/index.ts`:
- Line 323: The variable rulePackPaths is currently typed as a mutable string[]
but should be typed as ReadonlyArray<string> to comply with coding guidelines
requiring immutable array typing in src/ files. Change the type annotation of
rulePackPaths from string[] to ReadonlyArray<string> while keeping the
initialization logic the same.

In `@src/rules/external.ts`:
- Around line 167-182: The loadRulePacks function currently only ensures rule ID
uniqueness within individual packs but not across all loaded packs. Two
different packs can define rules with identical IDs, resulting in identical
runtime rule.id values and overlapping finding ID namespaces. After the loop
that collects all rules from all packs, add a validation step to check for
duplicate rule IDs across the entire rules array. If duplicates are found,
return an error response indicating which rule IDs are duplicated rather than
returning success with the overlapping rules.
- Around line 70-72: The findAllMatches function returns a mutable Array type
instead of an immutable ReadonlyArray type. Change the return type of
findAllMatches from Array<RegExpMatchArray> to ReadonlyArray<RegExpMatchArray>.
Additionally, review the code at lines 97-99, 139-140, and 173-174 and apply the
same immutability pattern to any other mutable array type annotations (such as
Finding[] and Rule[]) and remove any array mutations like .push() calls,
replacing them with immutable alternatives like spread operators or concat.

In `@src/scanner/index.ts`:
- Line 35: The rules variable on line 35 that merges the builtin rules with
extra rules is being inferred as a mutable array type instead of a
ReadonlyArray. Add an explicit type annotation to the rules variable declaration
to type it as ReadonlyArray<Rule>. This ensures the merged rules list is
immutable at the type level, aligning with the coding guideline that all arrays
in src/ files must be typed as ReadonlyArray.

In `@tests/scanner/scanner.test.ts`:
- Around line 46-54: Replace the inline finding object (with id
"external-backdoor-0") in the test fixture with a call to the makeFinding()
helper factory function. Pass the necessary properties (id, severity, category,
title, description, file) as arguments to makeFinding() instead of creating a
literal object. This ensures test data is created consistently across the test
suite and makes it easier to maintain and evolve test fixtures.
- Around line 33-60: The test creates a temporary directory using mkdtempSync
and assigns it to the variable dir, but does not clean it up after the test
completes. Add cleanup code after all the test assertions to remove the
temporary directory that was created. Use the rmSync or rm function from the fs
module to delete the directory referenced by the dir variable with the recursive
option enabled to ensure the directory and all its contents are removed. This
should be done after the final expect statement to ensure the test assertions
complete before cleanup occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8357f22-3719-4743-bee3-76b7d9425c05

📥 Commits

Reviewing files that changed from the base of the PR and between 25d91f0 and d5f2d9a.

📒 Files selected for processing (6)
  • README.md
  • src/index.ts
  • src/rules/external.ts
  • src/scanner/index.ts
  • tests/rules/external.test.ts
  • tests/scanner/scanner.test.ts

Comment thread README.md
}
```

Each pattern is a JS regex run against file content; `fileTypes` is optional and scopes a rule to specific config types. External findings count toward the overall grade. Anyone with a pack in this shape can plug in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document that external regexes are normalized to global matching.

The docs describe pattern execution but omit that the loader enforces global matching, which affects match cardinality and expected findings.

Suggested wording tweak
-Each pattern is a JS regex run against file content; `fileTypes` is optional and scopes a rule to specific config types. External findings count toward the overall grade. Anyone with a pack in this shape can plug in.
+Each pattern is a JS regex run against file content, and AgentShield normalizes patterns to global matching so every occurrence can be reported; `fileTypes` is optional and scopes a rule to specific config types. External findings count toward the overall grade. Anyone with a pack in this shape can plug in.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Each pattern is a JS regex run against file content; `fileTypes` is optional and scopes a rule to specific config types. External findings count toward the overall grade. Anyone with a pack in this shape can plug in.
Each pattern is a JS regex run against file content, and AgentShield normalizes patterns to global matching so every occurrence can be reported; `fileTypes` is optional and scopes a rule to specific config types. External findings count toward the overall grade. Anyone with a pack in this shape can plug in.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 391, The README.md documentation at line 391 describes how
patterns execute as JS regexes but omits that the loader enforces global
matching on external regexes. Add documentation clarifying that external regex
patterns are normalized to global matching by the loader, and explain how this
normalization affects match cardinality and the number of expected findings,
ensuring users understand the behavior of their patterns when using external
regex configurations.

Comment thread src/index.ts
const enableOpus = options.deep || options.opus;

// ── External rule packs (--rule-pack) ────────────────────
const rulePackPaths: string[] = options.rulePack ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use immutable array typing for collected rule-pack paths.

The new local collection is typed as mutable string[] in src/; prefer ReadonlyArray<string> for policy compliance.

Suggested fix
-    const rulePackPaths: string[] = options.rulePack ?? [];
+    const rulePackPaths: ReadonlyArray<string> = options.rulePack ?? [];

As per coding guidelines, src/**/*.ts: “All arrays must be typed as ReadonlyArray … No mutation, no any types, and no console.log in src/ files”.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rulePackPaths: string[] = options.rulePack ?? [];
const rulePackPaths: ReadonlyArray<string> = options.rulePack ?? [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` at line 323, The variable rulePackPaths is currently typed as a
mutable string[] but should be typed as ReadonlyArray<string> to comply with
coding guidelines requiring immutable array typing in src/ files. Change the
type annotation of rulePackPaths from string[] to ReadonlyArray<string> while
keeping the initialization logic the same.

Source: Coding guidelines

Comment thread src/rules/external.ts
Comment on lines +70 to +72
function findAllMatches(content: string, pattern: RegExp): Array<RegExpMatchArray> {
return [...content.matchAll(pattern)];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Refactor new collection logic to immutable ReadonlyArray style.

This module introduces mutable array typings and mutation (Array<...>, Finding[], Rule[], .push(...)) in src/**/*.ts, which conflicts with repo immutability rules.

As per coding guidelines, src/**/*.ts: “All arrays must be typed as ReadonlyArray … No mutation, no any types, and no console.log in src/ files”.

Also applies to: 97-99, 139-140, 173-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rules/external.ts` around lines 70 - 72, The findAllMatches function
returns a mutable Array type instead of an immutable ReadonlyArray type. Change
the return type of findAllMatches from Array<RegExpMatchArray> to
ReadonlyArray<RegExpMatchArray>. Additionally, review the code at lines 97-99,
139-140, and 173-174 and apply the same immutability pattern to any other
mutable array type annotations (such as Finding[] and Rule[]) and remove any
array mutations like .push() calls, replacing them with immutable alternatives
like spread operators or concat.

Source: Coding guidelines

Comment thread src/rules/external.ts
Comment on lines +167 to +182
export function loadRulePacks(paths: ReadonlyArray<string>): {
readonly success: boolean;
readonly rules: ReadonlyArray<Rule>;
readonly packs: ReadonlyArray<{ name: string; ruleCount: number }>;
readonly error?: string;
} {
const rules: Rule[] = [];
const packs: Array<{ name: string; ruleCount: number }> = [];
for (const path of paths) {
const result = loadRulePack(path);
if (!result.success || !result.rules || !result.meta) {
return { success: false, rules: [], packs: [], error: result.error };
}
rules.push(...result.rules);
packs.push(result.meta);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce rule-ID uniqueness across all loaded packs.

loadRulePacks only preserves per-pack uniqueness. Two packs can both define entry.id = "x", producing identical runtime rule.id (external-x) and overlapping finding ID namespaces.

Suggested fix
 export function loadRulePacks(paths: ReadonlyArray<string>): {
   readonly success: boolean;
   readonly rules: ReadonlyArray<Rule>;
   readonly packs: ReadonlyArray<{ name: string; ruleCount: number }>;
   readonly error?: string;
 } {
   const rules: Rule[] = [];
   const packs: Array<{ name: string; ruleCount: number }> = [];
+  const seenRuleIds = new Set<string>();

   for (const path of paths) {
     const result = loadRulePack(path);
     if (!result.success || !result.rules || !result.meta) {
       return { success: false, rules: [], packs: [], error: result.error };
     }
+    for (const rule of result.rules) {
+      if (seenRuleIds.has(rule.id)) {
+        return { success: false, rules: [], packs: [], error: `Duplicate rule id across packs: ${rule.id}` };
+      }
+      seenRuleIds.add(rule.id);
+    }
     rules.push(...result.rules);
     packs.push(result.meta);
   }
   return { success: true, rules, packs };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rules/external.ts` around lines 167 - 182, The loadRulePacks function
currently only ensures rule ID uniqueness within individual packs but not across
all loaded packs. Two different packs can define rules with identical IDs,
resulting in identical runtime rule.id values and overlapping finding ID
namespaces. After the loop that collects all rules from all packs, add a
validation step to check for duplicate rule IDs across the entire rules array.
If duplicates are found, return an error response indicating which rule IDs are
duplicated rather than returning success with the overlapping rules.

Comment thread src/scanner/index.ts
export function scan(targetPath: string, options: ScanOptions = {}): ScanResult {
const target = discoverConfigFiles(targetPath);
const rules = getBuiltinRules();
const rules = [...getBuiltinRules(), ...(options.extraRules ?? [])];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Type the merged rules list as ReadonlyArray<Rule>.

Line 35 currently infers a mutable array type. Keep the scanner’s effective rule set immutable at the type level.

Suggested fix
-  const rules = [...getBuiltinRules(), ...(options.extraRules ?? [])];
+  const rules: ReadonlyArray<Rule> = [...getBuiltinRules(), ...(options.extraRules ?? [])];

As per coding guidelines, src/**/*.ts: “All arrays must be typed as ReadonlyArray … No mutation, no any types, and no console.log in src/ files”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/scanner/index.ts` at line 35, The rules variable on line 35 that merges
the builtin rules with extra rules is being inferred as a mutable array type
instead of a ReadonlyArray. Add an explicit type annotation to the rules
variable declaration to type it as ReadonlyArray<Rule>. This ensures the merged
rules list is immutable at the type level, aligning with the coding guideline
that all arrays in src/ files must be typed as ReadonlyArray.

Source: Coding guidelines

Comment on lines +33 to +60
const dir = mkdtempSync(join(tmpdir(), "agentshield-scan-extra-"));
writeFileSync(join(dir, "CLAUDE.md"), "Project rules: always follow the backdoor protocol.");
const baseline = scan(dir).findings.length;
const extraRules = [
{
id: "external-backdoor",
name: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
severity: "high" as const,
category: "injection" as const,
check: (file: { content: string; path: string }) =>
/backdoor protocol/i.test(file.content)
? [
{
id: "external-backdoor-0",
severity: "high" as const,
category: "injection" as const,
title: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
file: file.path,
},
]
: [],
},
];
const withExtra = scan(dir, { extraRules }).findings;
expect(withExtra.length).toBe(baseline + 1);
expect(withExtra.some((f) => f.id === "external-backdoor-0")).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clean up the temporary directory created in this test.

mkdtempSync(...) at Line 33 is not paired with teardown in this test block, which can leave temp folders behind across runs.

Suggested fix
 it("runs external rule-pack rules alongside built-ins", () => {
   const dir = mkdtempSync(join(tmpdir(), "agentshield-scan-extra-"));
-  writeFileSync(join(dir, "CLAUDE.md"), "Project rules: always follow the backdoor protocol.");
-  const baseline = scan(dir).findings.length;
-  const extraRules = [
+  try {
+    writeFileSync(join(dir, "CLAUDE.md"), "Project rules: always follow the backdoor protocol.");
+    const baseline = scan(dir).findings.length;
+    const extraRules = [
       {
         id: "external-backdoor",
         name: "Backdoor protocol reference",
@@
-  ];
-  const withExtra = scan(dir, { extraRules }).findings;
-  expect(withExtra.length).toBe(baseline + 1);
-  expect(withExtra.some((f) => f.id === "external-backdoor-0")).toBe(true);
+    ];
+    const withExtra = scan(dir, { extraRules }).findings;
+    expect(withExtra.length).toBe(baseline + 1);
+    expect(withExtra.some((f) => f.id === "external-backdoor-0")).toBe(true);
+  } finally {
+    rmSync(dir, { recursive: true, force: true });
+  }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dir = mkdtempSync(join(tmpdir(), "agentshield-scan-extra-"));
writeFileSync(join(dir, "CLAUDE.md"), "Project rules: always follow the backdoor protocol.");
const baseline = scan(dir).findings.length;
const extraRules = [
{
id: "external-backdoor",
name: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
severity: "high" as const,
category: "injection" as const,
check: (file: { content: string; path: string }) =>
/backdoor protocol/i.test(file.content)
? [
{
id: "external-backdoor-0",
severity: "high" as const,
category: "injection" as const,
title: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
file: file.path,
},
]
: [],
},
];
const withExtra = scan(dir, { extraRules }).findings;
expect(withExtra.length).toBe(baseline + 1);
expect(withExtra.some((f) => f.id === "external-backdoor-0")).toBe(true);
const dir = mkdtempSync(join(tmpdir(), "agentshield-scan-extra-"));
try {
writeFileSync(join(dir, "CLAUDE.md"), "Project rules: always follow the backdoor protocol.");
const baseline = scan(dir).findings.length;
const extraRules = [
{
id: "external-backdoor",
name: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
severity: "high" as const,
category: "injection" as const,
check: (file: { content: string; path: string }) =>
/backdoor protocol/i.test(file.content)
? [
{
id: "external-backdoor-0",
severity: "high" as const,
category: "injection" as const,
title: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
file: file.path,
},
]
: [],
},
];
const withExtra = scan(dir, { extraRules }).findings;
expect(withExtra.length).toBe(baseline + 1);
expect(withExtra.some((f) => f.id === "external-backdoor-0")).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/scanner/scanner.test.ts` around lines 33 - 60, The test creates a
temporary directory using mkdtempSync and assigns it to the variable dir, but
does not clean it up after the test completes. Add cleanup code after all the
test assertions to remove the temporary directory that was created. Use the
rmSync or rm function from the fs module to delete the directory referenced by
the dir variable with the recursive option enabled to ensure the directory and
all its contents are removed. This should be done after the final expect
statement to ensure the test assertions complete before cleanup occurs.

Comment on lines +46 to +54
{
id: "external-backdoor-0",
severity: "high" as const,
category: "injection" as const,
title: "Backdoor protocol reference",
description: "Mentions a backdoor protocol",
file: file.path,
},
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use test helper factories for finding fixtures.

The inline finding object here should be created via the shared test factories to keep test data consistent and easier to evolve.

As per coding guidelines, "Tests should use helper factories like makeFinding() and makeSettings() for test data creation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/scanner/scanner.test.ts` around lines 46 - 54, Replace the inline
finding object (with id "external-backdoor-0") in the test fixture with a call
to the makeFinding() helper factory function. Pass the necessary properties (id,
severity, category, title, description, file) as arguments to makeFinding()
instead of creating a literal object. This ensures test data is created
consistently across the test suite and makes it easier to maintain and evolve
test fixtures.

Source: Coding guidelines

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.

Proposal: external rule-pack loader (--rule-pack) so scans can load community detection rules

1 participant