@sponsio/sdk 0.2.0-alpha.4 → 0.2.0-alpha.9

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 (88) hide show
  1. package/contracts/capability/destructive.yaml +213 -0
  2. package/contracts/capability/factual-claims.yaml +79 -0
  3. package/contracts/capability/host-config-integrity.yaml +42 -9
  4. package/contracts/capability/self-modify.yaml +32 -1
  5. package/contracts/capability/shell.yaml +19 -10
  6. package/contracts/incident/claude-code-secret-bypass.yaml +46 -14
  7. package/contracts/incident/cursor-railway-wipe.yaml +60 -4
  8. package/contracts/incident/openclaw.yaml +9 -63
  9. package/dist/cli/cli.js +32 -0
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/packs.d.ts.map +1 -1
  12. package/dist/cli/packs.js +13 -7
  13. package/dist/cli/packs.js.map +1 -1
  14. package/dist/cloud/client.d.ts +76 -0
  15. package/dist/cloud/client.d.ts.map +1 -0
  16. package/dist/cloud/client.js +198 -0
  17. package/dist/cloud/client.js.map +1 -0
  18. package/dist/cloud/evidence.d.ts +108 -0
  19. package/dist/cloud/evidence.d.ts.map +1 -0
  20. package/dist/cloud/evidence.js +184 -0
  21. package/dist/cloud/evidence.js.map +1 -0
  22. package/dist/cloud/privacy.d.ts +67 -0
  23. package/dist/cloud/privacy.d.ts.map +1 -0
  24. package/dist/cloud/privacy.js +170 -0
  25. package/dist/cloud/privacy.js.map +1 -0
  26. package/dist/cloud/view-model.d.ts +49 -0
  27. package/dist/cloud/view-model.d.ts.map +1 -0
  28. package/dist/cloud/view-model.js +172 -0
  29. package/dist/cloud/view-model.js.map +1 -0
  30. package/dist/core/config-loader.js +16 -0
  31. package/dist/core/config-loader.js.map +1 -1
  32. package/dist/core/evaluator.d.ts +2 -0
  33. package/dist/core/evaluator.d.ts.map +1 -1
  34. package/dist/core/evaluator.js +90 -4
  35. package/dist/core/evaluator.js.map +1 -1
  36. package/dist/core/formula.d.ts.map +1 -1
  37. package/dist/core/formula.js +12 -0
  38. package/dist/core/formula.js.map +1 -1
  39. package/dist/core/grounding.d.ts +43 -7
  40. package/dist/core/grounding.d.ts.map +1 -1
  41. package/dist/core/grounding.js +192 -26
  42. package/dist/core/grounding.js.map +1 -1
  43. package/dist/core/nl-parser.d.ts +1 -1
  44. package/dist/core/nl-parser.d.ts.map +1 -1
  45. package/dist/core/nl-parser.js +616 -40
  46. package/dist/core/nl-parser.js.map +1 -1
  47. package/dist/core/pattern-factory.d.ts.map +1 -1
  48. package/dist/core/pattern-factory.js +7 -1
  49. package/dist/core/pattern-factory.js.map +1 -1
  50. package/dist/core/patterns.d.ts +69 -0
  51. package/dist/core/patterns.d.ts.map +1 -1
  52. package/dist/core/patterns.js +133 -10
  53. package/dist/core/patterns.js.map +1 -1
  54. package/dist/core/tool-names.d.ts +59 -0
  55. package/dist/core/tool-names.d.ts.map +1 -0
  56. package/dist/core/tool-names.js +128 -0
  57. package/dist/core/tool-names.js.map +1 -0
  58. package/dist/index.d.ts +144 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +367 -32
  61. package/dist/index.js.map +1 -1
  62. package/dist/integrations/claude-agent.d.ts +5 -0
  63. package/dist/integrations/claude-agent.d.ts.map +1 -1
  64. package/dist/integrations/claude-agent.js +42 -13
  65. package/dist/integrations/claude-agent.js.map +1 -1
  66. package/dist/integrations/google-adk.d.ts +5 -3
  67. package/dist/integrations/google-adk.d.ts.map +1 -1
  68. package/dist/integrations/google-adk.js +9 -5
  69. package/dist/integrations/google-adk.js.map +1 -1
  70. package/dist/integrations/langchain.d.ts +7 -0
  71. package/dist/integrations/langchain.d.ts.map +1 -1
  72. package/dist/integrations/langchain.js +21 -3
  73. package/dist/integrations/langchain.js.map +1 -1
  74. package/dist/integrations/openai-agents.d.ts +22 -10
  75. package/dist/integrations/openai-agents.d.ts.map +1 -1
  76. package/dist/integrations/openai-agents.js +51 -12
  77. package/dist/integrations/openai-agents.js.map +1 -1
  78. package/dist/integrations/openai.d.ts +11 -0
  79. package/dist/integrations/openai.d.ts.map +1 -1
  80. package/dist/integrations/openai.js +130 -48
  81. package/dist/integrations/openai.js.map +1 -1
  82. package/dist/integrations/vercel-ai.d.ts +20 -9
  83. package/dist/integrations/vercel-ai.d.ts.map +1 -1
  84. package/dist/integrations/vercel-ai.js +131 -50
  85. package/dist/integrations/vercel-ai.js.map +1 -1
  86. package/package.json +12 -8
  87. package/prompts/onboard.md +17 -4
  88. package/contracts/core/llm_safety.yaml +0 -138
