2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
320 lines
14 KiB
Markdown
320 lines
14 KiB
Markdown
# Design — Multilingual Batch Scanner
|
||
|
||
> Built against SkillSpector v2.2.3. This contrib module has its own
|
||
> independent versioning; the upstream version is noted for compatibility
|
||
> reference only.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
CLI
|
||
│ python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 7
|
||
│
|
||
▼
|
||
batch_scan.py :: main()
|
||
├─ discover skills (recursive SKILL.md finder)
|
||
├─ detect language (Unicode script-ratio, per skill)
|
||
├─ create API pool (optional, 10-key scheduler)
|
||
├─ ThreadPoolExecutor(max_workers=N)
|
||
│ ├─ Thread A: skill_1 → graph.invoke() + gap-fill
|
||
│ ├─ Thread B: skill_2 → graph.invoke() + gap-fill
|
||
│ └─ ...
|
||
├─ collect results, sort by risk score
|
||
└─ report (terminal / JSON / Markdown)
|
||
```
|
||
|
||
### Per-skill flow
|
||
|
||
```
|
||
run_one(skill_dir)
|
||
├─ scan_state() # build initial LangGraph state
|
||
├─ graph.invoke(state) # upstream pipeline (unchanged)
|
||
│ ├─ build_context # file cache, manifest
|
||
│ ├─ 20 analyzers # fan-out (15 static + 5 LLM)
|
||
│ └─ meta_analyzer # LLM verification + enrich
|
||
├─ entry_from_result() # extract + annotate
|
||
└─ cleanup_result() # shutil.rmtree → subprocess fallback
|
||
```
|
||
|
||
## Three-layer concurrency
|
||
|
||
```
|
||
Layer 3 — batch_scan.py: ThreadPoolExecutor(max_workers=N) [CONTRIB]
|
||
Layer 2 — llm_analyzer_base: asyncio.Semaphore(10) [UPSTREAM]
|
||
Layer 1 — graph.py: 20 analyzers fan-out [UPSTREAM]
|
||
```
|
||
|
||
Each layer is unaware of the others. The graph doesn't know it's being called
|
||
concurrently; the workers don't know the graph fans out internally.
|
||
|
||
## Why ThreadPoolExecutor
|
||
|
||
- ProcessPoolExecutor hangs on macOS (spawn mode reimports LangGraph per child)
|
||
- `graph.invoke()` is a pure function — same state → same result, no shared state
|
||
- Each thread operates on its own state dict, isolated from other threads
|
||
|
||
## DeepSeek compatibility patches
|
||
|
||
Call ``setup_deepseek_compat()`` before any LLM activity to apply seven targeted
|
||
monkey-patches. The patches are applied explicitly (not at import time) via a
|
||
context manager that restores originals on exit. Nesting is tracked internally
|
||
— only the outermost exit restores.
|
||
|
||
| # | Target | Mechanism | Why |
|
||
|---|--------|-----------|-----|
|
||
| 1 | `LLMAnalyzerBase.__init__` | `self.response_schema = None` (instance attr) | Disable structured output; instance-isolated |
|
||
| 2 | `LLMAnalyzerBase.parse_response` | `json.loads` → Pydantic validate | Handle raw string (no `response_format`) |
|
||
| 3 | `LLMMetaAnalyzer.parse_response` | Same + sanitize null/`"none"` | LLM output quirks |
|
||
| 4 | `LLMAnalyzerBase.build_prompt` | Append JSON output instruction | Model needs format hint |
|
||
| 5 | `LLMMetaAnalyzer.build_prompt` | Same | Same |
|
||
| 6 | `ChatOpenAI.__init__` | `httpx.Timeout(connect=8s, read=30s)` | Prevent hung connections |
|
||
| 7 | `asyncio.run` | Exception handler: drop `Event loop is closed` | Suppress cleanup noise |
|
||
|
||
### Why instance attributes (Patch 1 is the key insight)
|
||
|
||
The original approach mutated `LLMAnalyzerBase.response_schema` (class attribute,
|
||
shared by all threads). Race: Thread A restores the original value while
|
||
Thread B is still creating instances → `with_structured_output()` fires → 400.
|
||
|
||
The fix: `self.response_schema = None` writes to the instance `__dict__`.
|
||
Python MRO finds the instance attribute before the class attribute. Each
|
||
analyzer instance gets its own `None` — zero shared state, zero races.
|
||
|
||
### Why `ChatOpenAI.__init__` (Patch 6 pipeline)
|
||
|
||
httpx defaults: `connect=5.0`, `read=None` (infinite). A TCP connection that
|
||
is accepted but never sends a response byte blocks the worker thread forever.
|
||
ThreadPoolExecutor cannot kill threads.
|
||
|
||
The fix injects `httpx.Timeout` via the `timeout` Pydantic alias **before**
|
||
the internal OpenAI client is cached. `ChatOpenAI`'s Pydantic model defines
|
||
`request_timeout` as the canonical field name with `timeout` as its alias
|
||
(`populate_by_name=True`). When both the alias and canonical name appear in
|
||
`**kwargs`, Pydantic v2 prefers the alias — so we overwrite `kwargs["timeout"]`
|
||
directly rather than setting `kwargs["request_timeout"]`. This ensures the
|
||
``httpx.Timeout(connect=8s, read=30s)` value flows into every `root_client`
|
||
and `async_client` from their first instantiation.
|
||
|
||
## DeepSeek compatibility
|
||
|
||
DeepSeek's API does not support `response_format` (structured output).
|
||
Upstream calls `with_structured_output()` unconditionally. Without patches,
|
||
this returns HTTP 400, corrupting the httpx connection pool.
|
||
|
||
The fix chain:
|
||
1. Patch 1 disables `with_structured_output()` → raw text responses
|
||
2. Patches 4/5 append JSON format instructions to every prompt
|
||
3. Patches 2/3 parse raw JSON strings manually with Pydantic validation
|
||
|
||
## Language detection
|
||
|
||
Unicode script-ratio heuristic, zero additional dependencies (uses `unicodedata`
|
||
from stdlib, already imported by upstream).
|
||
|
||
```
|
||
CJK Unified (0x4E00–0x9FFF) → zh (≥10% of alpha chars)
|
||
Hiragana + Katakana → ja (≥5%)
|
||
Hangul Syllables (0xAC00–0xD7AF) → ko (≥10%)
|
||
Otherwise → en
|
||
```
|
||
|
||
Aggregated per file by majority vote. Known limitation: Japanese text with
|
||
high kanji and low kana density misclassifies as Chinese.
|
||
|
||
## Gap-fill
|
||
|
||
When a skill is non-English, 25 English-keyword static rules lose recall.
|
||
17 are covered by SSD/SDI/SQP (semantic analyzers). 8 have no equivalent:
|
||
|
||
**P5** (harmful content), **P6–P8** (system prompt leakage),
|
||
**MP1–MP3** (memory poisoning), **RA1–RA2** (rogue agent).
|
||
|
||
`GapFillAnalyzer` extends `LLMAnalyzerBase` with a language-aware prompt,
|
||
runs via `ApiKeyPool` for key failover, and appends findings to the graph result.
|
||
|
||
## API Pool
|
||
|
||
Call ``set_api_pool(pool)`` before scanning to route **all** LLM calls — both
|
||
graph-internal analyzers (SSD/SDI/SQP/meta, 20 per skill) and the gap-fill pass —
|
||
through a shared key pool. ``set_api_pool(None)`` restores the original factory.
|
||
|
||
Kubernetes-scheduler-inspired design:
|
||
|
||
```
|
||
acquire → pick least-loaded idle key
|
||
release(success=True) → mark idle
|
||
release(success=False) → mark rate_limited, backoff 30s × 2^n (cap 300s)
|
||
acquire after 429 → picks different key automatically
|
||
```
|
||
|
||
The pool is created once and passed to ``set_api_pool()``, which patches both
|
||
``skillspector.llm_utils.get_chat_model`` **and**
|
||
``skillspector.llm_analyzer_base.get_chat_model`` — the latter is necessary
|
||
because ``llm_analyzer_base`` imports ``get_chat_model`` via ``from ... import``
|
||
at module level, creating a local reference that a single-module patch would
|
||
miss. Without the dual patch, graph-internal analyzers (95% of LLM calls)
|
||
bypass the pool entirely. ``test_pool_wiring.py`` verifies all three call paths
|
||
are wired: ``llm_utils``, ``LLMAnalyzerBase._llm``, and ``GapFillAnalyzer.chat_model``.
|
||
|
||
## cleanup_result resilience
|
||
|
||
```python
|
||
try:
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
except Exception:
|
||
subprocess.run(["rm", "-rf", temp_dir], timeout=10, capture_output=True)
|
||
```
|
||
|
||
`shutil.rmtree` blocks on macOS when the directory contains files with
|
||
dangling fd (e.g., from corrupted httpx connections). The subprocess
|
||
fallback runs outside the Python process and is unaffected. Platform
|
||
detection (`os.name`) selects `rm -rf` on Unix or `rmdir /s /q` on
|
||
Windows.
|
||
|
||
## Per-skill timeout (90s)
|
||
|
||
A skill that takes >90s is marked TIMEOUT and skipped. Other workers continue.
|
||
HTTP-level timeouts (Patch 6) prevent most hangs from reaching the 90s ceiling.
|
||
|
||
## Exit codes
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| 0 | All safe |
|
||
| 1 | ≥1 skill HIGH or CRITICAL |
|
||
| 2 | Scan errors |
|
||
|
||
## File layout
|
||
|
||
```
|
||
contrib/batch_scan/
|
||
├── __init__.py # package init + dotenv preload
|
||
├── batch_scan.py # CLI + ThreadPoolExecutor
|
||
├── runner.py # graph wrapper + setup_deepseek_compat()
|
||
├── discovery.py # SKILL.md finder
|
||
├── detection.py # language detection
|
||
├── annotation.py # finding compatibility labels
|
||
├── gap_fill.py # GapFillAnalyzer
|
||
├── api_pool.py # ApiKeyPool + PooledChatModel + set_api_pool()
|
||
├── reports.py # Terminal / JSON / Markdown
|
||
├── .env.example # configuration template
|
||
├── CONTRIBUTING.md # dev setup, testing, code conventions
|
||
├── tests/
|
||
│ ├── test_pool_wiring.py
|
||
│ ├── test_monkeypatch_invasiveness.py
|
||
│ ├── test_monkeypatch_fragility.py
|
||
│ ├── tests-pro/ # 120 unit tests (4 modules)
|
||
│ └── docs/ # TEST_DESIGN, TEST_GUIDE, BUGS_FOUND
|
||
└── docs/
|
||
├── README.md # user-facing guide
|
||
├── DESIGN.md # this file
|
||
├── REVIEW_RESPONSE.md
|
||
└── archive/ # deep dives, history, future work
|
||
```
|
||
|
||
## Rejected Alternatives
|
||
|
||
### Why ThreadPoolExecutor + asyncio, not full asyncio?
|
||
|
||
`graph.invoke(state)` is a synchronous blocking call. LangGraph's compiled
|
||
graph executes nodes sequentially and fans out analyzers internally — it does
|
||
not expose an async entry point. Replacing `graph.invoke()` with an async
|
||
equivalent would require modifying upstream's graph compilation, which violates
|
||
the zero-intrusion constraint.
|
||
|
||
The alternative — `asyncio.to_thread()` wrapping `graph.invoke()` inside an
|
||
async event loop — adds a scheduling layer without removing the thread-per-skill
|
||
requirement. It would also require all batch orchestration code to be async,
|
||
complicating the CLI layer (`argparse`, Rich console output) with no throughput
|
||
gain.
|
||
|
||
`ProcessPoolExecutor` was tested and rejected: macOS Python 3.13 `spawn` mode
|
||
reimports LangGraph + LangChain per child process, causing 30+ second startup
|
||
timeouts. `fork` mode is unavailable on macOS since Python 3.8.
|
||
|
||
### Why monkey-patch, not fork upstream?
|
||
|
||
Forking would create a permanent divergence. Every upstream release would
|
||
require rebasing and re-verifying. The monkey-patch approach keeps the contrib
|
||
module as a drop-in adapter: it tracks upstream automatically, and if upstream
|
||
adds a `response_schema` override (e.g., an env var `SKILLSPECTOR_RAW_LLM`),
|
||
the patches become no-ops and can be removed without code changes.
|
||
|
||
### Why 8 gap-fill rules, not a full second graph pass?
|
||
|
||
The 8 gap-fill rules (P5, P6-P8, MP1-MP3, RA1-RA2) are the intersection of:
|
||
|
||
1. **English-keyword dependency.** Each rule's static analyzer uses regex
|
||
patterns that match English text only (e.g., "print your system prompt",
|
||
"clear your memory", "you are no longer an assistant"). Non-English
|
||
text bypasses these patterns entirely.
|
||
2. **No semantic-analyzer equivalent.** SSD (semantic security discovery),
|
||
SDI (semantic developer intent), and SQP (semantic quality policy) cover
|
||
17 other English-keyword rules because those rules detect semantics (intent,
|
||
policy violation) rather than specific English phrases.
|
||
3. **LLM-solvable.** The 8 rules describe security concepts (harmful content,
|
||
memory manipulation, rogue persistence) that an LLM can recognize in any
|
||
language when given a targeted prompt.
|
||
|
||
The standard for inclusion is: the static regex is provably English-only (by
|
||
inspecting `static_patterns_*.py` source), and no semantic analyzer claims the
|
||
rule ID in its coverage set. Rules satisfying both criteria are gap-fill
|
||
candidates.
|
||
|
||
## Patch 2/3 Deep Dive: JSON Parse + Pydantic Validate
|
||
|
||
Patches 2 and 3 replace `LLMAnalyzerBase.parse_response` and
|
||
`LLMMetaAnalyzer.parse_response` respectively. Both follow the same pipeline:
|
||
|
||
```
|
||
raw LLM string → _strip_markdown_fences() → json.loads() → model_validate() → Finding objects
|
||
```
|
||
|
||
The two-step parse (stdlib `json.loads` then Pydantic `model_validate`) exists
|
||
because:
|
||
|
||
1. `json.loads` is fast, deterministic, and raises clear `JSONDecodeError` on
|
||
malformed output — we catch this and return `[]` (empty findings).
|
||
2. `model_validate` enforces the schema: required fields, literal enums,
|
||
confidence range, string length. Schema violations are caught and returned
|
||
as `[]` with a warning log.
|
||
|
||
**Error propagation:** If the LLM returns invalid JSON or schema-mismatched
|
||
output, the analyzer returns `[]` (no findings for that file). The scan
|
||
continues — a single malformed LLM response never blocks the pipeline.
|
||
The warning is logged at `WARNING` level so operators can monitor parse-failure
|
||
rates without sifting through debug logs.
|
||
|
||
Patch 3 adds a `_sanitize_meta_finding()` pass after validation to handle
|
||
known LLM quirks: `null` string fields → `""`, unrecognized enum values
|
||
(e.g., `"none"`) → `"low"`. These are applied post-validation because they
|
||
represent recoverable soft errors, not hard schema violations.
|
||
|
||
## Gap-Fill Rule Selection Criteria
|
||
|
||
The 25 English-keyword static rules in upstream SkillSpector are:
|
||
|
||
| Group | Rule IDs | Detection method |
|
||
|-------|----------|-----------------|
|
||
| Prompt injection | P1-P4 | English-keyword regex |
|
||
| Harmful content | **P5** | English-keyword regex |
|
||
| System prompt leakage | **P6-P8** | English-keyword regex |
|
||
| Data exfiltration | E1-E4 | English-keyword regex |
|
||
| Privilege escalation | PE1-PE3 | English-keyword regex |
|
||
| Excessive agency | EA1-EA4 | English-keyword regex |
|
||
| Output handling | OH1-OH3 | English-keyword regex |
|
||
| Trigger abuse | TR1-TR3 | English-keyword regex |
|
||
| Memory poisoning | **MP1-MP3** | English-keyword regex |
|
||
| Rogue agent | **RA1-RA2** | English-keyword regex |
|
||
|
||
SSD, SDI, and SQP (semantic analyzers) cover the semantic intent behind
|
||
P1-P4, E1-E4, PE1-PE3, EA1-EA4, OH1-OH3, and TR1-TR3 — 17 rules total.
|
||
The remaining 8 rules (P5, P6-P8, MP1-MP3, RA1-RA2) are flagged as
|
||
gap-fill targets because their static detectors rely on specific English
|
||
phrases (e.g., `r"(clear|erase|wipe|forget)\s+(your|my|the)\s+(memory|context|instructions)"`)
|
||
that have zero recall on non-English text.
|
||
|
||
---
|
||
|
||
**Next:** [README.md](README.md) — user guide & all commands · [REVIEW_RESPONSE.md](REVIEW_RESPONSE.md) — PR #100 review response · [CONTRIBUTING.md](../CONTRIBUTING.md) — dev setup
|