@aimarket/warden 0.5.0 → 0.7.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README-es.md +14 -6
  3. package/README-fr.md +14 -6
  4. package/README-ru.md +14 -6
  5. package/README-zh.md +14 -6
  6. package/README.md +14 -6
  7. package/dist/fold.d.ts +38 -0
  8. package/dist/fold.d.ts.map +1 -0
  9. package/dist/fold.js +99 -0
  10. package/dist/fold.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +8 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/mcp-rpc.d.ts +3 -1
  16. package/dist/mcp-rpc.d.ts.map +1 -1
  17. package/dist/mcp-rpc.js +32 -8
  18. package/dist/mcp-rpc.js.map +1 -1
  19. package/dist/mcp-server.d.ts +7 -2
  20. package/dist/mcp-server.d.ts.map +1 -1
  21. package/dist/mcp-server.js +43 -49
  22. package/dist/mcp-server.js.map +1 -1
  23. package/dist/mcp-tools.d.ts +0 -12
  24. package/dist/mcp-tools.d.ts.map +1 -1
  25. package/dist/mcp-tools.js +104 -23
  26. package/dist/mcp-tools.js.map +1 -1
  27. package/dist/pin-store.d.ts +13 -0
  28. package/dist/pin-store.d.ts.map +1 -0
  29. package/dist/pin-store.js +44 -0
  30. package/dist/pin-store.js.map +1 -0
  31. package/dist/pinning.d.ts +4 -3
  32. package/dist/pinning.d.ts.map +1 -1
  33. package/dist/pinning.js +16 -3
  34. package/dist/pinning.js.map +1 -1
  35. package/dist/sanitize.d.ts.map +1 -1
  36. package/dist/sanitize.js +6 -3
  37. package/dist/sanitize.js.map +1 -1
  38. package/dist/state.d.ts +7 -0
  39. package/dist/state.d.ts.map +1 -0
  40. package/dist/state.js +61 -0
  41. package/dist/state.js.map +1 -0
  42. package/dist/static-scan.d.ts +15 -2
  43. package/dist/static-scan.d.ts.map +1 -1
  44. package/dist/static-scan.js +300 -25
  45. package/dist/static-scan.js.map +1 -1
  46. package/dist/threat-feed.d.ts +9 -0
  47. package/dist/threat-feed.d.ts.map +1 -1
  48. package/dist/threat-feed.js +50 -7
  49. package/dist/threat-feed.js.map +1 -1
  50. package/dist/types.d.ts +9 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/docs/security-hardening.es.md +40 -0
  53. package/docs/security-hardening.fr.md +40 -0
  54. package/docs/security-hardening.md +40 -0
  55. package/docs/security-hardening.ru.md +40 -0
  56. package/docs/security-hardening.zh.md +40 -0
  57. package/package.json +3 -2
package/dist/state.js ADDED
@@ -0,0 +1,61 @@
1
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ export function defaultStateDir() {
6
+ return process.env.WARDEN_STATE_DIR || join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'warden');
7
+ }
8
+ export async function readState(path) {
9
+ try {
10
+ return JSON.parse(await readFile(path, 'utf8'));
11
+ }
12
+ catch (err) {
13
+ if (err.code === 'ENOENT')
14
+ return undefined;
15
+ throw err;
16
+ }
17
+ }
18
+ /** Atomic replace; a failed write never destroys the previous approval/snapshot. */
19
+ export async function writeState(path, value) {
20
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
21
+ const temp = `${path}.${randomUUID()}.tmp`;
22
+ try {
23
+ const file = await open(temp, 'wx', 0o600);
24
+ try {
25
+ await file.writeFile(JSON.stringify(value));
26
+ await file.sync();
27
+ }
28
+ finally {
29
+ await file.close();
30
+ }
31
+ await rename(temp, path);
32
+ }
33
+ finally {
34
+ await unlink(temp).catch(() => { });
35
+ }
36
+ }
37
+ /** Serialize read/compare/write across processes. A leftover lock fails closed. */
38
+ export async function withStateLock(path, action) {
39
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
40
+ const lock = `${path}.lock`;
41
+ let file;
42
+ for (let attempt = 0;; attempt++) {
43
+ try {
44
+ file = await open(lock, 'wx', 0o600);
45
+ break;
46
+ }
47
+ catch (err) {
48
+ if (err.code !== 'EEXIST' || attempt >= 100)
49
+ throw err;
50
+ await new Promise(resolve => setTimeout(resolve, 20));
51
+ }
52
+ }
53
+ try {
54
+ return await action();
55
+ }
56
+ finally {
57
+ await file.close();
58
+ await unlink(lock);
59
+ }
60
+ }
61
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.js","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,OAAO,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC1H,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,IAAY;IAC7C,IAAI,CAAC;QAAC,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAM,CAAC;IAAC,CAAC;IAC7D,OAAO,GAAG,EAAE,CAAC;QAAC,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAAC,MAAM,GAAG,CAAC;IAAC,CAAC;AACpG,CAAC;AAED,oFAAoF;AACpF,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,KAAc;IAC3D,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7D,MAAM,IAAI,GAAG,GAAG,IAAI,IAAI,UAAU,EAAE,MAAM,CAAC;IAC3C,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAC3C,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;YAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;QAAC,CAAC;gBAC/D,CAAC;YAAC,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;QAAC,CAAC;QAC/B,MAAM,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC3B,CAAC;YAAS,CAAC;QAAC,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IAAC,CAAC;AACnD,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,KAAK,UAAU,aAAa,CAAI,IAAY,EAAE,MAAwB;IAC3E,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7D,MAAM,IAAI,GAAG,GAAG,IAAI,OAAO,CAAC;IAC5B,IAAI,IAAI,CAAC;IACT,KAAK,IAAI,OAAO,GAAG,CAAC,GAAI,OAAO,EAAE,EAAE,CAAC;QAClC,IAAI,CAAC;YAAC,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;YAAC,MAAM;QAAC,CAAC;QACpD,OAAO,GAAG,EAAE,CAAC;YACX,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,IAAI,GAAG;gBAAE,MAAM,GAAG,CAAC;YAClF,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;QACxD,CAAC;IACH,CAAC;IACD,IAAI,CAAC;QAAC,OAAO,MAAM,MAAM,EAAE,CAAC;IAAC,CAAC;YACtB,CAAC;QAAC,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;QAAC,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IAAC,CAAC;AACrD,CAAC"}
@@ -37,7 +37,7 @@ type Tier = "block" | "advise";
37
37
  * characters and a base64 blob could sit in the one field that reaches the model