@@ -7,71 +7,466 @@
7
7
  * "tool `X` at most 3 times"
8
8
  * "tools `A` and `B` are mutually exclusive"
9
9
  */
10
- import { mustPrecede, alwaysFollowedBy, rateLimit, idempotent, mutualExclusion, noReversal, cooldown, deadline, maxLength, noPii, noKeywords, } from "./patterns.js";
10
+ import { mustPrecede, alwaysFollowedBy, rateLimit, idempotent, mutualExclusion, noReversal, argAllowlist, argBlacklist, approvalFreshness, auditAfter, backupBeforeDestructive, boundedRetry, cooldown, dataIntact, deadline, dryRunBeforeCommit, duplicateCallLimit, maxLength, mustConfirm, noDataLeak, noPii, noKeywords, sanitizedBeforeSink, scopeLimit, requiresPermission, segregationOfDuty, } from "./patterns.js";
11
11
  import { Atom } from "./formula.js";
12
- /** Extract backtick-wrapped tool names from NL text. */
12
+ // Bare snake_case identifiers. At least one underscore, so ordinary
13
+ // English words are not read as tool names.
14
+ const BARE_SNAKE = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/g;
15
+ // Phrases that look snake_case when squished and are not tools.
16
+ const BARE_STOP = new Set([
17
+ "at_most", "at_least", "no_more", "per_session", "per_call",
18
+ "must_not", "should_not", "same_session", "each_other",
19
+ ]);
20
+ const CUE_AND = /(?:^|\s)(?:tool|tools|call(?:ing)?|run(?:ning)?|execute|invoke)\s+([a-zA-Z][a-zA-Z0-9_]*)\s+and\s+([a-zA-Z][a-zA-Z0-9_]*)/i;
21
+ const CUE_PHRASE = /(?:^|\s)(?:tool|tools|action|actions|call(?:ing)?|run(?:ning)?|execute|invoke|use|using)\s+([a-zA-Z][a-zA-Z0-9_]*)/gi;
22
+ const TOOL_BEFORE_FIELD = /([a-zA-Z][a-zA-Z0-9_]*)\s+(?:command|args?|arguments?|input|params?)/i;
23
+ // English words that must never be read as a tool name.
24
+ const STOP_WORDS = new Set(["a", "admin", "after", "agent", "all", "allowed", "also", "always", "an", "and", "any", "are", "as", "at", "be", "been", "before", "being", "between", "both", "but", "by", "called", "can", "confirmed", "could", "data", "did", "different", "do", "does", "each", "every", "few", "file", "first", "for", "from", "get", "got", "had", "has", "have", "he", "her", "him", "if", "in", "is", "it", "its", "just", "last", "let", "made", "make", "may", "me", "more", "most", "must", "my", "never", "new", "no", "nor", "not", "of", "old", "on", "once", "only", "or", "other", "our", "permission", "required", "restricted", "same", "set", "shall", "she", "should", "so", "some", "than", "that", "the", "them", "then", "these", "they", "this", "those", "to", "too", "us", "user", "very", "was", "we", "were", "will", "with", "within", "would", "you", "your"]);
25
+ /**
26
+ * The tool names a sentence mentions. Parity with Python's
27
+ * ``_extract_actions``, and the reason this side used to drop rules
28
+ * Python accepted: it read backticks and nothing else, so
29
+ * "check_policy must precede issue_refund" named no tools here and
30
+ * parsed to null while Python armed it.
31
+ *
32
+ * One method wins, in this order: backticks, then quotes, then bare
33
+ * snake_case, then the cue phrases. Mixing them would let a sentence
34
+ * that quotes one tool pick up an English word as the other.
35
+ */
13
36
  function extractTools(text) {
14
- const matches = text.match(/`([^`]+)`/g);
15
- if (!matches)
16
- return [];
17
- return matches.map((m) => m.replace(/`/g, ""));
37
+ const ticked = [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
38
+ if (ticked.length)
39
+ return ticked;
40
+ const quoted = [...text.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
41
+ if (quoted.length)
42
+ return quoted;
43
+ const bare = [...text.matchAll(BARE_SNAKE)]
44
+ .map((m) => m[1])
45
+ .filter((w) => !BARE_STOP.has(w));
46
+ if (bare.length)
47
+ return bare;
48
+ const pair = text.match(CUE_AND);
49
+ if (pair) {
50
+ const both = [pair[1], pair[2]].filter((w) => !STOP_WORDS.has(w.toLowerCase()));
51
+ if (both.length >= 2)
52
+ return both;
53
+ }
54
+ const cued = [...text.matchAll(CUE_PHRASE)]
55
+ .map((m) => m[1])
56
+ .filter((w) => !STOP_WORDS.has(w.toLowerCase()));
57
+ if (cued.length)
58
+ return cued;
59
+ const beforeField = text.match(TOOL_BEFORE_FIELD);
60
+ if (beforeField && !STOP_WORDS.has(beforeField[1].toLowerCase())) {
61
+ return [beforeField[1]];
62
+ }
63
+ return [];
18
64
  }
19
- /** Extract a number from text. */
20
- function extractNumber(text) {
65
+ // Small numbers people write as words. Parity with Python's
66
+ // ``_WORD_NUMBERS``: "at most once" has to mean 1, or the rule is
67
+ // dropped rather than armed.
68
+ const WORD_NUMBERS = {
69
+ one: 1, once: 1, two: 2, twice: 2, three: 3, four: 4, five: 5,
70
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
71
+ };
72
+ /** A digit, or a small number spelled out. Parity with ``_parse_number``. */
73
+ function parseNumber(text) {
21
74
  const m = text.match(/(\d+)/);
75
+ if (m)
76
+ return parseInt(m[1], 10);
77
+ const lower = text.toLowerCase();
78
+ for (const [word, n] of Object.entries(WORD_NUMBERS)) {
79
+ if (lower.includes(word))
80
+ return n;
81
+ }
82
+ return null;
83
+ }
84
+ /** Parity with ``_parse_rate_limit_count``, including the word forms. */
85
+ function parseRateLimitCount(text) {
86
+ const lower = text.toLowerCase();
87
+ let m = lower.match(/at most (\d+)/) ??
88
+ lower.match(/(\d+)\s*(?:times|invocations|calls|per)/) ??
89
+ lower.match(/limit.*?(\d+)/) ??
90
+ lower.match(/(?:no more than|up to|maximum|max|more than)\s+(\d+)/);
91
+ if (m)
92
+ return parseInt(m[1], 10);
93
+ const words = Object.keys(WORD_NUMBERS).join("|");
94
+ m = lower.match(new RegExp(`(?:more than|at most|no more than|up to)\\s+(${words})\\b`));
95
+ if (m)
96
+ return WORD_NUMBERS[m[1]];
97
+ for (const [word, n] of Object.entries(WORD_NUMBERS)) {
98
+ if (lower.includes(word) && new RegExp(`${word}\\s+(?:times|calls|per)`).test(lower)) {
99
+ return n;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ /** Parity with ``_parse_retry_count``. */
105
+ function parseRetryCount(text) {
106
+ const lower = text.toLowerCase();
107
+ const m = lower.match(/at most (\d+)\s*retr/) ??
108
+ lower.match(/(\d+)\s*retr/) ??
109
+ lower.match(/max(?:imum)?\s*(\d+)/);
22
110
  return m ? parseInt(m[1], 10) : null;
23
111
  }
112
+ /** Parity with ``_parse_step_count``. */
113
+ function parseStepCount(text) {
114
+ const lower = text.toLowerCase();
115
+ const m = lower.match(/(\d+)\s*steps?/) ?? lower.match(/cooldown\s+(?:of\s+)?(\d+)/);
116
+ return m ? parseInt(m[1], 10) : null;
117
+ }
118
+ const BACKTICKED = /`([^`]+)`/g;
119
+ const QUOTED = /["']([^"']+)["']/g;
120
+ function allMatches(re, text) {
121
+ return [...text.matchAll(new RegExp(re.source, "g"))].map((m) => m[1]);
122
+ }
123
+ /**
124
+ * The shapes a sentence forbids. Parity with
125
+ * ``_extract_blacklist_patterns``: read the tail after the verb, prefer
126
+ * whatever is quoted there, and otherwise split the tail on "or" / ","
127
+ * / "and" so a bare "must not contain rm -rf or sudo" still bans two
128
+ * things rather than one long string.
129
+ */
130
+ function extractDelimitedTail(text) {
131
+ const m = text.match(/(?:contain|include|allow|permit)\s+(.+)/i);
132
+ if (!m)
133
+ return [];
134
+ const tail = m[1];
135
+ const items = allMatches(BACKTICKED, tail);
136
+ if (items.length)
137
+ return items;
138
+ const quoted = allMatches(QUOTED, tail);
139
+ if (quoted.length)
140
+ return quoted;
141
+ return tail
142
+ .split(/\s+or\s+|\s*,\s*|\s+and\s+/)
143
+ .map((x) => x.trim().replace(/\.$/, ""))
144
+ .filter(Boolean);
145
+ }
146
+ /** Parity with ``_extract_allowlist_patterns``. */
147
+ function extractAllowlistPatterns(text) {
148
+ const m = text.match(/(?:one\s+of|in|allow(?:list)?|whitelist|permit)\s+(.+)/i);
149
+ if (!m)
150
+ return [];
151
+ const tail = m[1];
152
+ const items = allMatches(BACKTICKED, tail);
153
+ if (items.length)
154
+ return items;
155
+ const quoted = allMatches(QUOTED, tail);
156
+ if (quoted.length)
157
+ return quoted;
158
+ return tail
159
+ .split(/\s+or\s+|\s*,\s*|\s+and\s+/)
160
+ .map((x) => x.trim().replace(/\.$/, ""))
161
+ .filter(Boolean);
162
+ }
163
+ // Generated from Python's ``_KEYWORD_RULES`` by scripts/gen_ts_rules.py,
164
+ // because a table of 155 regexes copied by hand drifts, and it drifted:
165
+ // this side carried 46 of them and silently returned null for the rest,
166
+ // so a rulebook that armed 23 patterns in Python armed 12 here and said
167
+ // nothing about the other 11.
168
+ //
169
+ // ``minArgs`` is this side's own: Python has no pre-gate and lets each
170
+ // branch check its own arity, so the numbers here are what each handler
171
+ // below actually needs, not the ones in Python's table.
24
172
  const KEYWORD_RULES = [
25
- // Deadline (before rate_limit so "within N steps" doesn't get swallowed by
26
- // any "N times"-style rule). Mirrors the Python ``dsl_to_contract`` regex set.
27
173
  {
28
174
  patterns: [
29
- /within\s+\d+\s+steps?\s+(?:of|after)/,
30
- /deadline\s+(?:of\s+)?\d+\s+steps?/,
31
- /must.*within\s+\d+\s+steps?/,
175
+ /arg(?:ument)?s?\s+(?:must\s+be|must\s+match)\s+(?:one\s+of|in)/,
176
+ /(?:command|input|param|recipient|to|host|domain|url)\s+must\s+be\s+(?:one\s+of|in)/,
177
+ /allowlist/,
178
+ /whitelist/,
179
+ /only\s+(?:allow|permit)\s+(?:the\s+)?(?:value|values|recipient|recipients|host|hosts|domain|domains)/,
180
+ /restrict\s+(?:.*\s+)?(?:to|in)\s+(?:the\s+)?(?:allowed|allow-listed|whitelisted)\s+(?:value|values|set|list)/,
32
181
  ],
33
- patternName: "deadline",
182
+ patternName: "arg_allowlist",
183
+ minArgs: 1,
184
+ },
185
+ {
186
+ patterns: [
187
+ /arg(?:ument)?s?\s+(?:must\s+)?not\s+contain/,
188
+ /\b(?:arg|argument|field|param(?:eter)?)s?\s+[`"'][^`"']+[`"']\s+(?:must\s+)?not\s+contain/,
189
+ /(?:command|input|param|query|body|text|content|url|path)\s+must\s+not\s+contain/,
190
+ /blacklist/,
191
+ /must\s+not\s+contain\s+(?:.*(?:rm\s*-rf|sudo|DROP|eval))/,
192
+ /forbid.*(?:in\s+(?:arguments?|params?|input))/,
193
+ /ban\s+(?:patterns?|commands?)\s+in/,
194
+ ],
195
+ patternName: "arg_blacklist",
196
+ minArgs: 1,
197
+ },
198
+ {
199
+ patterns: [
200
+ /restrict\s+(?:file\s+)?(?:access|operations?)\s+to\s+(?:`|\/)/,
201
+ /scope\s*limit/,
202
+ /only\s+(?:access|read|write|operate)\s+(?:files?\s+)?(?:in|within|under)\s+(?:`|\/)/,
203
+ /file\s+(?:operations?\s+)?restricted\s+to\s+(?:`|\/)/,
204
+ /(?:paths?|files?|directories?)\s+(?:must\s+be\s+)?(?:within|under)\s+(?:`|\/)/,
205
+ /confine.*to\s+(?:\/|`)/,
206
+ /restricted\s+to\s+(?:`?\/)/,
207
+ ],
208
+ patternName: "scope_limit",
209
+ minArgs: 0,
210
+ },
211
+ {
212
+ patterns: [
213
+ /data\s+(?:must\s+)?remain\s+(?:un(?:modified|changed|altered)|intact)/,
214
+ /(?:must\s+)?(?:only|exclusively)\s+(?:read|operate\s+on)\s+(?:from\s+)?(?:original|unmodified)/,
215
+ /data\s*intact/,
216
+ /read[- ]?only\s+(?:from|on)\b/,
217
+ ],
218
+ patternName: "data_intact",
219
+ minArgs: 1,
220
+ },
221
+ {
222
+ patterns: [
223
+ /dry[- ]?run.*before/,
224
+ /plan.*before.*(?:apply|commit|deploy|execute)/,
225
+ /(?:apply|commit|deploy|execute).*requires?.*dry[- ]?run/,
226
+ ],
227
+ patternName: "dry_run_before_commit",
228
+ minArgs: 2,
229
+ },
230
+ {
231
+ patterns: [
232
+ /backup.*before/,
233
+ /snapshot.*before/,
234
+ /(?:delete|drop|destroy|destructive).*requires?.*(?:backup|snapshot)/,
235
+ ],
236
+ patternName: "backup_before_destructive",
237
+ minArgs: 2,
238
+ },
239
+ {
240
+ patterns: [
241
+ /audit.*after/,
242
+ /log.*after/,
243
+ /(?:must|should).*be\s+(?:audited|logged)/,
244
+ /(?:audit|log)\s+(?:required|needed)/,
245
+ ],
246
+ patternName: "audit_after",
247
+ minArgs: 1,
248
+ },
249
+ {
250
+ patterns: [
251
+ /fresh\s+approval/,
252
+ /approval.*(?:within|expires?|expire)/,
253
+ /(?:approval|authorization).*fresh/,
254
+ /(?:approve|approval).*(?:\d+)\s+steps?/,
255
+ ],
256
+ patternName: "approval_freshness",
257
+ minArgs: 1,
258
+ },
259
+ {
260
+ patterns: [
261
+ /sanitize.*before/,
262
+ /saniti[sz]ed.*before/,
263
+ /(?:untrusted|external|web|email).*sanitize.*(?:before|then)/,
264
+ /(?:source|input).*sanitizer.*sink/,
265
+ ],
266
+ patternName: "sanitized_before_sink",
267
+ minArgs: 3,
268
+ },
269
+ {
270
+ patterns: [
271
+ /duplicate\s+call/,
272
+ /same.*request.*at most/,
273
+ /same\s+(?:tool|api|request|args?).*at most/,
274
+ /repeat(?:ed)?\s+(?:same\s+)?(?:call|request)/,
275
+ /no duplicate/,
276
+ /never repeat/,
277
+ ],
278
+ patternName: "duplicate_call_limit",
34
279
  minArgs: 2,
35
280
  },
