@lmzhen/dsh-evolution-core 0.26.26 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threat scanning for agent-authored memory and skill content.
|
|
3
|
+
*
|
|
4
|
+
* Ported as a small, dependency-free subset of Hermes Agent's
|
|
5
|
+
* `tools/threat_patterns.py` + hermes-claw `threats.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
|
|
8
|
+
* Blocking is reserved for shapes with no legitimate use in stored knowledge
|
|
9
|
+
* (prompt-injection phrasing, credential exfiltration, the invisible-character
|
|
10
|
+
* smuggling core). Typography and presentation characters that are legitimate
|
|
11
|
+
* in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
|
|
12
|
+
* hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
|
|
13
|
+
* REPORT findings: they stay visible to operators and tests but never reject a
|
|
14
|
+
* write. Blocking them turned every emoji into a security event.
|
|
15
|
+
*/
|
|
16
|
+
import { DEFAULT_THREAT_MAX_SCAN_CHARS } from "./constants.js";
|
|
17
|
+
import { clampedNumber } from "./numeric.js";
|
|
18
|
+
const FILLER = String.raw `(?:\w+\s+){0,8}`;
|
|
19
|
+
const PATTERNS = [
|
|
20
|
+
// Prompt injection / rule override.
|
|
21
|
+
{ label: 'prompt_injection_ignore', category: 'prompt_injection', scope: 'all', regex: new RegExp(String.raw `ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}instructions`, 'i') },
|
|
22
|
+
// P2-2 (v18): the FILLER between the quantifier and the noun closes
|
|
23
|
+
// "disregard all previous instructions" / "disregard any prior rules",
|
|
24
|
+
// which the bare form missed while its sibling `prompt_injection_ignore`
|
|
25
|
+
// already had it.
|
|
26
|
+
{ label: 'disregard_rules', category: 'prompt_injection', scope: 'all', regex: new RegExp(String.raw `disregard\s+${FILLER}(?:your|all|any)\s+${FILLER}(?:instructions|rules|guidelines)`, 'i') },
|
|
27
|
+
{ label: 'system_prompt_override', category: 'prompt_injection', scope: 'all', regex: /system\s+prompt\s+override/i },
|
|
28
|
+
{ label: 'bypass_restrictions', category: 'prompt_injection', scope: 'all', regex: /act\s+as\s+(?:if|though)\s+(?:you\s+)?(?:have\s+no|don'?t\s+have)\s+(?:restrictions?|limits?|rules)/i },
|
|
29
|
+
{ label: 'new_system_prompt', category: 'prompt_injection', scope: 'strict', regex: new RegExp(String.raw `new\s+${FILLER}system\s+${FILLER}prompt`, 'i') },
|
|
30
|
+
{ label: 'forget_everything', category: 'prompt_injection', scope: 'strict', regex: new RegExp(String.raw `forget\s+${FILLER}(?:everything|all)\s+${FILLER}(?:discussed|you\s+know)`, 'i') },
|
|
31
|
+
// Role hijacking / jailbreak-adjacent instructions.
|
|
32
|
+
{ label: 'role_hijack', category: 'role_hijacking', scope: 'context', regex: /you\s+are\s+now\s+(?:a|an|the|acting|playing|pretending)/i },
|
|
33
|
+
{ label: 'fake_update', category: 'role_hijacking', scope: 'context', regex: new RegExp(String.raw `you\s+have\s+been\s+${FILLER}(?:updated|upgraded|patched)\s+to`, 'i') },
|
|
34
|
+
{ label: 'identity_override', category: 'role_hijacking', scope: 'context', regex: /\bname\s+yourself\s+\w+/i },
|
|
35
|
+
{ label: 'remove_filters', category: 'role_hijacking', scope: 'context', regex: /(?:respond|answer|reply)\s+without\s+(?:restrictions?|limitations?|filters?|safety)/i },
|
|
36
|
+
// Deception and context exfiltration.
|
|
37
|
+
{ label: 'deception_hide', category: 'deception', scope: 'all', regex: new RegExp(String.raw `do\s+not\s+${FILLER}tell\s+${FILLER}the\s+user`, 'i') },
|
|
38
|
+
{ label: 'leak_system_prompt', category: 'deception', scope: 'context', regex: new RegExp(String.raw `output\s+${FILLER}(?:system|initial)\s+prompt`, 'i') },
|
|
39
|
+
{ label: 'context_exfil', category: 'exfiltration', scope: 'strict', regex: /(?:include|output|print|share)\s+(?:the\s+)?(?:conversation|chat\s+history|previous\s+messages|(?:full|entire)\s+context)/i },
|
|
40
|
+
// v22 (SEC-2): the gap may now cross newlines (`[\s\S]{0,512}?`, lazy for
|
|
41
|
+
// proximity) — the old `[^\n]{0,512}` form let a C2 instruction split its
|
|
42
|
+
// verb and URL across lines ("send the full report\n…\nto https://…"),
|
|
43
|
+
// which no in-scope pattern caught.
|
|
44
|
+
{ label: 'send_to_url', category: 'exfiltration', scope: 'strict', regex: /(?:send|post|upload|transmit)\s+[\s\S]{0,512}?\s+(?:to|at)\s+https?:\/\//i },
|
|
45
|
+
// Shell-based secret exfiltration. V8-13 (0.3.47): `\b` word boundaries —
|
|
46
|
+
// the bare `cat`/`curl`/`wget` substrings matched inside `concat`/`scurl`
|
|
47
|
+
// prose (`[^\n]{0,512}` can be empty), locking EVERY write; the C2 table
|
|
48
|
+
// next to this one already uses `\b` — this row now matches that discipline.
|
|
49
|
+
// v22 (SEC-2): same cross-line gap as send_to_url.
|
|
50
|
+
{ label: 'exfil_curl', category: 'exfiltration', scope: 'all', regex: /\bcurl\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i },
|
|
51
|
+
{ label: 'exfil_wget', category: 'exfiltration', scope: 'all', regex: /\bwget\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i },
|
|
52
|
+
// PLAN S4.3 (2026-09-16), audit P2-14: `.env` keeps its P3-22 `(?!\w)` guard
|
|
53
|
+
// (`.envrc` must not trigger — `r` is a word char) and gains a second guard
|
|
54
|
+
// so the DOCUMENTED template variants pass. Their next char after `.env` is
|
|
55
|
+
// a `.` — which the first lookahead never excluded — so the legitimate
|
|
56
|
+
// debugging read `cat .env.example 查看变量` was hard-refused. PLAN-R2 P2-3
|
|
57
|
+
// (2026-09-16): the suffix's old trailing `\b` only demanded a non-word
|
|
58
|
+
// char, so CONTINUATIONS of the template name inherited the exemption —
|
|
59
|
+
// `.env.example.local` (a real-world conventional name that may hold real
|
|
60
|
+
// values), `.env.sample-x`, `.env.example/…`. The terminator is now
|
|
61
|
+
// `(?=$|[\s"'])`: the example/sample suffix must END the path segment,
|
|
62
|
+
// closed by end-of-line, whitespace or a quote. Per-variant behavior:
|
|
63
|
+
// `cat .env` → blocked (tail is EOL/space: the second
|
|
64
|
+
// guard's `\.` never starts)
|
|
65
|
+
// `cat .env.local` → blocked (`.local` is real per-machine config:
|
|
66
|
+
// no example/sample suffix at a
|
|
67
|
+
// terminator)
|
|
68
|
+
// `cat .env.example` → passes (dotted `example` suffix closed by
|
|
69
|
+
// EOL/space/quote)
|
|
70
|
+
// `cat .env.sample` → passes (dotted `sample` suffix, same rule)
|
|
71
|
+
// `cat .env.local.example` → passes (`local.` middle segment, `example`
|
|
72
|
+
// at a terminator)
|
|
73
|
+
// `cat .env.example.local` → blocked (`example` is not at a terminator
|
|
74
|
+
// and `local` is no example/sample)
|
|
75
|
+
// `cat .env.sample-x` → blocked (`-` continues the segment)
|
|
76
|
+
// `cat .env.example/notes` → blocked (`/` continues into another path)
|
|
77
|
+
// `cat .env.example..` → blocked (a dangling dot is no terminator)
|
|
78
|
+
// `cat .env.exampleX` → blocked (unknown suffix spelling, like
|
|
79
|
+
// `.samples`; `X` is a word char so
|
|
80
|
+
// even the first guard trips)
|
|
81
|
+
// `cat .envrc` → no match (first guard, plus the `\b` on `cat`)
|
|
82
|
+
// (A bare `(?![\w.])` would be WRONG here: it frees `.env.local`, whose next
|
|
83
|
+
// char is a dot — hence the suffix-spelled exemption instead. The `[\w-]`
|
|
84
|
+
// segments cannot eat the trailing dot, so no nested-quantifier blowup.)
|
|
85
|
+
{ label: 'read_secrets', category: 'exfiltration', scope: 'all', regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)(?!\.(?:[\w-]+\.)*(?:example|sample)(?=$|[\s"']))|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i },
|
|
86
|
+
// Persistence / backdoor / harness-config tampering. V9-10 (0.3.51):
|
|
87
|
+
// `\b` word boundaries — the bare `authorized_keys` substring matched inside
|
|
88
|
+
// OTHER underscore-joined words (`unauthorized_keys`-style compounds);
|
|
89
|
+
// real references are `.ssh/authorized_keys` / `~/.ssh/authorized_keys`,
|
|
90
|
+
// where the word sits on its own boundary.
|
|
91
|
+
{ label: 'ssh_backdoor', category: 'persistence', scope: 'strict', regex: /\bauthorized_keys\b/i },
|
|
92
|
+
{ label: 'agent_config_mod', category: 'persistence', scope: 'strict', regex: /(?:update|modify|edit|write|change|append|add\s+to)\s+(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)/i },
|
|
93
|
+
{ label: 'hermes_env', category: 'persistence', scope: 'strict', regex: /\$?HOME\/\.hermes|~\/\.hermes|\.hermes\/\.env|%USERPROFILE%[\\/]\.hermes/i },
|
|
94
|
+
// C2 / promptware vocabulary.
|
|
95
|
+
{ label: 'c2_node_registration', category: 'c2_promptware', scope: 'context', regex: /register\s+(?:as\s+)?a?\s*node/i },
|
|
96
|
+
{ label: 'c2_heartbeat', category: 'c2_promptware', scope: 'context', regex: /(?:heartbeats?|beacon|check[\s-]?in)\s+(?:to|with)/i },
|
|
97
|
+
{ label: 'c2_task_pull', category: 'c2_promptware', scope: 'context', regex: /pull\s+(?:new\s+)?tasks?/i },
|
|
98
|
+
{ label: 'known_c2_framework', category: 'c2_promptware', scope: 'context', regex: /\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b/i },
|
|
99
|
+
// Hardcoded secrets.
|
|
100
|
+
{ label: 'hardcoded_secret', category: 'hardcoded_secrets', scope: 'strict', regex: /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][a-z0-9+/=_-]{20,}["']/i },
|
|
101
|
+
// P3-22 (v14): a JWT is `base64url.base64url.base64url` — the dot-separated
|
|
102
|
+
// alphabet is outside `hardcoded_secret`'s character class, so an embedded
|
|
103
|
+
// token of that form went unreported. Dedicated pattern instead of widening
|
|
104
|
+
// the generic class (which would raise false positives on dotted identifiers).
|
|
105
|
+
{ label: 'jwt_like_secret', category: 'hardcoded_secrets', scope: 'strict', regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
|
|
106
|
+
// v22 (SEC-3): ssh-keygen's DEFAULT format is OPENSSH, and EC/DSA/
|
|
107
|
+
// ENCRYPTED/PGP blocks are equally private material — the RSA-only form
|
|
108
|
+
// let every other header through (redact.ts has no private-key pattern
|
|
109
|
+
// either, so both layers missed it).
|
|
110
|
+
{ label: 'private_key_block', category: 'hardcoded_secrets', scope: 'all', regex: /-----BEGIN\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/ },
|
|
111
|
+
// v22 (SEC-5): Chinese-language injection / exfiltration. This harness's own
|
|
112
|
+
// conversations and stored memory are predominantly Chinese, and every
|
|
113
|
+
// pattern above is English-only — the classic Chinese jailbreak phrasing
|
|
114
|
+
// ("忽略之前所有指令…不要告诉用户…把对话记录发送到…") scored zero findings.
|
|
115
|
+
// Chinese has no inter-word spaces, so the English FILLER (`\w+\s+`) cannot
|
|
116
|
+
// apply; proximity gaps are tight `[\s\S]{0,N}` bounds instead. Blocking
|
|
117
|
+
// scopes mirror their English siblings.
|
|
118
|
+
{ label: 'prompt_injection_ignore_zh', category: 'prompt_injection', scope: 'all', regex: /(?:忽略|无视|抛开)[\s\S]{0,8}(?:之前|以上|上面|先前|以前)?[\s\S]{0,8}(?:所有|全部|任何)?[\s\S]{0,4}(?:指令|规则|设定|约束)/ },
|
|
119
|
+
{ label: 'deception_hide_zh', category: 'deception', scope: 'all', regex: /(?:不要|别|勿)(?:告诉|告知|透露)(?:给)?[\s\S]{0,6}(?:用户|任何人|主人|开发者)/ },
|
|
120
|
+
{ label: 'system_prompt_leak_zh', category: 'deception', scope: 'context', regex: /(?:泄露|输出|打印|透露|导出)[\s\S]{0,10}系统提示|系统提示[\s\S]{0,10}(?:泄露|透露|发送|导出)/ },
|
|
121
|
+
{ label: 'context_exfil_zh', category: 'exfiltration', scope: 'strict', regex: /(?:对话记录|聊天记录|全部上下文|完整上下文)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至|给)/ },
|
|
122
|
+
{ label: 'secret_exfil_zh', category: 'exfiltration', scope: 'strict', regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/ },
|
|
123
|
+
];
|
|
124
|
+
// P2-2 (v18) added an invisible/format set; P1-1 (v19) splits it by intent
|
|
125
|
+
// after the first cut rejected ordinary prose and emoji:
|
|
126
|
+
// - BLOCKING: the smuggling core. Zero-width breaks/joiners with no prose
|
|
127
|
+
// use (ZWSP, WORD JOINER, invisible operators, deprecated format
|
|
128
|
+
// controls, BOM), COMBINING GRAPHEME JOINER, and the astral TAG block —
|
|
129
|
+
// note the TAG range must live INSIDE the character class (P2-6: the v18
|
|
130
|
+
// form wrote it after the class, where it matched the literal text).
|
|
131
|
+
// - REPORT: typography/presentation characters that are smuggling vectors
|
|
132
|
+
// only when abused: soft hyphen (PDF paste), Arabic letter mark, Mongolian
|
|
133
|
+
// vowel separator, ZWNJ (Persian/Arabic typography), variation selectors
|
|
134
|
+
// VS1-16 (emoji/text presentation, e.g. ❤️).
|
|
135
|
+
// - ZWJ (U+200D) blocks only OUTSIDE an emoji sequence: 👨👩👧 is a joined
|
|
136
|
+
// sequence and must pass; a ZWJ between ordinary words must not.
|
|
137
|
+
// Homoglyphs (e.g. Cyrillic look-alikes) remain outside this detector by design.
|
|
138
|
+
const INVISIBLE_CHAR_CLASS = '\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}';
|
|
139
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[${INVISIBLE_CHAR_CLASS}]`, 'u');
|
|
140
|
+
const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
|
|
141
|
+
const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
|
|
142
|
+
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
143
|
+
// S0.4 (v37 P0-3): the splitter universe is the FULL format-control set, not the
|
|
144
|
+
// two hand-written groups above. Every Cf/Zl/Zp character (LRM/RLM, LS/PS, the
|
|
145
|
+
// interlinear annotations, the musical/tag ranges, NUL, ...) splits a payload
|
|
146
|
+
// phrase exactly like a zero-width space, so the reconstruction must be able to
|
|
147
|
+
// remove ANY of them — exempting a label may silence its finding, never blind the
|
|
148
|
+
// detector that finding protects. Findings stay as they are (blocking benign RTL
|
|
149
|
+
// typography would be a false-positive regression); the visibility half is the
|
|
150
|
+
// report-level `unicode_format_control` finding below. The explicit code points
|
|
151
|
+
// after the properties are the UNASSIGNED slots inside those same invisible
|
|
152
|
+
// ranges (U+2065, U+FFF0-FFF8, U+E0080-E00FF): a smuggler does not need an
|
|
153
|
+
// assigned character to split a word.
|
|
154
|
+
const FORMAT_CONTROL_CLASS = '\\p{Cf}\\p{Zl}\\p{Zp}\\u0000\\u2065\\ufff0-\\ufff8\\u{e0080}-\\u{e00ff}';
|
|
155
|
+
const FORMAT_CONTROL_TEST = new RegExp(`[${FORMAT_CONTROL_CLASS}]`, 'u');
|
|
156
|
+
/** S0.4 (v37 P0-3): a format control the three finding sets above do NOT report.
|
|
157
|
+
* S1-E3: the ZWJ exemption compares the real character. It used to read
|
|
158
|
+
* `'\\u200d'` — a 6-character literal that never equals a code point — so the
|
|
159
|
+
* branch was dead and every emoji ZWJ sequence (👨👩👧) raised the report-level
|
|
160
|
+
* `unicode_format_control` finding the class comment above promises it will not. */
|
|
161
|
+
function hasUnreportedFormatControl(text) {
|
|
162
|
+
for (const character of text) {
|
|
163
|
+
if (!FORMAT_CONTROL_TEST.test(character))
|
|
164
|
+
continue;
|
|
165
|
+
if (ZERO_WIDTH_CHARS.test(character) || TYPOGRAPHY_CHARS.test(character) || BIDI_CHARS.test(character))
|
|
166
|
+
continue;
|
|
167
|
+
if (character === '\u200d')
|
|
168
|
+
continue;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const SCOPE_ORDER = { all: 1, context: 2, strict: 3 };
|
|
174
|
+
const NO_SCAN_OPTIONS = {};
|
|
175
|
+
/** Minimum window size for the full-coverage scan (V6-05, 0.3.35). With the
|
|
176
|
+
* proportional half-window step below, the overlap is `ceil(w/2)` — only a
|
|
177
|
+
* window at or above this floor keeps the overlap above the longest pattern
|
|
178
|
+
* span (~530 chars: `curl [^\n]{0,512} ...`), so a match straddling a window
|
|
179
|
+
* boundary is fully inside at least one window (E-12, 0.3.16). The clamp
|
|
180
|
+
* falls back to the default for a smaller caller value instead of risking a
|
|
181
|
+
* blind zone. */
|
|
182
|
+
export const PATTERN_OVERLAP = 4096;
|
|
183
|
+
/**
|
|
184
|
+
* Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
|
|
185
|
+
* `options.excludeLabels` removes matching patterns without changing `scope`.
|
|
186
|
+
* `maxScanChars` is the WINDOW SIZE, not a total cap (E-12, 0.3.16): the whole
|
|
187
|
+
* text is always scanned in overlapping windows, so content beyond 65,536
|
|
188
|
+
* characters (skill files may run to 100,000) is no longer a blind zone.
|
|
189
|
+
*/
|
|
190
|
+
export function scanThreats(text, scope = 'strict', maxScanChars = DEFAULT_THREAT_MAX_SCAN_CHARS, options = NO_SCAN_OPTIONS) {
|
|
191
|
+
// V4-43 self-defense: a non-finite (NaN/±Infinity) or out-of-domain window
|
|
192
|
+
// size would fold the window loop into an empty first window (or a NaN spin)
|
|
193
|
+
// and make every in-scope pattern blind. Clamp to the default so an invalid
|
|
194
|
+
// caller value still scans; the config layer is the first-line guard, this is
|
|
195
|
+
// depth. V5-27: in-repo callers pass valid/clamped values (the threat
|
|
196
|
+
// package pre-clamps its config-derived value), so behavior is unchanged —
|
|
197
|
+
// but the clamp is the guarantee for any third-party caller, not a promise
|
|
198
|
+
// about call sites. V6-05: the floor is now PATTERN_OVERLAP + 1 — a window
|
|
199
|
+
// below it cannot guarantee full coverage (see PATTERN_OVERLAP).
|
|
200
|
+
const windowSize = clampedNumber(maxScanChars, DEFAULT_THREAT_MAX_SCAN_CHARS, { min: PATTERN_OVERLAP + 1 });
|
|
201
|
+
const findings = [];
|
|
202
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
203
|
+
// P3-22 (v14): unicode obfuscation is scope-INDEPENDENT by design — the
|
|
204
|
+
// smuggling core is never legitimate in stored knowledge, so it blocks even
|
|
205
|
+
// under `scope: 'all'`. The exemption surface still applies: a deployment
|
|
206
|
+
// that knows a label is benign can allowlist it like any other.
|
|
207
|
+
// P1-1 (v19): the typography set reports without blocking (see the class
|
|
208
|
+
// comment above); ZWJ is judged by context.
|
|
209
|
+
if ((ZERO_WIDTH_CHARS.test(text) || ZWJ_OUTSIDE_EMOJI.test(text)) && !excluded.has('unicode_zero_width')) {
|
|
210
|
+
findings.push({ label: 'unicode_zero_width', category: 'unicode_obfuscation', scope: 'all' });
|
|
211
|
+
}
|
|
212
|
+
if (TYPOGRAPHY_CHARS.test(text) && !excluded.has('unicode_typography')) {
|
|
213
|
+
findings.push({ label: 'unicode_typography', category: 'unicode_obfuscation', scope: 'all', severity: 'report' });
|
|
214
|
+
}
|
|
215
|
+
if (BIDI_CHARS.test(text) && !excluded.has('unicode_bidi_override')) {
|
|
216
|
+
findings.push({ label: 'unicode_bidi_override', category: 'unicode_obfuscation', scope: 'all' });
|
|
217
|
+
}
|
|
218
|
+
// S0.4 (v37 P0-3): report (never block) the format controls no set above names —
|
|
219
|
+
// the splitter universe already defuses the obfuscation, this only makes the
|
|
220
|
+
// attempt visible in the run report.
|
|
221
|
+
if (!excluded.has('unicode_format_control') && hasUnreportedFormatControl(text)) {
|
|
222
|
+
findings.push({ label: 'unicode_format_control', category: 'unicode_obfuscation', scope: 'all', severity: 'report' });
|
|
223
|
+
}
|
|
224
|
+
const normalized = text.normalize('NFKC');
|
|
225
|
+
// v22 (SEC-1): the report-level typography characters survive NFKC, and
|
|
226
|
+
// every pattern above matches literal words — one ZWNJ inside "ignore all"
|
|
227
|
+
// or one soft hyphen inside "do not tell the user" split every blocking
|
|
228
|
+
// regex while the only finding (unicode_typography, severity:'report') is
|
|
229
|
+
// non-blocking AND store-side consumers read the BLOCK verdict only: a
|
|
230
|
+
// one-character injection bypass. Patterns are scored against TWO
|
|
231
|
+
// reconstructed variants of the text (the original still drives the
|
|
232
|
+
// typography report above, so legitimate typography keeps passing):
|
|
233
|
+
// - SPACE variant: an inter-word splitter (ZWNJ between 忽略 words)
|
|
234
|
+
// becomes a word boundary ("ignore all");
|
|
235
|
+
// - STRIPPED variant: an intra-word splitter (soft hyphen in "n\u00ADot")
|
|
236
|
+
// rejoins the word — a space there would leave "n ot" unmatched.
|
|
237
|
+
const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
|
|
238
|
+
// V27 G-1 (P1): the reconstruction must cover the ZERO-WIDTH class too, not
|
|
239
|
+
// just the typography set. `unicode_zero_width` is the only rule that reports
|
|
240
|
+
// ZWSP/WJ/BOM/TAG, so exempting that single label used to be a complete
|
|
241
|
+
// bypass: the characters produced no finding (the guard was excluded) AND the
|
|
242
|
+
// pattern variants never removed them, so `ig<ZWSP>nore all previous
|
|
243
|
+
// instructions` matched no injection pattern at all. Exempting a label may
|
|
244
|
+
// silence its FINDING; it may never blind the detector that the finding
|
|
245
|
+
// protects. ZWJ rides along (its own report is context-sensitive) because a
|
|
246
|
+
// ZWJ between words splits a pattern just as effectively.
|
|
247
|
+
const OBFUSCATION_SPLITTERS = new RegExp(`[${FORMAT_CONTROL_CLASS}]`, 'gu');
|
|
248
|
+
const patternTexts = [
|
|
249
|
+
normalized.replace(SPACE_SPLITTERS, ' ').replace(OBFUSCATION_SPLITTERS, ' '),
|
|
250
|
+
normalized.replace(SPACE_SPLITTERS, '').replace(OBFUSCATION_SPLITTERS, ''),
|
|
251
|
+
];
|
|
252
|
+
const windows = [];
|
|
253
|
+
for (const patternText of patternTexts) {
|
|
254
|
+
if (patternText.length <= windowSize) {
|
|
255
|
+
windows.push(patternText);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
// V6-05 (0.3.35): proportional half-window step. Overlap = `ceil(w/2)`, so
|
|
259
|
+
// every position is covered by ~2 windows and the scan cost is O(n × 2)
|
|
260
|
+
// instead of the O(n × w) cliff the old fixed-overlap step produced for
|
|
261
|
+
// small windows (w ≤ PATTERN_OVERLAP collapsed the step to 1). A wider
|
|
262
|
+
// overlap never drops a match — findings are per label/scope, not per
|
|
263
|
+
// location.
|
|
264
|
+
const step = Math.max(Math.floor(windowSize / 2), 1);
|
|
265
|
+
for (let start = 0; start < patternText.length; start += step) {
|
|
266
|
+
windows.push(patternText.slice(start, start + windowSize));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
for (const window of windows) {
|
|
272
|
+
for (const pattern of PATTERNS) {
|
|
273
|
+
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope])
|
|
274
|
+
continue;
|
|
275
|
+
if (excluded.has(pattern.label))
|
|
276
|
+
continue;
|
|
277
|
+
if (!pattern.regex.test(window))
|
|
278
|
+
continue;
|
|
279
|
+
const key = `${pattern.label}|${pattern.scope}`;
|
|
280
|
+
if (seen.has(key))
|
|
281
|
+
continue;
|
|
282
|
+
seen.add(key);
|
|
283
|
+
findings.push({
|
|
284
|
+
label: pattern.label,
|
|
285
|
+
category: pattern.category,
|
|
286
|
+
scope: pattern.scope,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
292
|
+
/** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
|
|
293
|
+
* `report`-only. Pattern findings carry no severity and therefore block as
|
|
294
|
+
* before. */
|
|
295
|
+
export function evaluateThreat(text, scope = 'strict', maxScanChars = DEFAULT_THREAT_MAX_SCAN_CHARS, options = NO_SCAN_OPTIONS) {
|
|
296
|
+
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
297
|
+
return { blocked: findings.some(finding => finding.severity !== 'report'), findings };
|
|
298
|
+
}
|
|
299
|
+
/** User-facing block message for memory writes. */
|
|
300
|
+
export function scanMemoryThreats(text, maxScanChars = DEFAULT_THREAT_MAX_SCAN_CHARS, options = NO_SCAN_OPTIONS) {
|
|
301
|
+
const { blocked, findings } = evaluateThreat(text, 'strict', maxScanChars, options);
|
|
302
|
+
if (!blocked)
|
|
303
|
+
return null;
|
|
304
|
+
const pattern = findings.find(f => f.category !== 'unicode_obfuscation');
|
|
305
|
+
// WD2 (0.3.56): every user-facing block carries the exemption surface — a
|
|
306
|
+
// legitimate DevOps/skill phrase must end in a next step, not a dead end.
|
|
307
|
+
if (pattern)
|
|
308
|
+
return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${THREAT_EXEMPTION_HINT}`;
|
|
309
|
+
return `Blocked by security scan: invisible or potentially malicious Unicode detected.${THREAT_EXEMPTION_HINT}`;
|
|
310
|
+
}
|
|
311
|
+
/** User-facing block message for skill content writes. */
|
|
312
|
+
export function scanContentThreats(text, maxScanChars = DEFAULT_THREAT_MAX_SCAN_CHARS, options = NO_SCAN_OPTIONS) {
|
|
313
|
+
const { blocked, findings } = evaluateThreat(text, 'strict', maxScanChars, options);
|
|
314
|
+
if (!blocked)
|
|
315
|
+
return null;
|
|
316
|
+
// OPT-11 (2026-09): name an actual BLOCKING pattern, not `findings[0]` —
|
|
317
|
+
// scanThreats pushes the `severity: 'report'` unicode findings FIRST, so
|
|
318
|
+
// content with a soft hyphen plus a real injection phrase used to be told
|
|
319
|
+
// it was blocked by `unicode_typography`. Exempting that label (the hint's
|
|
320
|
+
// self-heal path) then did nothing — the operator only met the real label
|
|
321
|
+
// on a second attempt. The memory variant already filtered this way.
|
|
322
|
+
const pattern = findings.find(f => f.category !== 'unicode_obfuscation');
|
|
323
|
+
if (pattern)
|
|
324
|
+
return `Blocked by security scan (${pattern.label}). This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
|
|
325
|
+
return `Blocked by security scan: invisible or potentially malicious Unicode detected. This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* WD2 (0.3.56): the shared tail of every user-facing threat block — names the
|
|
329
|
+
* deployable self-heal path so the model (or operator) can allowlist a
|
|
330
|
+
* known-benign label. v20 (B-2) single source: the scan builders embed this
|
|
331
|
+
* constant verbatim; do NOT re-word it per call site (the three former copies
|
|
332
|
+
* — memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
|
|
333
|
+
* whose docblock still claimed a store-side append that memory-store/skill-store
|
|
334
|
+
* had already removed — had drifted apart). The evolution-threat guard channel
|
|
335
|
+
* returns the scan message verbatim, so the hint rides along there too.
|
|
336
|
+
*/
|
|
337
|
+
export const THREAT_EXEMPTION_HINT = ' A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).';
|
|
338
|
+
//# sourceMappingURL=threats.js.map
|
|
@@ -53,6 +53,11 @@ export declare const NATIVE_RESULT_EVENT = "tool/result";
|
|
|
53
53
|
* against these literals itself (rule N11); it compares \`kind\` instead.
|
|
54
54
|
*/
|
|
55
55
|
export declare const DISPATCH_EVENT_TYPES: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* Model-visible session frames: the message types plus the ONE dispatch vocabulary above.
|
|
58
|
+
* Consumers classify through this list instead of naming event literals (rule N11).
|
|
59
|
+
*/
|
|
60
|
+
export declare const MODEL_SESSION_EVENT_TYPES: readonly string[];
|
|
56
61
|
/**
|
|
57
62
|
* One normalized tool dispatch.
|
|
58
63
|
*
|