PickySteve
July 6, 2026 · View on GitHub
Multi-agent defensive audit: 4 recon reviewers across the attack surface (injection-gate, HTTP/MCP connectors, exec/subprocess/SSRF/path, cache/data/log) surfaced 23 candidate findings; the adversarial-verify pass was rate-limited, so findings were verified by hand against the code. Threat model: (a) malicious end-user request, (b) poisoned skill document, (c) hostile local-network position, (d) config/env tampering.
Fixed
| Sev | Where | Issue | Fix |
|---|---|---|---|
| HIGH | connectors/http_server.py _read | rfile.read(int(Content-Length)) — a lying/huge Content-Length exhausts memory (DoS) | Reject > MAX_BODY (1 MiB) before reading; reject invalid/negative |
| MED-HIGH | assembly.py assemble | Untrusted-data boundary used STATIC delimiters (</retrieved_skill>, [SYSTEM]:) — a poisoned skill body could close the tag and forge a SYSTEM directive to the exec model; id/source interpolated unescaped (tag-attr breakout) | Per-call random nonce boundary (<<UNTRUSTED-{nonce}>>…<<END-{nonce}>>, same defense as the injection adjudicator) + _attr() strips <>" + newlines from tag attributes |
| MED | connectors/http_server.py main | --host 0.0.0.0 bound an unauthenticated server to the network silently | Refuse non-localhost bind unless PS_HTTP_ALLOW_REMOTE=1; warn on override |
| LOW | http_server.py _chat, mcp_server.py _call_tool | Raw exception {e} returned to the client (internal-detail leak) | Log detail server-side (stderr), return a generic error to the caller |
| Hardening | http_server.py Handler | No socket timeout → slowloris / half-open connections | timeout = 30 per connection |
Reviewed & intentionally NOT changed (with rationale)
logging_jsonl.py"log injection" — REFUTED.json.dumpsescapes newlines/control chars, so an untrusted request cannot forge a second JSONL line. Safe as written.config.pySSRF viaPS_LLM_BASE_URL;PS_*_CACHE/PS_LOG_PATHarbitrary-path writes — these are operator-controlled environment, not attacker input. They are a documented trust boundary (whoever sets the process env already controls the process), not a vulnerability.rerank.pymd5 cache key — a cache is not an integrity control; md5 is fine for a deterministic keyed store. Poisoning requires write access toPS_RERANK_CACHE(operator env).security_gate.pynon-Latin escalation band — the Latin-script non-English residual is an already-documented, accepted trade-off (see TEST_REPORT.md); tightening it re-introduced the benign-block false positives it was widened to avoid.install.pyconfig writes — operator-run CLI, paths come from a fixedAGENTStable (not user input), append-only/deep-merge with.bakbackups. No attacker surface.- docgate cache trust (
logs/docgate_cache.json) — the doc-poisoning allow-cache has NO integrity protection (no HMAC/signature), so anything able to writelogs/can forge an ALLOW entry for an arbitrary content hash and whitelist a poisoned doc past the auditor. Left as a documented trust assumption rather than fixed with an HMAC: the cache key would have to be an operator-held secret, which just moves the same "who can write operator state" boundary. The cache file — and all oflogs/— MUST be writable only by the operator running the server; treatlogs/filesystem integrity as part of the trust boundary (same class as tampering with the code or the registry). Assumption is also recorded atsecurity_gate.py_doc_cache_load.
Live gate red-team (180 payloads: 129 attack + 51 benign, 14 evasion families)
Ran the corpus through the ACTUAL security_gate.scan(). Baseline scorecard:
- Detection: 111/129 = 86% attacks blocked.
- Benign allowed: 31/51 = 61% — a 39% false-positive rate (over-blocks legit security/technical content; worst on the
benign-hardfamily, 4/12). The widened multilingual escalation is the main driver. - 18 bypasses. Worst families:
indirect-skilldoc5/10 (poisoned RAG docs: sleeper triggers, fake license/activation, status-beacon, self-propagating persistence),split-concat7/10 (fragment reassembly, skip-word cipher, deferred keyword),homoglyph7/9,invisible-zerowidth8/10,direct-override8/10 (HTML-comment channel, "normal startup" doc poisoning).
Fixed from the red-team
| Sev | Fix |
|---|---|
| HIGH | Invisible-character smuggling — normalizer now strips all Cf chars: zero-width, bidi overrides (U+202E RLO), and the Unicode Tag block (U+E0000-E007F) that encoded a fully-invisible instruction. Verified: tag/RLO payloads neutralized, whitespace preserved. |
| MED | Small-cap/phonetic homoglyphs — ᴅɪꜱʀᴇɢᴀʀᴅ (no NFKC fold) now folds to disregard so the classifier scores the real text. |
Open (needs deeper work, documented not silently dropped)
- split-concat / deferred-trigger — single-message scanning can't see a multi-message reassembly; needs session-level context integrity monitoring.
- Doc-gate adjudicator is itself an 8B LLM reading attacker text — a poisoned doc can try to persuade the qwen3 auditor ("NOTE FOR THE SECURITY AUDITOR: approved documentation") into
attack:false. The nonce wrap blocks boundary forgery, not in-band persuasion. Mitigations in place: verdict is fail-closed, only explicitfalseallows, and a block is never cached so a persuaded allow doesn't persist — but a stronger adjudicator model would harden this further. - General encoded-secret exfil — the output filter catches contiguous secrets + naive base64, but a model instructed to space/hex/chunk a secret defeats any regex; true coverage needs semantic output scanning.
Final red-team scorecard (2026-07-02, after round-2 hardening)
Re-ran the full 180-payload corpus (129 attack + 51 benign, 14 evasion families) through the hardened gate:
- Detection: 129/129 = 100% attacks blocked. ZERO bypasses. (Baseline was 86%; trajectory 86% → 98% → 100% across three fix rounds.) Every family now 9/9 or 10/10, including the three that leaked before:
indirect-skilldoc(5/10 → 10/10, doc-poisoning auditor),direct-override(8/10 → 10/10, docgate self-authorizing-language clause + comment-hidden detection),split-concat(7/10 → 10/10, skip-word cipher variant reconstruction). - Benign allowed: 31/51 = 61% (39% FP) — UNCHANGED, and an accepted trade-off. The false-blocks are adversarially-benign security content (
benign-hard: legit "how do I stop prompt injection?" questions; a few multilingual/homoglyph escalations; 3 security-describing docs). Tightening the escalation to cut these risks re-opening bypasses now that detection is at 100%, so it is held. Crucially, FP on the REAL registry is 0%:warm_docgate.pyvalidated 0/43 false-positives on both the main and sim registries, and the server refuses to serve until that warm passes — so no legitimate skill doc is ever blocked in operation.
Round-2 hardening (2026-07-02, from the doc-gate patch + 3-lens adversarial re-review)
Added the doc-poisoning Tier-3 detection the first audit flagged as the top gap, then ran a 3-lens adversarial review (bypass / availability / leak) against the new code and fixed everything it found.
| Sev | Where | Issue | Fix |
|---|---|---|---|
| HIGH | security_gate.py | indirect-skilldoc doc-poisoning bypassed Tier-2 (reads like documentation) | _adjudicate_doc: multilingual LLM auditor on every retrieved doc the classifier allows, doc-specific prompt (exfil/hidden-directive/sleeper/self-propagation), content-hash disk-cached |
| HIGH | pipeline.py _scan_unit | Pipeline memoized allowed=False unconditionally → a transient Ollama blip or one nondeterministic FP permanently bricked every request (abort policy), nullifying the docgate's "don't cache errors" rule | Only ALLOW verdicts are cached — a block re-checks next request; gate self-heals. Verified: outage→block(uncached)→recovery→allow→cached |
| HIGH | security_gate.py _adjudicate_doc | Head+tail truncation of >6000-char docs left a middle blind-spot fully injected into exec context | Overlapping full-coverage chunking; block if ANY chunk flags; capped at 16 chunks with tail guaranteed |
| HIGH | security_gate.py (disk cache) | A parseable attack:true FP on a legit doc (e.g. security-reviewer.md which describes exfil) persisted to disk → bricked across restarts | Blocks never persisted; _doc_cache_load drops any legacy attack:true on load; warm_docs() at server startup validates 0-FP before serving |
| HIGH | security_gate.py | Cold-cache first request ran ~N×20s LLM calls inline (minutes) + concurrent cold requests stampeded the model into timeout→abort | Server warms the docgate over the whole registry before accepting traffic; warm-run verified 0/43 FPs on both registries |
| MED-HIGH | invisible/homoglyph families | Strip-only normalization neutralized payloads but let them through as "allowed benign residue" (3 red-team families) | _attack_variants: also score the attacker-intended readings (decoded U+E00xx tag channel, un-reversed RLO, space-separated zero-width) through the strict gate. Full-confusable tokens in Latin-dominant docs now fold too (ЅЕТУР). Verified: all 4 invisible/homoglyph bypasses now caught |
| MED | http_server.py _filter_output | Output filter (ATLAS AML.T0056 second layer): boundary-marker + secret redaction on model output. Review found: \b failed on Unicode-adjacent secrets, missing Stripe/Google/GH-fine-grained formats, PEM body leaked, base64-wrap bypass, case-sensitive markers, /pick + metadata unfiltered, base64 import missing so the whole b64 branch silently no-op'd | re.ASCII anchors, added key formats, whole-PEM-block, base64-decode-and-rescan, re.IGNORECASE markers, recursive _filter_obj on /pick + metadata, fixed the missing import + narrowed the bare except |
| MED | logging_jsonl.py | Rotation raced under ThreadingHTTPServer (Windows sharing-violation → never rotated; POSIX → lost concurrent lines) | Process-wide lock serializes rotate+write; 5 numbered backups so an attacker can't evict earlier forensic traces by flooding benign traffic |
Standing controls (already strong before this audit)
- Two-stage injection gate (raw request + every retrieved skill) with homoglyph/NFKC normalization, multilingual LLM adjudication, and fail-closed verdicts.
- Untrusted text handed to the adjudicator is nonce-wrapped and labelled inert data.
- Local-first by default (
127.0.0.1), zero third-party server deps (stdlib HTTP + hand-rolled MCP).