36
- // Rate limit (before idempotent — "at most N times")
37
281
  {
38
- patterns: [/at most.*times/, /maximum.*invocations/, /limit.*calls/, /no more than.*times/],
282
+ patterns: [
283
+ /at most.*retr/,
284
+ /retry.*at most/,
285
+ /bounded retry/,
286
+ /max.*retries/,
287
+ /maximum.*retries/,
288
+ /no more than.*retr/,
289
+ /limit.*retries?\s+to\b/,
290
+ ],
291
+ patternName: "bounded_retry",
292
+ minArgs: 1,
293
+ },
294
+ {
295
+ patterns: [
296
+ /rate\s*limit/,
297
+ /at most.*times/,
298
+ /maximum.*invocations/,
299
+ /limit.*(?:calls|invocations|uses)\b/,
300
+ /must not be called more than/,
301
+ /(?:at most|no more than|up to|maximum|max)\s+(\d+)\s+(?:per|times|calls)/,
302
+ /limit.*to\s+(\d+)\s+(?:per|times|calls)/,
303
+ ],
39
304
  patternName: "rate_limit",
40
305
  minArgs: 1,
41
306
  },
42
- // Idempotent
43
307
  {
44
- patterns: [/idempotent/, /at most once/, /only (?:once|call(?:ed)? once)/, /single invocation/],
308
+ patterns: [
309
+ /idempotent/,
310
+ /at most once/,
311
+ /only (?:once|run once|call(?:ed)? once)/,
312
+ /called? once\b/,
313
+ /should only (?:run|be called|execute) once/,
314
+ /single invocation/,
315
+ /no repeated calls?\b/,
316
+ ],
45
317
  patternName: "idempotent",
46
318
  minArgs: 1,
47
319
  },