38
38
  * first and WARDEN reported nothing.
39
39
  */
40
- type Surface = "name" | "description" | "inputSchema";
40
+ type Surface = "name" | "description" | "inputSchema" | "title" | "outputSchema" | "annotations" | "metadata";
41
41
  /**
42
42
  * Ruleset version. Bump on ANY change to the table above — a scan result is only
43
43
  * comparable to another scan made under the same version and digest.
@@ -59,8 +59,17 @@ type Surface = "name" | "description" | "inputSchema";
59
59
  * selecting for honest servers: the bare `exfiltrat*` noun, `system prompt`,
60
60
  * `do not tell the user`, and (in severity only) the credential nouns. A
61
61
  * rule's guards are part of this table and therefore of the digest.
62
+ * 5 — language-independent coverage. Text is FOLDED before matching (see ./fold.ts, FOLD_ID in
63
+ * the digest): NFKC, Unicode-tag decoding, invisible-character stripping and mixed-script
64
+ * look-alike mapping, so an English rule cannot be defeated by spelling the words in
65
+ * fullwidth, with a zero-width space inside a word, in tag characters, or with a Cyrillic
66
+ * "о". A new TOOL_DEF_SECRET_EXFIL rule blocks on the language-independent PAIR of a secret
67
+ * store and an external address in one definition. Three field false positives are guarded
68
+ * out: "send the user to <url>" (a redirect, `navigation`), "without asking the user" in
69
+ * autonomy phrasing (`autonomy`), and the zero-width joiner inside an emoji sequence. The
70
+ * hidden-character rule now also catches the Unicode-tag block and the bidi isolates.
62
71
  */