48
- // Mutual exclusion
49
320
  {
50
- patterns: [/mutually exclusive/, /exactly one of/, /either.*or.*not both/, /only one of/],
321
+ patterns: [
322
+ /mutually exclusive/,
323
+ /exactly one of/,
324
+ /either.*or.*not both/,
325
+ /cannot (?:both|call both)/,
326
+ /only one of/,
327
+ /at most one of/,
328
+ ],
51
329
  patternName: "mutual_exclusion",
52
330
  minArgs: 2,
53
331
  },
54
- // Always followed by
55
332
  {
56
- patterns: [/(?:must be |always )?followed by/, /must eventually follow/],
57
- patternName: "always_followed_by",
333
+ patterns: [
334
+ /never together/,
335
+ /never\s+(?:call(?:ing)?\s+)?[^,]*\band\b[^,]*\btogether\b/,
336
+ /never both/,
337
+ /not at the same time/,
338
+ /never co-occur/,
339
+ /must never.*called together/,
340
+ /never be called together/,
341
+ /not.*(?:in|within|during)\s+(?:the\s+)?same\s+session/,
342
+ /do not call.*(?:and|,).*(?:in|within|during)\s+(?:the\s+)?same/,
343
+ ],
344
+ patternName: "mutual_exclusion",
58
345
  minArgs: 2,
59
346
  },
60
- // No reversal
61
347
  {
62
- patterns: [/cannot.*after\s+approv/, /no reversal/, /cannot\s+deny\s+after/, /must\s+not.*after/],
63
- patternName: "no_reversal",
348
+ patterns: [
349
+ /cooldown/,
350
+ /cool\s*down\s+(?:of|between|period\s+of)\s+\d+/,
351
+ /minimum\s+\d+\s+steps?\s+between/,
352
+ /at least\s+\d+\s+steps?\s+between/,
353
+ /wait\s+\d+\s+steps?\s+between/,
354
+ /gap of\s+\d+\s+steps?/,
355
+ /interval\s+(?:of\s+)?\d+\s+steps?/,
356
+ ],
357
+ patternName: "cooldown",
358
+ minArgs: 1,
359
+ },
360
+ {
361
+ patterns: [
362
+ /segregation of dut/,
363
+ /separation of dut/,
364
+ /same agent.*cannot.*both/,
365
+ /different agent/,
366
+ /cannot do both/,
367
+ /must be (?:done|performed) by different/,
368
+ /two[- ]person\s+rule/,
369
+ /dual\s+control/,
370
+ ],
371
+ patternName: "segregation_of_duty",
64
372
  minArgs: 2,
65
373
  },
66
- // Cooldown
67
374
  {
68
- patterns: [/cooldown/, /minimum\s+\d+\s+steps?\s+between/],
69
- patternName: "cooldown",
375
+ patterns: [
376
+ /within\s+\d+\s+steps?\s+(?:of|after)/,
377
+ /deadline\s+(?:of\s+)?\d+\s+steps?/,
378
+ /must.*within\s+\d+\s+steps?/,
379
+ /at most\s+\d+\s+steps?\s+after/,
380
+ ],
381
+ patternName: "deadline",
382
+ minArgs: 2,
383
+ },
384
+ {
385
+ patterns: [
386
+ /must be confirmed/,
387
+ /confirm(?:ation)?\s+(?:before|required|needed)/,
388
+ /requires?\s+confirmation/,
389
+ /must confirm before/,
390
+ /(?:needs?|requires?)\s+(?:user\s+)?(?:approval|consent)\s+before/,
391
+ /without\s+confirmation/,
392
+ /never\s+call.*without\s+confirm/,
393
+ ],
394
+ patternName: "must_confirm",
70
395
  minArgs: 1,
71
396
  },