63
- export declare const STATIC_SCAN_RULESET_VERSION = "4";
72
+ export declare const STATIC_SCAN_RULESET_VERSION = "6";
64
73
  export interface StaticScanRule {
65
74
  code: string;
66
75
  severity: Severity;
@@ -76,8 +85,12 @@ export interface StaticScanRule {
76
85
  * `polarity` reports different findings on the same text.
77
86
  */
78
87
  guards: string[];
88
+ /** True when the rule matches the raw text rather than the folded form. */
89
+ raw: boolean;
79
90
  }
80
91
  export interface StaticScanRuleset extends RulesetRef {
92
+ /** Identity of the pre-match text fold this ruleset applies. See ./fold.ts. */
93
+ fold: string;
81
94
  rules: StaticScanRule[];
82
95
  }
83
96
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"static-scan.d.ts","sourceRoot":"","sources":["../src/static-scan.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,UAAU,EACV,QAAQ,EAER,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,YAAY,EACb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/B;;;;;;;;;;;;;;GAcG;AACH,KAAK,OAAO,GAAG,MAAM,GAAG,aAAa,GAAG,aAAa,CAAC;AAuZtD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAmB/C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,iFAAiF;IACjF,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACnD,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,CAerD;AAOD,uEAAuE;AACvE,wBAAgB,oBAAoB,IAAI,UAAU,CAGjD;AAED,qBAAa,cAAe,YAAW,UAAU;IAC/C,QAAQ,CAAC,IAAI,iBAAiB;IAE9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC;;;;OAIG;gBACS,GAAG,GAAE,YAA6B;IAIxC,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAoDlE"}
1
+ {"version":3,"file":"static-scan.d.ts","sourceRoot":"","sources":["../src/static-scan.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,UAAU,EACV,QAAQ,EAER,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,YAAY,EACb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/B;;;;;;;;;;;;;;GAcG;AACH,KAAK,OAAO,GAAG,MAAM,GAAG,aAAa,GAAG,aAAa,GAAG,OAAO,GAAG,cAAc,GAAG,aAAa,GAAG,UAAU,CAAC;AAqnB9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAmB/C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,iFAAiF;IACjF,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,2EAA2E;IAC3E,GAAG,EAAE,OAAO,CAAC;CACd;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACnD,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,CAgBrD;AAOD,uEAAuE;AACvE,wBAAgB,oBAAoB,IAAI,UAAU,CAGjD;AAED,qBAAa,cAAe,YAAW,UAAU;IAC/C,QAAQ,CAAC,IAAI,iBAAiB;IAE9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC;;;;OAIG;gBACS,GAAG,GAAE,YAA6B;IAIxC,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAkFlE"}
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { canonicalize } from "./jcs.js";
3
3
  import { displaySafe } from "./sanitize.js";
4
+ import { foldForScan, FOLD_ID } from "./fold.js";
4
5
  import { silentLogger } from "./logger.js";
5
6
  /** Prose surfaces: the default, and everything a noun-keyed rule may look at. */
6
- const PROSE = ["description", "inputSchema"];
7
+ const PROSE = ["description", "inputSchema", "title", "outputSchema", "annotations", "metadata"];
7
8
  /** Prose plus the identifier - for phrase and hidden-payload rules. */
8
- const ALL_SURFACES = ["name", "description", "inputSchema"];
9
+ const ALL_SURFACES = ["name", ...PROSE];
9
10
  // Imperative instructions aimed at the model — the classic injection tells.
10
11
  const INJECTION_PATTERNS = [
11
12
  { re: /\bignore\s+(?:all\s+|the\s+)?(?:previous|prior|above|preceding)\b/i, code: "TOOL_DEF_INJECTION", severity: "critical", tier: "block", surfaces: ALL_SURFACES, guards: ["mention"] },
@@ -17,7 +18,7 @@ const INJECTION_PATTERNS = [
17
18
  // servers that were being careful. A blocking rule needs a concealment target
18
19
  // that refers to the tool's own action; the bare phrase does not carry one.
19
20
  { re: /\bdo\s+not\s+(?:tell|inform|mention|reveal\s+to|notify)\s+(?:the\s+)?user\b/i, code: "TOOL_DEF_INJECTION", severity: "medium", tier: "advise", surfaces: ALL_SURFACES, guards: ["mention"] },
20
- { re: /\bwithout\s+(?:telling|informing|asking)\s+(?:the\s+)?user\b/i, code: "TOOL_DEF_INJECTION", severity: "high", tier: "block", surfaces: ALL_SURFACES },
21
+ { re: /\bwithout\s+(?:telling|informing|asking)\s+(?:the\s+)?user\b/i, code: "TOOL_DEF_INJECTION", severity: "high", tier: "block", surfaces: ALL_SURFACES, guards: ["autonomy"] },
21
22
  { re: /<\s*\/?\s*(?:system|instructions?|important)\s*>/i, code: "TOOL_DEF_INJECTION", severity: "high", tier: "block", surfaces: ALL_SURFACES },
22
23
  // Demoted in v4. The comment here used to say this was "the blocking rule most
23
24
  // likely to need a policy override"; the survey settled it — 15 findings across
@@ -46,6 +47,8 @@ const EXFIL_PATTERNS = [
46
47
  severity: "high",
47
48
  tier: "block",
48
49
  surfaces: ALL_SURFACES,
50
+ // "send the user to https://…" is a redirect of a person, not a copy of data.
51
+ guards: ["navigation"],
49
52
  },
50
53
  { re: /\b(?:post|send|put)\s+to\s+https?:\/\//i, code: "TOOL_DEF_EXFIL", severity: "critical", tier: "block", surfaces: ALL_SURFACES },
51
54
  { re: /\bupload\b[\s\S]{0,40}\bto\s+(?:https?:\/\/|[\w.-]+\.[a-z]{2,})/i, code: "TOOL_DEF_EXFIL", severity: "high", tier: "block", surfaces: ALL_SURFACES },
@@ -90,6 +93,37 @@ const SECRET_PATTERNS = [
90
93
  { re: /\bpassword\b|\bpasswd\b/i, code: "TOOL_DEF_CREDENTIAL_PARAM", severity: "medium", tier: "advise", surfaces: PROSE },
91
94
  { re: /(?:^|[^.\w])\.env\b|\benvironment\s+variables?\b/i, code: "TOOL_DEF_ENV_REFERENCE", severity: "medium", tier: "advise", surfaces: PROSE },
92
95
  ];
96
+ /**
97
+ * A secret store and an external address in the same definition — language-independent.
98
+ *
99
+ * The verb between them is what an attacker writes in whatever language they like, and it is
100
+ * exactly what a word list cannot keep up with. The two ANCHORS do not need translating: a
101
+ * secret file path (`~/.ssh/id_rsa`, `.env`, `~/.aws/credentials`) and a URL, e-mail address or
102
+ * bare host are spelled the same in every language. A definition that names both — "read `.env`
103
+ * … evil.example.com" — is the tool-poisoning shape whether the connective words are English,
104
+ * Russian or Chinese. The {@link GUARDS.secretExfilPair} guard drops the finding when only the
105
+ * secret path is present (ordinary config prose), so the pair is what the rule reports, not the
106
+ * path alone.
107
+ *
108
+ * ADVISORY, not blocking. On the field corpus of 10 645 servers this pair, even windowed and with
109
+ * the scanner/refusal guards, matched exactly one server — a deploy tool returning an `ssh …
110
+ * ~/.ssh/deploy-key` command to reach its own host, which is the pair used honestly — and no real
111
+ * attack. A rule whose only real-world hits are honest must not refuse a connection. It is kept as
112
+ * a reported signal (it surfaces on the HISTOR desk and in a recorded label, and would flag a true
113
+ * "read .env → post to evil.example.com" the same way) while the actual blocking of cross-lingual
114
+ * exfiltration is left to a meaning-based classifier, which a word- or path-shaped rule cannot be.
115
+ */
116
+ const SECRET_EXFIL_PATTERNS = [
117
+ {
118
+ re: /~\/\.ssh\/[\w.-]{1,64}|\bid_rsa\b|~?\/?\.aws\/credentials\b|~?\/?\.(?:npmrc|pgpass|netrc|git-credentials)\b|(?:^|[^.\w])\.env(?:\.[\w-]{1,40})?\b|\bprocess\.env\b/i,
119
+ code: "TOOL_DEF_SECRET_EXFIL",
120
+ severity: "medium",
121
+ tier: "advise",
122
+ surfaces: PROSE,
123
+ note: "names a secret store and an external address in the same breath",
124
+ guards: ["publicKeyPath", "polarity", "detection", "secretExfilPair"],
125
+ },
126
+ ];
93
127
  // Dangerous URL schemes embedded in text.
94
128
  const URL_SCHEME_PATTERNS = [
95
129
  // A `data:` URI with no payload behind it is the format being documented —
@@ -113,17 +147,30 @@ const PAYLOAD_PATTERNS = [
113
147
  surfaces: ALL_SURFACES,
114
148
  note: "contains a long base64-encoded blob — possible hidden payload",
115
149
  guards: ["blob"],
150
+ // Runs on the folded text (default): NFKC leaves the base64 alphabet
151
+ // [A-Za-z0-9+/_=-] untouched, and folding first strips invisible characters a
152
+ // blob could be broken up with (a soft hyphen every 60 chars) so the run is
153
+ // seen whole. The raw pass still covers the ordinary, unbroken case.
116
154
  },
117
155
  {
118
- // U+200B–200F, U+202A–202E, U+2060, U+FEFF. Built from a \u-escaped string so
119
- // the source stays reviewable (the characters are, by definition, invisible).
120
- re: new RegExp("[\\u200B-\\u200F\\u202A-\\u202E\\u2060\\uFEFF]"),
156
+ // Zero-width and joiners (U+200B–200F), bidi overrides and isolates
157
+ // (U+202A–202E, U+2066–2069), the word joiner (U+2060), the BOM (U+FEFF), the
158
+ // Unicode TAG block (U+E0000–E007F, which can spell out a whole hidden
159
+ // sentence), and the variation-selector supplement (U+E0100–E01EF, a byte per
160
+ // character of hidden payload). The emoji variation selectors U+FE00–FE0F are
161
+ // deliberately NOT here: U+FE0F is part of ordinary emoji. Built from a
162
+ // \u-escaped string so the source stays reviewable — the characters are, by
163
+ // definition, invisible. The zeroWidth guard exempts the standard uses (emoji
164
+ // ZWJ sequences, subdivision-flag tags, joiner controls in Indic/Arabic).
165
+ re: new RegExp("[\\u200B-\\u200F\\u202A-\\u202E\\u2060\\u2066-\\u2069\\uFEFF\\u{E0000}-\\u{E007F}\\u{E0100}-\\u{E01EF}]", "u"),
121
166
  code: "TOOL_DEF_HIDDEN_UNICODE",
122
167
  severity: "high",
123
168
  tier: "block",
124
169
  surfaces: ALL_SURFACES,
125
- note: "contains zero-width or bidi control characters hiding text from review",
170
+ note: "contains zero-width, bidi control or Unicode-tag characters hiding text from review",
126
171
  guards: ["zeroWidth"],
172
+ // The whole point of this rule is the characters the fold removes.
173
+ raw: true,
127
174
  },
128
175
  ];
129
176
  /**
@@ -200,6 +247,45 @@ function entropy(sample) {
200
247
  * blocking.
201
248
  */
202
249
  const SCRIPT_NEEDING_JOINER_CONTROL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0900-\u0DFF\uFB50-\uFDFF\uFE70-\uFEFE]/;
250
+ /** Emoji and other pictographs \u2014 a U+200D between two of these is an emoji sequence. */
251
+ const EXTENDED_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
252
+ /**
253
+ * Is the code point next to `pos` a pictograph? `dir < 0` looks at the code point
254
+ * ending at `pos`, `dir > 0` at the one starting at `pos`. A U+FE0F emoji
255
+ * variation selector between the emoji and the position is skipped. Works on
256
+ * astral emoji by iterating code points, not code units.
257
+ */
258
+ function isPictographAt(text, pos, dir) {
259
+ if (dir < 0) {
260
+ const cps = Array.from(text.slice(Math.max(0, pos - 5), pos + 1));
261
+ while (cps.length && cps[cps.length - 1] === "\ufe0f")
262
+ cps.pop();
263
+ const last = cps[cps.length - 1];
264
+ return last !== undefined && EXTENDED_PICTOGRAPHIC.test(last);
265
+ }
266
+ const cps = Array.from(text.slice(pos, pos + 6));
267
+ while (cps.length && cps[0] === "\ufe0f")
268
+ cps.shift();
269
+ const first = cps[0];
270
+ return first !== undefined && EXTENDED_PICTOGRAPHIC.test(first);
271
+ }
272
+ /**
273
+ * Is the tag character at `i` part of a subdivision-flag emoji \u2014 U+1F3F4 followed
274
+ * by tag letters (and normally the cancel tag U+E007F)? Walks back over the tag
275
+ * run looking for the black flag that opens it.
276
+ */
277
+ function inFlagSequence(text, i) {
278
+ const cps = Array.from(text.slice(Math.max(0, i - 24), i));
279
+ for (let k = cps.length - 1, steps = 0; k >= 0 && steps < 8; k--, steps++) {
280
+ const c = cps[k].codePointAt(0);
281
+ if (c === 0x1f3f4)
282
+ return true;
283
+ if (c >= 0xe0020 && c <= 0xe007f)
284
+ continue;
285
+ return false;
286
+ }
287
+ return false;
288
+ }
203
289
  const GUARDS = {
204
290
  /** A credential noun inside a refusal is a promise, not a request. */
205
291
  polarity(m, text) {
@@ -223,9 +309,12 @@ const GUARDS = {
223
309
  mention(m, text, surface) {
224
310
  const start = m.index;
225
311
  const end = start + m[0].length;
226
- if (isQuoted(text, start, end))
312
+ const structured = ["inputSchema", "outputSchema", "annotations", "metadata"].includes(surface);
313
+ // JSON serialization quotes every string. Those delimiters do not make a
314
+ // description an innocent citation; preserve the narrow whole-token exemption below.
315
+ if (!structured && isQuoted(text, start, end))
227
316
  return "match is quoted or in backticks — a citation, not an instruction";
228
- if (surface === "inputSchema" && text[start - 1] === '"' && text[end] === '"') {
317
+ if (["inputSchema", "outputSchema", "annotations", "metadata"].includes(surface) && text[start - 1] === '"' && text[end] === '"') {
229
318
  return "match is a complete JSON string token — an enum value or field name";
230
319
  }
231
320
  return null;
@@ -243,6 +332,11 @@ const GUARDS = {
243
332
  // javascript:, mailto:, data: schemes".
244
333
  if (after === "" || /^[\s"'`)\],;.]/.test(after))
245
334
  return "no URI payload after the scheme — a label, not a link";
335
+ // A real javascript: URI is followed by ASCII code, not by prose. NFKC turns a
336
+ // fullwidth colon (U+FF1A) into ':' , so "前端javascript:负责交互" folds to
337
+ // "javascript:负责…"; a non-URL character after the colon means a label, not a link.
338
+ if (!/^[\x21-\x7E]/.test(after))
339
+ return "scheme followed by non-ASCII prose — a label, not a link";
246
340
  return null;
247
341
  },
248
342
  /** A `data:` URI that carries no payload is documentation of the format. */
@@ -272,6 +366,13 @@ const GUARDS = {
272
366
  return null;
273
367
  },
274
368
  zeroWidth(m, text) {
369
+ const cp = m[0].codePointAt(0);
370
+ // A tag character (U+E0020-E007F) is a hidden-instruction carrier EXCEPT in
371
+ // the one place it is standard: a subdivision flag, U+1F3F4 followed by tag
372
+ // letters and the cancel tag U+E007F. Exempt a tag char that sits in such a run.
373
+ if (cp >= 0xe0020 && cp <= 0xe007f) {
374
+ return inFlagSequence(text, m.index) ? "tag character inside a subdivision-flag emoji — not concealment" : null;
375
+ }
275
376
  const ch = m[0];
276
377
  if (ch !== "\u200C" && ch !== "\u200D")
277
378
  return null;
@@ -280,6 +381,70 @@ const GUARDS = {
280
381
  if (SCRIPT_NEEDING_JOINER_CONTROL.test(prev) || SCRIPT_NEEDING_JOINER_CONTROL.test(next)) {
281
382
  return "joiner control adjacent to a script that requires it — orthography, not concealment";
282
383
  }
384
+ // A U+200D BETWEEN two pictographs is an emoji ZWJ sequence. Both sides must be
385
+ // a pictograph (a ZWJ with an emoji on one side only is still concealment), and
386
+ // a U+FE0F emoji variation selector between an emoji and the joiner is tolerated.
387
+ if (ch === "\u200D" && isPictographAt(text, m.index - 1, -1) && isPictographAt(text, m.index + 1, 1)) {
388
+ return "zero-width joiner between two emoji — an emoji sequence, not concealment";
389
+ }
390
+ return null;
391
+ },
392
+ /**
393
+ * "send the user to https://…" redirects a PERSON; it does not copy DATA out.
394
+ * The largest single exfil false positive in the field corpus.
395
+ */
396
+ navigation(m) {
397
+ // Only the OBJECT of "send … to" is inspected — the part before the last
398
+ // " to " in the match. The destination host is not: a real exfil target may
399
+ // legitimately be spelled `user.example.net`, and reading the person-word out
400
+ // of the destination would drop the finding it is supposed to keep.
401
+ const toAt = m[0].toLowerCase().lastIndexOf(" to ");
402
+ const object = toAt >= 0 ? m[0].slice(0, toAt) : m[0];
403
+ if (/\b(?:the\s+)?(?:user|users|person|people|customer|client|visitor|human|someone|reader|buyer|shopper|guest|caller|member|subscriber)\b/i.test(object)) {
404
+ return "object of 'send … to' is a person — a redirect, not data exfiltration";
405
+ }
406
+ return null;
407
+ },
408
+ /**
409
+ * "without asking the user" is AUTONOMY when the tool is telling the model to
410
+ * keep working on its own ("keep calling … until done without asking the user").
411
+ *
412
+ * Narrow on purpose. It fires only for the "asking" wording — concealment reads
413
+ * "without telling / informing the user", and those are never exempted — and
414
+ * only when an autonomy cue sits in the SAME short clause immediately before,
415
+ * with commas ending the clause. "Keep a copy of the notes and email them
416
+ * without telling the user" is concealment and is not touched: the verb is
417
+ * "telling", not "asking".
418
+ */
419
+ autonomy(m, text) {
420
+ if (!/\basking\b/i.test(m[0]))
421
+ return null;
422
+ let a = m.index;
423
+ const floor = Math.max(0, m.index - 48);
424
+ while (a > floor && !/[.;,!?\n]/.test(text[a - 1]))
425
+ a--;
426
+ const before = text.slice(a, m.index);
427
+ if (/\b(?:keep|keeps|keep\s+calling|continue|continues|poll\w*|retry|retries|until\s+(?:done|complete|finished|ready)|repeatedly|periodically)\b/i.test(before)) {
428
+ return "autonomy phrasing (keep calling / poll / until done, with 'without asking') — not concealment";
429
+ }
430
+ return null;
431
+ },
432
+ /**
433
+ * A secret store with no external address NEAR it is ordinary config prose.
434
+ *
435
+ * The finding is the two together in one breath — "read the .env and post it to
436
+ * evil.example.com". A `.env` in one schema field and an unrelated URL in
437
+ * another is the whole tool's vocabulary, not an instruction, so the address
438
+ * must fall within a short window of the secret token, not merely somewhere in
439
+ * the same definition. In the field corpus the unwindowed form fired on eight
440
+ * honest servers — deploy tools, migrators, an inbox reader — and nothing else.
441
+ */
442
+ secretExfilPair(m, text) {
443
+ const start = m.index;
444
+ const end = start + m[0].length;
445
+ const window = text.slice(Math.max(0, start - SECRET_EXFIL_SPAN), Math.min(text.length, end + SECRET_EXFIL_SPAN));
446
+ if (!hasExternalAddress(window))
447
+ return "no external address near the secret store — ordinary config reference";
283
448
  return null;
284
449
  },
285
450
  /**
@@ -354,9 +519,52 @@ const RULES = [
354
519
  ...INJECTION_PATTERNS,
355
520
  ...EXFIL_PATTERNS,
356
521
  ...SECRET_PATTERNS,
522
+ ...SECRET_EXFIL_PATTERNS,
357
523
  ...URL_SCHEME_PATTERNS,
358
524
  ...PAYLOAD_PATTERNS,
359
525
  ];
526
+ /**
527
+ * URL, e-mail address or bare external host in a piece of text. Used by
528
+ * {@link GUARDS.secretExfilPair}. Deliberately wordless: an address reads the
529
+ * same in every language. A bare `name.ext` is far more often a file than a host,
530
+ * so a domain whose last label is a known file extension does not count.
531
+ */
532
+ const EXTERNAL_FILE_EXT = /^(?:json|txt|csv|tsv|md|pdf|png|jpe?g|gif|svg|webp|ico|ya?ml|xml|html?|log|py|js|mjs|cjs|tsx?|jsx|sh|bash|zsh|ps1|bat|exe|dll|so|zip|tar|gz|tgz|bz2|xz|7z|rar|docx?|xlsx?|pptx?|mp[34]|wav|avi|mov|mkv|env|toml|ini|cfg|conf|lock|sql|db|sqlite|parquet|ipynb|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|pl|css|scss|less|vue|svelte|wasm|bin|dat|bak|tmp|pem|crt|key|pub)$/i;
533
+ /** How near a secret token an address has to be to read as one instruction. */
534
+ const SECRET_EXFIL_SPAN = 100;
535
+ /** Never inspect more than this much text for an address — a DoS ceiling. */
536
+ const ADDR_SCAN_CAP = 4000;
537
+ /**
538
+ * A host is a spec host only when the WHOLE host equals one of these (or is a
539
+ * subdomain of it): an unanchored substring test would read `w3.org.evil.com` or
540
+ * `localhost.attacker.net` as a spec host and let real exfiltration through.
541
+ */
542
+ const SPEC_HOST = /^(?:localhost|(?:[a-z0-9-]+\.)*(?:json-schema\.org|schema\.org|spdx\.org|w3\.org|iana\.org|ietf\.org|rfc-editor\.org|purl\.org|xmlns\.com))$/i;
543
+ // Linear regexes: every alternative is anchored so a run of dotted text has one
544
+ // start, not one per character. Used only inside the bounded secret-exfil window.
545
+ const ADDR_URL = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,30}:\/\/(?:[a-z0-9._~%!$&'()*+,;=:-]{0,256}@)?(\[[0-9a-f:.]{2,64}\]|[a-z0-9](?:[a-z0-9.-]{0,252}[a-z0-9])?)/gi;
546
+ const ADDR_EMAIL = /(?<![a-z0-9._%+-])[a-z0-9._%+-]{1,64}@((?:[a-z0-9-]{1,63}\.){1,10}[a-z]{2,24})(?![a-z0-9-])/gi;
547
+ const ADDR_DOMAIN = /(?<![a-z0-9@/.:_%+-])((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,10}([a-z]{2,24}))(?![a-z0-9_-])/gi;
548
+ const ADDR_IPV4 = /(?<![\d.])(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}(?!\.?\d)/g;
549
+ const host = (s) => s.replace(/^\[|\]$/g, "").replace(/:\d+$/, "").replace(/^[a-z0-9._~%!$&'()*+,;=:-]*@/i, "").toLowerCase();
550
+ function hasExternalAddress(text) {
551
+ const slice = text.length > ADDR_SCAN_CAP ? text.slice(0, ADDR_SCAN_CAP) : text;
552
+ for (const m of slice.matchAll(ADDR_URL)) {
553
+ if (!SPEC_HOST.test(host(m[1])))
554
+ return true;
555
+ }
556
+ const rest = slice.replace(ADDR_URL, " "); // URL userinfo is never read as an e-mail
557
+ ADDR_EMAIL.lastIndex = 0;
558
+ if (ADDR_EMAIL.test(rest))
559
+ return true;
560
+ const noEmail = rest.replace(ADDR_EMAIL, " ");
561
+ for (const m of noEmail.matchAll(ADDR_DOMAIN)) {
562
+ if (!EXTERNAL_FILE_EXT.test(m[2]) && !SPEC_HOST.test(m[1].toLowerCase()))
563
+ return true;
564
+ }
565
+ ADDR_IPV4.lastIndex = 0;
566
+ return ADDR_IPV4.test(noEmail);
567
+ }
360
568
  /**
361
569
  * Ruleset version. Bump on ANY change to the table above — a scan result is only
362
570
  * comparable to another scan made under the same version and digest.
@@ -378,8 +586,19 @@ const RULES = [
378
586
  * selecting for honest servers: the bare `exfiltrat*` noun, `system prompt`,
379
587
  * `do not tell the user`, and (in severity only) the credential nouns. A
380
588
  * rule's guards are part of this table and therefore of the digest.
589
+ * 5 — language-independent coverage. Text is FOLDED before matching (see ./fold.ts, FOLD_ID in
590
+ * the digest): NFKC, Unicode-tag decoding, invisible-character stripping and mixed-script
591
+ * look-alike mapping, so an English rule cannot be defeated by spelling the words in
592
+ * fullwidth, with a zero-width space inside a word, in tag characters, or with a Cyrillic
593
+ * "о". A new TOOL_DEF_SECRET_EXFIL rule blocks on the language-independent PAIR of a secret
594
+ * store and an external address in one definition. Three field false positives are guarded
595
+ * out: "send the user to <url>" (a redirect, `navigation`), "without asking the user" in
596
+ * autonomy phrasing (`autonomy`), and the zero-width joiner inside an emoji sequence. The
597
+ * hidden-character rule now also catches the Unicode-tag block and the bidi isolates.
381
598
  */
382
- export const STATIC_SCAN_RULESET_VERSION = "4";
599
+ // v6 extends scanning to title, outputSchema, annotations and extension metadata;
600
+ // JSON string delimiters no longer count as a benign quotation around instructions.
601
+ export const STATIC_SCAN_RULESET_VERSION = "6";
383
602
  const SEVERITY_RANK = {
384
603
  info: 0,
385
604
  low: 1,
@@ -416,10 +635,11 @@ export function staticScanRuleset() {
416
635
  source: r.re.source,
417
636
  flags: r.re.flags,
418
637
  guards: [...(r.guards ?? [])],
638
+ raw: r.raw ?? false,
419
639
  })).sort((a, b) => cmp(a.code, b.code) || cmp(a.source, b.source) || cmp(a.flags, b.flags));
420
- const preimage = canonicalize({ version: STATIC_SCAN_RULESET_VERSION, rules });
640
+ const preimage = canonicalize({ version: STATIC_SCAN_RULESET_VERSION, fold: FOLD_ID, rules });
421
641
  const digest = `sha256-${createHash("sha256").update(preimage, "utf8").digest("base64")}`;
422
- return { version: STATIC_SCAN_RULESET_VERSION, digest, rules };
642
+ return { version: STATIC_SCAN_RULESET_VERSION, fold: FOLD_ID, digest, rules };
423
643
  }
424
644
  /** Code-unit comparison. See staticScanRuleset for why not localeCompare. */
425
645
  function cmp(a, b) {
@@ -452,23 +672,59 @@ export class StaticScanGate {
452
672
  { text: tool.name ?? "", surface: "name", where: "name" },
453
673
  { text: tool.description ?? "", surface: "description", where: "description" },
454
674
  { text: schemaText, surface: "inputSchema", where: "input schema" },
675
+ { text: tool.title ?? "", surface: "title", where: "title" },
676
+ { text: safeStringifySchema(tool.outputSchema ?? {}), surface: "outputSchema", where: "output schema" },
677
+ { text: safeStringifySchema(tool.annotations ?? {}), surface: "annotations", where: "annotations" },
678
+ { text: safeStringifySchema(Object.fromEntries(Object.entries(tool).filter(([k]) => !["name", "description", "inputSchema", "title", "outputSchema", "annotations"].includes(k)))),
679
+ surface: "metadata", where: "extension metadata" },
455
680
  ];
456
681
  // The name is quoted back in every message, so it is escaped once here
457
682
  // rather than at each call site. `finding.tool` keeps the raw name: it is
458
683
  // the key the host filters its tool list with.
459
684
  const shown = displaySafe(tool.name);
460
685
  for (const { text, surface, where } of haystacks) {
686
+ // A rule is matched against BOTH the folded text and the raw text, and
687
+ // reports if either yields a match its guards keep. The folded pass adds
688
+ // coverage — a compatibility spelling, an invisible character inside a
689
+ // word, a Unicode-tag instruction or a mixed-script look-alike cannot
690
+ // hide a match — while the raw pass guarantees v5 never misses what the
691
+ // unfolded rule would have caught (NFKC can, for instance, fuse a
692
+ // superscript digit into a word and break a `\b` the raw text still
693
+ // honours). The two hidden-payload rules set `raw` and skip the folded
694
+ // pass, because they look for exactly what the fold removes.
695
+ const folded = foldForScan(text);
696
+ const passes = rule_texts(text, folded);
461
697
  for (const rule of RULES) {
462
698
  if (!rule.surfaces.includes(surface))
463
699
  continue;
464
- const m = matchOf(rule.re, text);
465
- if (!m)
466
- continue;
467
- const dropped = rule.guards?.map((g) => GUARDS[g](m, text, surface)).find((r) => r !== null);
468
- if (dropped) {
469
- this.log.debug(`static-scan: dropped ${rule.code} on "${shown}" ${where} — ${dropped}`);
700
+ // First match, across either text, that every guard keeps. Guards run
701
+ // against the text the match came from, so their offsets line up. A
702
+ // rule that a guard drops on its first hit is tried on later hits and
703
+ // on the other text, so one benign early match cannot mask a real one.
704
+ let m = null;
705
+ let hay = text;
706
+ let dropped = null;
707
+ for (const candidate of rule.raw ? [text] : passes) {
708
+ for (const cm of allMatches(rule.re, candidate)) {
709
+ const d = rule.guards?.map((g) => GUARDS[g](cm, candidate, surface)).find((r) => r !== null) ?? null;
710
+ if (!d) {
711
+ m = cm;
712
+ hay = candidate;
713
+ dropped = null;
714
+ break;
715
+ }
716
+ dropped = d;
717
+ }
718
+ if (m)
719
+ break;
720
+ }
721
+ if (!m) {
722
+ if (dropped) {
723
+ this.log.debug(`static-scan: dropped ${rule.code} on "${shown}" ${where} — ${dropped}`);
724
+ }
470
725
  continue;
471
726
  }
727
+ void hay;
472
728
  // The matched text goes into the message. Without it the reader gets
473
729
  // "matches TOOL_DEF_SECRET_HARVEST signature (\b(?:read|extract|…)" and
474
730
  // cannot tell which alternative fired, or on what — which is most of
@@ -526,14 +782,33 @@ function describe(re) {
526
782
  /** How much of the matched text a finding quotes back. */
527
783
  const SPAN_MAX = 80;
528
784
  /**
529
- * First match, with its position, without mutating the shared rule regex.
785
+ * The texts a non-`raw` rule is matched against: the folded form and the raw
786
+ * form, the raw one dropped when the fold changed nothing so the common case
787
+ * scans once.
788
+ */
789
+ function rule_texts(raw, folded) {
790
+ return raw === folded ? [raw] : [folded, raw];
791
+ }
792
+ /**
793
+ * Every match of a rule, in order, without mutating the shared rule regex.
530
794
  *
531
- * The rules are module-level constants reused for every tool, so `lastIndex` must
532
- * never be left behind on them: a `g`-flagged rule would silently start scanning
533
- * the next tool from wherever the previous match ended. None of them carry `g`
534
- * today; this makes that a non-issue rather than an invariant to remember.
795
+ * The rules are module-level constants reused for every tool, so a fresh `g`
796
+ * copy is made here rather than carrying `g` on the shared object, where a
797
+ * left-behind `lastIndex` would make the next tool start mid-string. Iterating
798
+ * every match (not just the first) is what lets a guard drop a benign early hit
799
+ * — an emoji ZWJ, a "send the user to …" redirect — without hiding a real one
800
+ * later in the same field.
535
801
  */
536
- function matchOf(re, text) {
537
- return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).exec(text);
802
+ function* allMatches(re, text) {
803
+ const g = new RegExp(re.source, re.flags.replace(/[gy]/g, "") + "g");
804
+ let m;
805
+ let guardZeroWidth = 0;
806
+ while ((m = g.exec(text)) !== null) {
807
+ yield m;
808
+ if (m.index === g.lastIndex)
809
+ g.lastIndex++; // never spin on a zero-width match
810
+ if (++guardZeroWidth > text.length + 1)
811
+ break;
812
+ }
538
813
  }
539
814
  //# sourceMappingURL=static-scan.js.map