72
- // Must precede (last — most general, requires backtick context)
73
397
  {
74
- patterns: [/precede/, /`[^`]+`\s+(?:must\s+)?before\s+`/, /before\s+`/, /is\s+required\s+before/],
398
+ patterns: [
399
+ /cannot.*after\s+approv/,
400
+ /no reversal/,
401
+ /never\s+reverse/,
402
+ /cannot\s+deny\s+after/,
403
+ /cannot\s+reject\s+after/,
404
+ /cannot\s+contradict/,
405
+ /cannot\s+be\s+reversed/,
406
+ /(?:never|cannot|must\s+not|don'?t|do\s+not)\s+(?:call\s+)?.*after\s+(?:calling\s+)?/,
407
+ /^\s*after\s+.*\b(?:never|cannot|must\s+not|don'?t|do\s+not)\b/,
408
+ /forbidden\s+after/,
409
+ /prohibited\s+after/,
410
+ /must\s+not\s+follow/,
411
+ /must\s+not.*after/,
412
+ /should\s+not\s+follow/,
413
+ /not\s+allowed\s+after/,
414
+ /once.*(?:cannot|must\s+not|never)/,
415
+ /irreversible/,
416
+ ],
417
+ patternName: "no_reversal",
418
+ minArgs: 2,
419
+ },
420
+ {
421
+ patterns: [
422
+ /requires?\s+(?:\w+\s+)?permission/,
423
+ /needs?\s+permission/,
424
+ /must\s+have\s+permission/,
425
+ /(?:requires?|needs?)\s+(?:\w+\s+)?(?:authorization|auth)\b/,
426
+ /requires?\s+admin\b/,
427
+ /(?:only\s+)?(?:authorized|permitted)\s+(?:users?|agents?|roles?)\s+(?:can|may)/,
428
+ /require\s+\w+\s+(?:permission|role|access)\s+to\b/,
429
+ ],
430
+ patternName: "requires_permission",
431
+ minArgs: 2,
432
+ },
433
+ {
434
+ patterns: [
435
+ /no data leak/,
436
+ /data must not (?:flow|leak|be\s+sent)/,
437
+ /no leak/,
438
+ /(?:must\s+)?not\s+(?:send|transmit|expose|share).*(?:to\s+external|outside)/,
439
+ /protect.*from\s+(?:leaking|exposure)/,
440
+ ],
441
+ patternName: "no_data_leak",
442
+ minArgs: 2,
443
+ },
444
+ {
445
+ patterns: [
446
+ /(?:must\s+be\s+|always\s+)?followed\s+by/,
447
+ /must eventually follow/,
448
+ /eventually.*after/,
449
+ /after\s+(?:calling\s+)?.*(?:always|must)\s+(?:call\s+|run\s+)?/,
450
+ /(?:always|must)\s+(?:call|run|execute)\s+.*after/,
451
+ /whenever.*(?:is\s+)?called.*(?:must|should|always)/,
452
+ /(?:should|must)\s+(?:always\s+)?come\s+after/,
453
+ ],
454
+ patternName: "always_followed_by",
455
+ minArgs: 2,
456
+ },
457
+ {
458
+ patterns: [
459
+ /precede/,
460
+ /prior to\s+`/,
461
+ /`[^`]+`\s+(?:must\s+)?(?:be\s+)?(?:called\s+|run\s+|executed\s+)?before\s+`/,
462
+ /before\s+(?:calling\s+)?`/,
463
+ /required\s+before\s+`/,
464
+ /is\s+(?:a\s+)?prerequisite\s+for\b/,
465
+ /(?:always|must)\s+run\s+`[^`]+`\s+first/,
466
+ /is\s+required\s+before/,
467
+ /needs?\s+to\s+(?:be\s+)?(?:called|run)\s+before/,
468
+ /must\s+(?:be\s+)?(?:called|run|executed)\s+first/,
469
+ ],
75
470
  patternName: "must_precede",
76
471
  minArgs: 2,
77
472
  },
@@ -94,7 +489,11 @@ function parseBareCalledAtom(text, tools) {
94
489
  return {
95
490
  formula: new Atom("called", [tools[0]]),
96
491
  desc: `called(${tools[0]})`,
97
- patternName: "called",
492
+ patternName: "trigger_called",
493
+ // Empty, as Python leaves it. The tool is in the atom; putting it
494
+ // here too would make the same rule group differently on the two
495
+ // sides, which is the one thing args exists to prevent.
496
+ args: [],
98
497
  liveness: false,
99
498
  };
100
499
  }
@@ -166,7 +565,44 @@ function tryResponseContent(text) {
166
565
  }
167
566
  return null;
168
567
  }
169
- export function parseNl(text) {
568
+ const NEGATION_RE = /\b(?:never|cannot|can\s+not|must\s+not|don'?t|do\s+not|no)\b/;
569
+ /**
570
+ * True for "never call A after B", where B is the commitment.
571
+ *
572
+ * Decided by position rather than by phrase: the phrase list this
573
+ * supplements matches "never after" and not "never call `x` after", and
574
+ * every new way of saying it would need another entry. Parity with
575
+ * Python's ``_forbidden_action_comes_first``.
576
+ */
577
+ function forbiddenActionComesFirst(lower, first, second) {
578
+ const iFirst = lower.indexOf(first.toLowerCase());
579
+ if (iFirst < 0)
580
+ return false;
581
+ const iSecond = lower.indexOf(second.toLowerCase(), iFirst + 1);
582
+ if (iSecond < 0 || iSecond <= iFirst)
583
+ return false;
584
+ // "after" has to sit between the two actions: in "after `b`, never call
585
+ // `a`" the commitment already comes first and must not be swapped.
586
+ if (!/\bafter\b/.test(lower.slice(iFirst + first.length, iSecond)))
587
+ return false;
588
+ return NEGATION_RE.test(lower.slice(0, iFirst));
589
+ }
590
+ /**
591
+ * The longest rule sentence this will look at, matching Python's
592
+ * ``_MAX_NL_LINE_LEN``.
593
+ *
594
+ * Several of the regexes below are quadratic on adversarial input: a
595
+ * long run of digits before ``\s*steps?`` makes the engine retry from
596
+ * every position. Python has always truncated and this side never did,
597
+ * so the same rulebook that cost Python milliseconds could hang a
598
+ * TypeScript agent — and on a platform whose customers supply their own
599
+ * overlay rules, that string is not fully trusted.
600
+ *
601
+ * A sentence longer than this is not a rule anybody wrote.
602
+ */
603
+ const MAX_RULE_CHARS = 10_000;
604
+ export function parseNl(input) {
605
+ const text = input.length > MAX_RULE_CHARS ? input.slice(0, MAX_RULE_CHARS) : input;
170
606
  // P2 response-content patterns first — keep them ahead of the
171
607
  // generic keyword cascade so "response must not contain emails"
172
608
  // doesn't get swallowed by something more general.
@@ -184,10 +620,19 @@ export function parseNl(text) {
184
620
  switch (rule.patternName) {
185
621
  case "must_precede":
186
622
  return mustPrecede(tools[0], tools[1]);
187
- case "always_followed_by":
188
- return alwaysFollowedBy(tools[0], tools[1]);
623
+ case "always_followed_by": {
624
+ // English puts the trigger second in two shapes, and reading the
625
+ // names in the order they appear built the rule backwards:
626
+ // "`b` should come after `a`" and "always call `b` after `a`"
627
+ // both mean a triggers b. Parity with Python.
628
+ const swap = /come\s+after|should\s+(?:always\s+)?(?:come|happen)\s+after/.test(lower) ||
629
+ /(?:always|must)\s+(?:call|run)\s+\S+\s+after\b/.test(lower);
630
+ return swap
631
+ ? alwaysFollowedBy(tools[1], tools[0])
632
+ : alwaysFollowedBy(tools[0], tools[1]);
633
+ }
189
634
  case "rate_limit": {
190
- const n = extractNumber(text);
635
+ const n = parseRateLimitCount(text);
191
636
  if (n == null)
192
637
  continue;
193
638
  return rateLimit(tools[0], n);
@@ -196,16 +641,147 @@ export function parseNl(text) {
196
641
  return idempotent(tools[0]);
197
642
  case "mutual_exclusion":
198
643
  return mutualExclusion(tools[0], tools[1]);
199
- case "no_reversal":
200
- return noReversal(tools[0], tools[1]);
644
+ case "arg_blacklist": {
645
+ // Three ways a sentence names the field, in the order they win.
646
+ // Parity with Python, and the reason this side got it wrong:
647
+ // taking the second backticked name produced
648
+ // arg_field_has('bash', 'rm -rf', 'rm -rf'), a rule watching an
649
+ // argument nobody sends, which parses and arms and never fires.
650
+ //
651
+ // 1. marked: "tool `bash` arg `command` must not ..."
652
+ // 2. a cue word naming a real field
653
+ // 3. a second name that is not itself one of the banned shapes
654
+ const forbidden = extractDelimitedTail(text);
655
+ if (!forbidden.length)
656
+ continue;
657
+ const tool = tools[0];
658
+ const marked = text.match(/\b(?:arg|argument|field|param(?:eter)?)s?\s+[`"']([^`"']+)[`"']/i);
659
+ const cue = text.match(/\b(command|query|input|body|path|url|text|content|param|parameter)\b/i);
660
+ const field = marked
661
+ ? marked[1].trim()
662
+ : cue
663
+ ? cue[1].toLowerCase()
664
+ : tools.length >= 2 && !forbidden.includes(tools[1])
665
+ ? tools[1]
666
+ : "command";
667
+ return argBlacklist(tool, field, forbidden);
668
+ }
669
+ case "requires_permission": {
670
+ // "`deploy` requires permission `approve_deploy`" names a tool,
671
+ // not a permission held in the agent's static definition, and
672
+ // ``requires_permission`` reads its atom from that definition:
673
+ // armed against a permission nobody grants, it fires on every
674
+ // call. Python routes a tool-shaped name to must_precede.
675
+ const perm = tools[1];
676
+ return perm.includes("_") || perm.endsWith("()")
677
+ ? mustPrecede(perm, tools[0])
678
+ : requiresPermission(tools[0], perm);
679
+ }
680
+ case "segregation_of_duty":
681
+ return segregationOfDuty(tools[0], tools[1]);
682
+ case "scope_limit": {
683
+ // The roots are the path-shaped arguments; the tool is whatever
684
+ // else was named. "restrict file access to `/workspace`" names no
685
+ // tool, so the rule applies to any call carrying a path.
686
+ const roots = tools.filter((t) => t.startsWith("/"));
687
+ const named = tools.filter((t) => !t.startsWith("/"));
688
+ const bare = text.match(/(?:^|\s)(\/[^\s`'",;]+)/g) ?? [];
689
+ const paths = roots.length
690
+ ? roots
691
+ : bare.map((b) => b.trim()).filter(Boolean);
692
+ if (!paths.length)
693
+ continue;
694
+ return scopeLimit(named[0] ?? ".*", paths);
695
+ }
696
+ case "no_reversal": {
697
+ // ``noReversal(commitment, contradiction)`` takes the committing
698
+ // action first, and English puts it last whenever the sentence
699
+ // opens with the prohibition: "never call `delete` after
700
+ // `restore`" means restore commits. Reading the names in the
701
+ // order they appear built the opposite rule — and this parser
702
+ // rewrites the desc from the args, so the console showed a rule
703
+ // the user never wrote. Parity with Python's
704
+ // ``_forbidden_action_comes_first``.
705
+ const swap = /must not follow|should not follow|not allowed after|forbidden after|prohibited after|never after/.test(lower) ||
706
+ // "never `A` after `B`" and its two siblings put the
707
+ // contradiction first as well, and matched none of the
708
+ // spellings above because the negation word is separated from
709
+ // "after" by the action itself.
710
+ /^(?:never|cannot|can\s*not|must\s+not)\b(?=.*\bafter\b)/.test(lower) ||
711
+ forbiddenActionComesFirst(lower, tools[0], tools[1]);
712
+ return swap
713
+ ? noReversal(tools[1], tools[0])
714
+ : noReversal(tools[0], tools[1]);
715
+ }
201
716
  case "cooldown": {
202
- const n = extractNumber(text);
717
+ const n = parseStepCount(text);
203
718
  if (n == null)
204
719
  continue;
205
720
  return cooldown(tools[0], n);
206
721
  }
722
+ case "arg_allowlist": {
723
+ const allowed = extractAllowlistPatterns(text);
724
+ if (!allowed.length)
725
+ continue;
726
+ // A second name is the field; with only one name the sentence
727
+ // named the tool and left the field implicit, as Python does.
728
+ return argAllowlist(tools[0], tools[1] ?? "command", allowed);
729
+ }
730
+ case "data_intact": {
731
+ const paths = tools.filter((t) => t.startsWith("/"));
732
+ if (!paths.length)
733
+ continue;
734
+ return dataIntact(tools[0], paths);
735
+ }
736
+ case "dry_run_before_commit":
737
+ return dryRunBeforeCommit(tools[0], tools[1]);
738
+ case "backup_before_destructive":
739
+ return backupBeforeDestructive(tools[0], tools[1]);
740
+ case "audit_after":
741
+ // One name means the audit step is derived, the same convention
742
+ // Python uses, so "`deploy` must be logged" is a usable rule
743
+ // rather than an arity error.
744
+ return auditAfter(tools[0], tools[1] ?? `audit_${tools[0]}`);
745
+ case "approval_freshness": {
746
+ const steps = parseStepCount(text) ?? parseNumber(text);
747
+ if (steps == null)
748
+ continue;
749
+ return tools.length >= 2
750
+ ? approvalFreshness(tools[0], tools[1], steps)
751
+ : approvalFreshness(`approve_${tools[0]}`, tools[0], steps);
752
+ }
753
+ case "sanitized_before_sink":
754
+ return sanitizedBeforeSink(tools[0], tools[1], tools[2]);
755
+ case "duplicate_call_limit": {
756
+ const n = parseRateLimitCount(text) ?? parseNumber(text);
757
+ if (n == null)
758
+ continue;
759
+ return duplicateCallLimit(tools[0], tools[1], n);
760
+ }
761
+ case "bounded_retry": {
762
+ const n = parseRetryCount(text);
763
+ if (n == null)
764
+ continue;
765
+ return boundedRetry(tools[0], n);
766
+ }
767
+ case "must_confirm":
768
+ return mustConfirm(tools[0]);
769
+ case "no_data_leak": {
770
+ // ``no_data_leak`` is about a kind of data reaching a sink. When
771
+ // the first name is a tool rather than a kind, the sentence is
772
+ // saying "having called A, do not call B", which is a reversal.
773
+ // Python routes it that way and this side did not, so the same
774
+ // rulebook produced two different patterns and the cloud counted
775
+ // them as two findings.
776
+ const DATA_KINDS = new Set([
777
+ "pii", "credentials", "personal_data", "sensitive", "secrets",
778
+ ]);
779
+ return DATA_KINDS.has(tools[0].toLowerCase())
780
+ ? noDataLeak(tools[0], tools[1])
781
+ : noReversal(tools[0], tools[1]);
782
+ }
207
783
  case "deadline": {
208
- const n = extractNumber(text);
784
+ const n = parseStepCount(text);
209
785
  if (n == null)
210
786
  continue;
211
787
  // Parity with Python NL parser: ``deadline(actions[0], actions[1], n)``,