@goplus/agentguard 1.1.28-beta.0 → 1.1.28-beta.2

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 (48) hide show
  1. package/dist/action/detectors/exec.d.ts.map +1 -1
  2. package/dist/action/detectors/exec.js +287 -8
  3. package/dist/action/detectors/exec.js.map +1 -1
  4. package/dist/action/detectors/network.d.ts.map +1 -1
  5. package/dist/action/detectors/network.js +50 -6
  6. package/dist/action/detectors/network.js.map +1 -1
  7. package/dist/adapters/openclaw-plugin.d.ts.map +1 -1
  8. package/dist/adapters/openclaw-plugin.js +16 -0
  9. package/dist/adapters/openclaw-plugin.js.map +1 -1
  10. package/dist/cli.js +41 -12
  11. package/dist/cli.js.map +1 -1
  12. package/dist/runtime/evaluator.d.ts +5 -1
  13. package/dist/runtime/evaluator.d.ts.map +1 -1
  14. package/dist/runtime/evaluator.js +530 -42
  15. package/dist/runtime/evaluator.js.map +1 -1
  16. package/dist/runtime/policy.d.ts.map +1 -1
  17. package/dist/runtime/policy.js +1 -0
  18. package/dist/runtime/policy.js.map +1 -1
  19. package/dist/runtime/protect.d.ts +2 -0
  20. package/dist/runtime/protect.d.ts.map +1 -1
  21. package/dist/runtime/protect.js +33 -10
  22. package/dist/runtime/protect.js.map +1 -1
  23. package/dist/runtime/types.d.ts +3 -0
  24. package/dist/runtime/types.d.ts.map +1 -1
  25. package/dist/tests/action.test.js +115 -9
  26. package/dist/tests/action.test.js.map +1 -1
  27. package/dist/tests/cli-connect.test.js +77 -3
  28. package/dist/tests/cli-connect.test.js.map +1 -1
  29. package/dist/tests/cli-policy.test.js +34 -4
  30. package/dist/tests/cli-policy.test.js.map +1 -1
  31. package/dist/tests/cli-subscribe.test.js +80 -3
  32. package/dist/tests/cli-subscribe.test.js.map +1 -1
  33. package/dist/tests/feed-cron.test.js +1 -1
  34. package/dist/tests/feed-cron.test.js.map +1 -1
  35. package/dist/tests/integration.test.js +48 -0
  36. package/dist/tests/integration.test.js.map +1 -1
  37. package/dist/tests/runtime-cloud.test.js +388 -1
  38. package/dist/tests/runtime-cloud.test.js.map +1 -1
  39. package/dist/tests/smoke.test.js +1 -3
  40. package/dist/tests/smoke.test.js.map +1 -1
  41. package/dist/types/action.d.ts +1 -1
  42. package/dist/types/action.d.ts.map +1 -1
  43. package/dist/utils/system-paths.d.ts +14 -0
  44. package/dist/utils/system-paths.d.ts.map +1 -0
  45. package/dist/utils/system-paths.js +172 -0
  46. package/dist/utils/system-paths.js.map +1 -0
  47. package/package.json +1 -1
  48. package/skills/agentguard/scripts/hermes-hook.js +12 -1
@@ -1,11 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.__resetNetworkBehaviorForTests = __resetNetworkBehaviorForTests;
3
4
  exports.evaluateLocalAction = evaluateLocalAction;
4
5
  const index_js_1 = require("../action/index.js");
6
+ const node_crypto_1 = require("node:crypto");
7
+ const node_fs_1 = require("node:fs");
5
8
  const node_os_1 = require("node:os");
9
+ const node_path_1 = require("node:path");
6
10
  const skill_js_1 = require("../types/skill.js");
7
11
  const patterns_js_1 = require("../utils/patterns.js");
12
+ const system_paths_js_1 = require("../utils/system-paths.js");
13
+ const config_js_1 = require("../config.js");
8
14
  const redaction_js_1 = require("./redaction.js");
15
+ const ONE_MINUTE_MS = 60_000;
16
+ const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS;
17
+ const HIGH_FREQUENCY_THRESHOLD = 100;
18
+ const ODD_HOUR_FREQUENCY_THRESHOLD = 20;
19
+ const TOKEN_DOMAIN_THRESHOLD = 10;
20
+ const REPLAY_THRESHOLD = 5;
21
+ const DOS_STATUS_THRESHOLD = 5;
22
+ const SINGLE_RESPONSE_BYTES_THRESHOLD = 10 * 1024 * 1024;
23
+ const WINDOW_RESPONSE_BYTES_THRESHOLD = 100 * 1024 * 1024;
24
+ const MAX_PERSISTED_BEHAVIOR_EVENTS = 1_000;
25
+ const networkBehaviorEvents = [];
26
+ let networkBehaviorStateLoaded = false;
27
+ function __resetNetworkBehaviorForTests() {
28
+ networkBehaviorEvents.length = 0;
29
+ networkBehaviorStateLoaded = true;
30
+ const statePath = process.env.AGENTGUARD_BEHAVIOR_STATE_PATH;
31
+ if (statePath) {
32
+ try {
33
+ (0, node_fs_1.rmSync)(statePath, { force: true });
34
+ }
35
+ catch {
36
+ // Test cleanup only.
37
+ }
38
+ }
39
+ }
9
40
  function reason(code, severity, title, description, evidence) {
10
41
  return {
11
42
  code,
@@ -15,7 +46,7 @@ function reason(code, severity, title, description, evidence) {
15
46
  evidence: evidence === undefined ? undefined : (0, redaction_js_1.redactPreview)(evidence, 240),
16
47
  };
17
48
  }
18
- async function evaluateLocalAction(policy, action) {
49
+ async function evaluateLocalAction(policy, action, options = {}) {
19
50
  if (isAllowedByCommandPolicy(policy, action)) {
20
51
  return {
21
52
  actionId: `act_local_${Date.now()}_${process.pid}`,
@@ -27,7 +58,7 @@ async function evaluateLocalAction(policy, action) {
27
58
  };
28
59
  }
29
60
  const customReasons = customPolicyReasons(policy, action);
30
- const ossDecision = await evaluateWithOssActionScanner(policy, action);
61
+ const ossDecision = await evaluateWithOssActionScanner(policy, action, options);
31
62
  const ossReasons = (ossDecision?.risk_tags || []).map((tag, index) => normalizeOssReason(tag, ossDecision?.evidence?.[index], action));
32
63
  const reasons = (0, redaction_js_1.redactReasons)([...customReasons, ...ossReasons]);
33
64
  const riskScore = riskScoreFor(reasons, ossDecision?.risk_level || 'safe');
@@ -65,7 +96,7 @@ function customPolicyReasons(policy, action) {
65
96
  }
66
97
  }
67
98
  for (const domain of policy.network.blockedDomains) {
68
- if (domain && lower.includes(domain.toLowerCase())) {
99
+ if (domain && matchesNetworkReference(input, domain)) {
69
100
  reasons.push(reason('CUSTOM_BLOCKED_DOMAIN', 'high', 'Custom blocked domain', 'The action references a domain blocked by runtime policy.', domain));
70
101
  }
71
102
  }
@@ -80,7 +111,15 @@ function customPolicyReasons(policy, action) {
80
111
  reasons.push(reason('NETWORK_OUTBOUND', 'medium', 'Network outbound policy', 'The action makes an outbound network request covered by runtime policy.', input));
81
112
  }
82
113
  }
114
+ const networkTargets = networkTargetsFromAction(action);
115
+ if (networkTargets.length > 0) {
116
+ reasons.push(...networkBehaviorReasons(action, networkTargets));
117
+ reasons.push(...networkResponseReasons(action, networkTargets[0]));
118
+ }
83
119
  if (action.actionType === 'file_read' || action.actionType === 'file_write') {
120
+ const systemPathReason = systemPathReasonForFileAction(action);
121
+ if (systemPathReason)
122
+ reasons.push(systemPathReason);
84
123
  for (const pathPattern of policy.protectedPaths) {
85
124
  if (matchesPath(input, pathPattern)) {
86
125
  reasons.push(reason('SECRET_ACCESS', action.actionType === 'file_write' ? 'critical' : 'high', 'Protected path access', 'The agent attempted to access a path protected by runtime policy.', pathPattern));
@@ -92,25 +131,36 @@ function customPolicyReasons(policy, action) {
92
131
  }
93
132
  return reasons;
94
133
  }
95
- async function evaluateWithOssActionScanner(policy, action) {
134
+ function systemPathReasonForFileAction(action) {
135
+ const operation = action.actionType === 'file_read' ? 'read' : 'write';
136
+ const classification = (0, system_paths_js_1.classifySystemPathOperation)(action.input, operation);
137
+ if (!classification)
138
+ return null;
139
+ return reason(classification.decision === 'block' ? 'SYSTEM_PATH_MUTATION' : 'SYSTEM_PATH_ACCESS', classification.severity, classification.decision === 'block' ? 'System path mutation blocked' : 'System path access requires approval', `${operation} operation targets ${classification.description}.`, classification.path);
140
+ }
141
+ async function evaluateWithOssActionScanner(policy, action, options) {
96
142
  const mapped = mapRuntimeAction(action);
97
143
  if (!mapped)
98
144
  return null;
145
+ const runtimeCapabilities = {
146
+ ...skill_js_1.DEFAULT_CAPABILITY,
147
+ exec: 'allow',
148
+ network_allowlist: policy.network.approvalDomains,
149
+ filesystem_allowlist: runtimeFilesystemAllowlist(policy, options),
150
+ };
99
151
  const registry = {
100
152
  async lookup() {
101
153
  return {
102
154
  record: null,
103
155
  effective_trust_level: 'trusted',
104
- effective_capabilities: {
105
- ...skill_js_1.DEFAULT_CAPABILITY,
106
- exec: 'allow',
107
- network_allowlist: policy.network.approvalDomains,
108
- filesystem_allowlist: policy.protectedPaths,
109
- },
156
+ effective_capabilities: runtimeCapabilities,
110
157
  };
111
158
  },
112
159
  };
113
- const scanner = new index_js_1.ActionScanner({ registry: registry });
160
+ const scanner = new index_js_1.ActionScanner({
161
+ registry: registry,
162
+ defaultCapabilities: runtimeCapabilities,
163
+ });
114
164
  return scanner.decide({
115
165
  actor: {
116
166
  skill: {
@@ -130,6 +180,9 @@ async function evaluateWithOssActionScanner(policy, action) {
130
180
  },
131
181
  });
132
182
  }
183
+ function runtimeFilesystemAllowlist(policy, options) {
184
+ return options.filesystemAllowlist ?? policy.filesystemAllowlist ?? ['*'];
185
+ }
133
186
  function mapRuntimeAction(action) {
134
187
  if (action.actionType === 'shell') {
135
188
  return { type: 'exec_command', data: { command: action.input, cwd: action.cwd } };
@@ -156,18 +209,72 @@ function mapRuntimeAction(action) {
156
209
  return null;
157
210
  }
158
211
  function methodFromMetadata(value) {
159
- if (value === 'POST' || value === 'PUT' || value === 'DELETE' || value === 'PATCH')
160
- return value;
212
+ const method = typeof value === 'string' ? value.toUpperCase() : '';
213
+ if (method === 'GET' ||
214
+ method === 'HEAD' ||
215
+ method === 'OPTIONS' ||
216
+ method === 'POST' ||
217
+ method === 'PUT' ||
218
+ method === 'DELETE' ||
219
+ method === 'PATCH') {
220
+ return method;
221
+ }
161
222
  return 'GET';
162
223
  }
163
224
  function stringFromMetadata(value) {
164
225
  return typeof value === 'string' && value.length > 0 ? value : undefined;
165
226
  }
227
+ function firstStringFromMetadata(...values) {
228
+ for (const value of values) {
229
+ if (typeof value === 'string' && value.length > 0)
230
+ return value;
231
+ }
232
+ return undefined;
233
+ }
234
+ function numberFromMetadata(...values) {
235
+ for (const value of values) {
236
+ if (typeof value === 'number' && Number.isFinite(value))
237
+ return value;
238
+ if (typeof value === 'string' && value.trim()) {
239
+ const parsed = Number(value);
240
+ if (Number.isFinite(parsed))
241
+ return parsed;
242
+ }
243
+ }
244
+ return undefined;
245
+ }
246
+ function timeFromMetadata(value) {
247
+ if (typeof value === 'number' && Number.isFinite(value))
248
+ return value;
249
+ if (typeof value === 'string') {
250
+ const parsed = Date.parse(value);
251
+ if (Number.isFinite(parsed))
252
+ return parsed;
253
+ }
254
+ return undefined;
255
+ }
256
+ function recordFromMetadata(value) {
257
+ if (!value || typeof value !== 'object' || Array.isArray(value))
258
+ return undefined;
259
+ return value;
260
+ }
166
261
  function normalizeOssReason(tag, evidence, action) {
167
262
  const evidenceText = evidence?.match || evidence?.description || action.input;
168
263
  if (tag === 'DANGEROUS_COMMAND') {
169
264
  return reason('DESTRUCTIVE_COMMAND', 'critical', 'Dangerous command', 'The local OSS runtime detected a dangerous command.', evidenceText);
170
265
  }
266
+ if (tag === 'DESTRUCTIVE_FILE_OPERATION') {
267
+ return reason('DESTRUCTIVE_FILE_OPERATION', 'high', 'Destructive file operation', 'The local OSS runtime detected a destructive file operation.', evidenceText);
268
+ }
269
+ if (tag === 'SYSTEM_PATH_MUTATION') {
270
+ return reason('SYSTEM_PATH_MUTATION', 'critical', 'System path mutation', 'The local OSS runtime detected a mutation of a protected system path.', evidenceText);
271
+ }
272
+ if (tag === 'SYSTEM_PATH_ACCESS') {
273
+ return reason('SYSTEM_PATH_ACCESS', 'high', 'System path access', 'The local OSS runtime detected access to a protected system path.', evidenceText);
274
+ }
275
+ if (tag === 'HIDDEN_NETWORK_COMMAND') {
276
+ return reason('HIDDEN_NETWORK_COMMAND', 'high', 'Hidden network command', 'The local OSS runtime detected a network command hidden inside a wrapper.', evidenceText);
277
+ }
171
278
  if (tag === 'SENSITIVE_DATA_ACCESS' || tag === 'SENSITIVE_ENV_VAR') {
172
279
  return reason('SECRET_ACCESS', 'high', 'Sensitive data access', 'The local OSS runtime detected access to sensitive data.', evidenceText);
173
280
  }
@@ -182,44 +289,201 @@ function normalizeOssReason(tag, evidence, action) {
182
289
  }
183
290
  return reason(tag, 'medium', tag.replace(/_/g, ' ').toLowerCase(), 'The local OSS runtime detected a risky action.', evidenceText);
184
291
  }
292
+ const BEHAVIOR_ANOMALY_CODES = new Set([
293
+ 'NETWORK_RATE_LIMIT',
294
+ 'NETWORK_TOKEN_DOMAIN_SWEEP',
295
+ 'NETWORK_ODD_HOUR_ACTIVITY',
296
+ 'NETWORK_REPLAY',
297
+ 'NETWORK_LARGE_RESPONSE',
298
+ 'NETWORK_DOS_RESPONSE',
299
+ ]);
300
+ const RESPONSE_ANOMALY_CODES = new Set([
301
+ 'RESPONSE_XSS_ECHO',
302
+ 'RESPONSE_ERROR_DISCLOSURE',
303
+ 'RESPONSE_MALICIOUS_SCRIPT',
304
+ 'RESPONSE_PATH_TRAVERSAL',
305
+ 'RESPONSE_CONTENT_TYPE_MISMATCH',
306
+ 'RESPONSE_CREDENTIAL_ECHO',
307
+ ]);
308
+ function networkTargetsFromAction(action) {
309
+ if (action.actionType === 'network' || action.actionType === 'browser') {
310
+ const target = parseNetworkTarget(action.input);
311
+ return target ? [target] : [];
312
+ }
313
+ if (action.actionType !== 'shell')
314
+ return [];
315
+ const targets = new Map();
316
+ for (const reference of extractNetworkReferences(action.input)) {
317
+ const target = parseNetworkTarget(reference);
318
+ if (target)
319
+ targets.set(`${target.hostname}${target.pathname}`, target);
320
+ }
321
+ return [...targets.values()];
322
+ }
323
+ function networkBehaviorReasons(action, targets) {
324
+ const timestamp = timeFromMetadata(action.metadata?.timestamp) ?? Date.now();
325
+ loadNetworkBehaviorState(timestamp);
326
+ pruneNetworkBehaviorEvents(timestamp);
327
+ const method = methodFromMetadata(action.metadata?.method);
328
+ const headers = recordFromMetadata(action.metadata?.headers) ?? recordFromMetadata(action.metadata?.requestHeaders);
329
+ const bodyPreview = stringFromMetadata(action.metadata?.bodyPreview);
330
+ const responseBytes = numberFromMetadata(action.metadata?.responseBodyBytes, action.metadata?.responseBytes, action.metadata?.contentLength);
331
+ const responseStatus = numberFromMetadata(action.metadata?.responseStatusCode, action.metadata?.statusCode, action.metadata?.status);
332
+ const tokenHashes = extractCredentialValues(action.input, headers, bodyPreview).map(hashValue);
333
+ const fingerprint = requestFingerprint(action.input, targets[0], method, headers, bodyPreview);
334
+ for (const target of targets) {
335
+ networkBehaviorEvents.push({
336
+ timestamp,
337
+ sessionId: action.sessionId,
338
+ hostname: target.hostname,
339
+ method,
340
+ fingerprint,
341
+ tokenHashes,
342
+ responseBytes,
343
+ responseStatus,
344
+ });
345
+ }
346
+ const reasons = [];
347
+ const sessionRecent = networkBehaviorEvents.filter((event) => event.sessionId === action.sessionId &&
348
+ timestamp - event.timestamp <= ONE_MINUTE_MS);
349
+ if (sessionRecent.length > HIGH_FREQUENCY_THRESHOLD) {
350
+ reasons.push(reason('NETWORK_RATE_LIMIT', 'high', 'High-frequency network activity', 'The agent made more than 100 outbound requests in one minute.', `${sessionRecent.length} requests in 60s`));
351
+ }
352
+ if (isOddHour(timestamp) && sessionRecent.length > ODD_HOUR_FREQUENCY_THRESHOLD) {
353
+ reasons.push(reason('NETWORK_ODD_HOUR_ACTIVITY', 'high', 'Odd-hour network burst', 'The agent made a high-frequency network burst during the 02:00-06:00 local risk window.', `${sessionRecent.length} requests in 60s`));
354
+ }
355
+ if (fingerprint) {
356
+ const repeats = sessionRecent.filter((event) => event.fingerprint === fingerprint).length;
357
+ if (repeats >= REPLAY_THRESHOLD) {
358
+ reasons.push(reason('NETWORK_REPLAY', 'high', 'Repeated identical request', 'The same request body and headers appeared repeatedly in a short window.', `${repeats} repeats in 60s`));
359
+ }
360
+ }
361
+ for (const tokenHash of tokenHashes) {
362
+ const domains = new Set(networkBehaviorEvents
363
+ .filter((event) => event.sessionId === action.sessionId &&
364
+ timestamp - event.timestamp <= TEN_MINUTES_MS &&
365
+ event.tokenHashes.includes(tokenHash))
366
+ .map((event) => event.hostname));
367
+ if (domains.size > TOKEN_DOMAIN_THRESHOLD) {
368
+ reasons.push(reason('NETWORK_TOKEN_DOMAIN_SWEEP', 'high', 'Credential used across many domains', 'The same credential-like value was used against more than 10 distinct domains.', `${domains.size} domains in 10m`));
369
+ break;
370
+ }
371
+ }
372
+ const responseBytesTotal = sessionRecent.reduce((total, event) => total + (event.responseBytes ?? 0), 0);
373
+ if ((responseBytes !== undefined && responseBytes > SINGLE_RESPONSE_BYTES_THRESHOLD) ||
374
+ responseBytesTotal > WINDOW_RESPONSE_BYTES_THRESHOLD) {
375
+ reasons.push(reason('NETWORK_LARGE_RESPONSE', 'high', 'Large network response volume', 'The network response volume crossed the runtime anomaly threshold.', responseBytes !== undefined ? `${responseBytes} bytes` : `${responseBytesTotal} bytes in 60s`));
376
+ }
377
+ if (responseStatus === 429 || responseStatus === 503) {
378
+ const statusRepeats = sessionRecent.filter((event) => event.responseStatus === responseStatus).length;
379
+ if (statusRepeats >= DOS_STATUS_THRESHOLD) {
380
+ reasons.push(reason('NETWORK_DOS_RESPONSE', 'high', 'Repeated throttling or service unavailable responses', 'The agent received repeated 429/503 responses in a short window.', `${statusRepeats} responses with status ${responseStatus} in 60s`));
381
+ }
382
+ }
383
+ saveNetworkBehaviorState();
384
+ return dedupeReasons(reasons);
385
+ }
386
+ function networkResponseReasons(action, target) {
387
+ const responseBody = firstStringFromMetadata(action.metadata?.responseBodyPreview, action.metadata?.responsePreview, action.metadata?.responseBody);
388
+ const responseHeaders = recordFromMetadata(action.metadata?.responseHeaders);
389
+ const contentType = (firstStringFromMetadata(action.metadata?.responseContentType, contentTypeFromHeaders(responseHeaders)) ?? '').toLowerCase();
390
+ if (!responseBody)
391
+ return [];
392
+ const reasons = [];
393
+ if (hasSuspiciousXssResponse(responseBody)) {
394
+ reasons.push(reason('RESPONSE_XSS_ECHO', 'high', 'Executable markup in response', 'The network response contains script-like markup or JavaScript URL content.', target.hostname));
395
+ }
396
+ if (/sql syntax|mysql_fetch|postgresql|ora-\d+|traceback|stack trace|exception|at .+:\d+:\d+/i.test(responseBody)) {
397
+ reasons.push(reason('RESPONSE_ERROR_DISCLOSURE', 'high', 'Server error disclosure in response', 'The network response contains SQL, command, or stack-trace style error output.', target.hostname));
398
+ }
399
+ if (/eval\s*\(\s*(?:atob|unescape)|fromcharcode|document\.write\s*\(/i.test(responseBody)) {
400
+ reasons.push(reason('RESPONSE_MALICIOUS_SCRIPT', 'critical', 'Obfuscated script in response', 'The network response contains script patterns commonly used for payload staging.', target.hostname));
401
+ }
402
+ if (/root:.*:0:0:|\/etc\/passwd|c:\\windows\\|windows\\system32/i.test(responseBody)) {
403
+ reasons.push(reason('RESPONSE_PATH_TRAVERSAL', 'critical', 'Path traversal content in response', 'The network response contains filesystem markers associated with traversal or local file disclosure.', target.hostname));
404
+ }
405
+ if (contentType && isBinaryOrMediaContentType(contentType) && looksLikeHtmlOrScript(responseBody)) {
406
+ reasons.push(reason('RESPONSE_CONTENT_TYPE_MISMATCH', 'critical', 'Response content type mismatch', 'The response body looks executable or HTML-like while the Content-Type claims binary/media content.', target.hostname));
407
+ }
408
+ const requestSecrets = extractCredentialValues(action.input, recordFromMetadata(action.metadata?.headers) ?? recordFromMetadata(action.metadata?.requestHeaders), stringFromMetadata(action.metadata?.bodyPreview));
409
+ if (requestSecrets.some((secret) => secret.length >= 8 && responseBody.includes(secret))) {
410
+ reasons.push(reason('RESPONSE_CREDENTIAL_ECHO', 'critical', 'Credential echoed in response', 'The response appears to echo a credential-like value from the request.', target.hostname));
411
+ }
412
+ return dedupeReasons(reasons);
413
+ }
185
414
  function decisionFor(policy, reasons, riskLevel, ossDecision) {
415
+ const policyDecisions = [];
186
416
  for (const item of reasons) {
187
- if (item.code === 'NETWORK_OUTBOUND')
188
- continue;
189
- const decision = policyDecisionFor(item.code, policy);
417
+ const decision = policyDecisionFor(item, policy);
190
418
  if (decision)
191
- return decision;
419
+ policyDecisions.push(decision);
192
420
  }
193
- if (ossDecision === 'deny')
194
- return riskLevel === 'critical' ? 'block' : 'require_approval';
195
- if (ossDecision === 'confirm')
196
- return 'require_approval';
197
- for (const item of reasons) {
198
- if (item.code !== 'NETWORK_OUTBOUND')
199
- continue;
200
- const decision = policyDecisionFor(item.code, policy);
201
- if (decision)
202
- return decision;
421
+ const strongestPolicyDecision = strongestDecision(policyDecisions);
422
+ const scannerDecision = scannerPolicyDecision(ossDecision, riskLevel);
423
+ if (strongestPolicyDecision) {
424
+ if (scannerDecision &&
425
+ DECISION_ORDER[strongestPolicyDecision] <= DECISION_ORDER.warn &&
426
+ DECISION_ORDER[scannerDecision] > DECISION_ORDER[strongestPolicyDecision]) {
427
+ return scannerDecision;
428
+ }
429
+ return strongestPolicyDecision;
203
430
  }
431
+ if (scannerDecision)
432
+ return scannerDecision;
204
433
  if (reasons.length > 0)
205
434
  return 'warn';
206
435
  return 'allow';
207
436
  }
208
- function policyDecisionFor(code, policy) {
437
+ function policyDecisionFor(reasonItem, policy) {
438
+ const code = reasonItem.code;
209
439
  if (code === 'CUSTOM_BLOCKED_COMMAND' || code === 'DESTRUCTIVE_COMMAND')
210
440
  return policy.decisions.destructiveCommand;
441
+ if (code === 'DESTRUCTIVE_FILE_OPERATION')
442
+ return 'require_approval';
443
+ if (code === 'SYSTEM_PATH_MUTATION')
444
+ return 'block';
445
+ if (code === 'SYSTEM_PATH_ACCESS')
446
+ return 'require_approval';
447
+ if (code === 'HIDDEN_NETWORK_COMMAND')
448
+ return 'require_approval';
211
449
  if (code === 'REMOTE_CODE_EXECUTION')
212
450
  return policy.decisions.remoteCodeExecution;
213
451
  if (code === 'CUSTOM_BLOCKED_DOMAIN' || code === 'DATA_EXFILTRATION')
214
452
  return policy.decisions.dataExfiltration;
215
453
  if (code === 'NETWORK_OUTBOUND')
216
454
  return policy.network.defaultOutbound;
455
+ if (BEHAVIOR_ANOMALY_CODES.has(code))
456
+ return policy.network.behaviorAnomaly ?? 'require_approval';
457
+ if (RESPONSE_ANOMALY_CODES.has(code)) {
458
+ return policy.network.responseAnomaly ?? (reasonItem.severity === 'critical' ? 'block' : 'require_approval');
459
+ }
217
460
  if (code === 'SECRET_ACCESS')
218
461
  return policy.decisions.secretAccess;
219
462
  if (code === 'DEPLOYMENT_ACTION')
220
463
  return policy.decisions.deployAction;
221
464
  return null;
222
465
  }
466
+ const DECISION_ORDER = {
467
+ allow: 0,
468
+ warn: 1,
469
+ require_approval: 2,
470
+ block: 3,
471
+ };
472
+ function strongestDecision(decisions) {
473
+ let strongest = null;
474
+ for (const decision of decisions) {
475
+ if (!strongest || DECISION_ORDER[decision] > DECISION_ORDER[strongest])
476
+ strongest = decision;
477
+ }
478
+ return strongest;
479
+ }
480
+ function scannerPolicyDecision(ossDecision, riskLevel) {
481
+ if (ossDecision === 'deny')
482
+ return riskLevel === 'critical' ? 'block' : 'require_approval';
483
+ if (ossDecision === 'confirm')
484
+ return 'require_approval';
485
+ return null;
486
+ }
223
487
  function riskScoreFor(reasons, ossRiskLevel) {
224
488
  if (reasons.some((item) => item.severity === 'critical') || ossRiskLevel === 'critical')
225
489
  return 95;
@@ -248,11 +512,22 @@ function shouldAutoAllowRuntimeDecision(riskScore, riskLevel) {
248
512
  function matchesPattern(input, pattern) {
249
513
  if (!pattern)
250
514
  return false;
515
+ if (isRootRmRfPattern(pattern))
516
+ return isRootRmRfCommand(input);
251
517
  if (input.includes(pattern))
252
518
  return true;
253
519
  const compact = pattern.replace(/\s*\.\.\.\s*/g, ' ');
254
520
  return compact !== pattern && input.includes(compact);
255
521
  }
522
+ function isRootRmRfPattern(pattern) {
523
+ return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/$/.test(pattern) ||
524
+ /^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/$/.test(pattern);
525
+ }
526
+ function isRootRmRfCommand(input) {
527
+ const normalized = normalizeCommand(input);
528
+ return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/\s*$/.test(normalized) ||
529
+ /^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/\s*$/.test(normalized);
530
+ }
256
531
  function matchesAllowedCommand(input, pattern) {
257
532
  const trimmedInput = input.trim();
258
533
  const trimmedPattern = pattern.trim();
@@ -274,27 +549,240 @@ function matchesAllowedCommand(input, pattern) {
274
549
  (!inputHasControl && normalizedInput.startsWith(`${normalizedPattern} `));
275
550
  }
276
551
  function matchesNetworkTarget(input, pattern) {
277
- const normalizedPattern = pattern.trim().toLowerCase();
278
- if (!normalizedPattern)
552
+ const target = parseNetworkTarget(input);
553
+ const matcher = parseNetworkPattern(pattern);
554
+ if (!target || !matcher)
279
555
  return false;
280
- const normalizedInput = input.trim().toLowerCase();
281
- if (normalizedInput.includes(normalizedPattern))
282
- return true;
283
- const domain = (0, patterns_js_1.extractDomain)(input);
284
- if (!domain)
556
+ if (!(0, patterns_js_1.domainMatchesPattern)(target.hostname, matcher.hostname))
285
557
  return false;
286
- if (!normalizedPattern.includes('/')) {
287
- return (0, patterns_js_1.domainMatchesPattern)(domain.toLowerCase(), normalizedPattern);
558
+ if (!matcher.pathname)
559
+ return true;
560
+ return target.pathname === matcher.pathname ||
561
+ target.pathname.startsWith(`${matcher.pathname.replace(/\/+$/, '')}/`);
562
+ }
563
+ function matchesNetworkReference(input, pattern) {
564
+ if (matchesNetworkTarget(input, pattern))
565
+ return true;
566
+ return extractNetworkReferences(input).some((reference) => matchesNetworkTarget(reference, pattern));
567
+ }
568
+ function parseNetworkTarget(value) {
569
+ const trimmed = trimNetworkToken(value);
570
+ if (!trimmed)
571
+ return null;
572
+ const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
573
+ ? trimmed
574
+ : `https://${trimmed}`;
575
+ try {
576
+ const parsed = new URL(urlLike);
577
+ return {
578
+ hostname: parsed.hostname.toLowerCase(),
579
+ pathname: normalizeNetworkPath(parsed.pathname),
580
+ };
581
+ }
582
+ catch {
583
+ return null;
288
584
  }
585
+ }
586
+ function parseNetworkPattern(value) {
587
+ const trimmed = trimNetworkToken(value).toLowerCase();
588
+ if (!trimmed)
589
+ return null;
590
+ const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
591
+ ? trimmed
592
+ : `https://${trimmed}`;
289
593
  try {
290
- const parsed = new URL(input);
291
- const hostAndPath = `${parsed.hostname}${parsed.pathname}`.toLowerCase();
292
- return hostAndPath === normalizedPattern ||
293
- hostAndPath.startsWith(`${normalizedPattern.replace(/\/+$/, '')}/`);
594
+ const parsed = new URL(urlLike);
595
+ return {
596
+ hostname: parsed.hostname.toLowerCase(),
597
+ pathname: parsed.pathname && parsed.pathname !== '/' ? normalizeNetworkPath(parsed.pathname) : null,
598
+ };
294
599
  }
295
600
  catch {
296
- return false;
601
+ return null;
602
+ }
603
+ }
604
+ function extractNetworkReferences(input) {
605
+ const references = new Set();
606
+ for (const match of input.matchAll(/https?:\/\/[^\s'"<>`]+/gi)) {
607
+ references.add(trimNetworkToken(match[0]));
608
+ }
609
+ for (const match of input.matchAll(/\b[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}(?:\/[^\s'"<>`]*)?/gi)) {
610
+ references.add(trimNetworkToken(match[0]));
611
+ }
612
+ return [...references].filter(Boolean);
613
+ }
614
+ function trimNetworkToken(value) {
615
+ return value.trim().replace(/[),.;\]]+$/g, '');
616
+ }
617
+ function normalizeNetworkPath(pathname) {
618
+ if (!pathname || pathname === '/')
619
+ return '/';
620
+ return pathname.replace(/\/+$/g, '') || '/';
621
+ }
622
+ function behaviorStatePath() {
623
+ return process.env.AGENTGUARD_BEHAVIOR_STATE_PATH ||
624
+ (0, node_path_1.join)((0, config_js_1.getAgentGuardPaths)().home, 'network-behavior.json');
625
+ }
626
+ function loadNetworkBehaviorState(now) {
627
+ if (networkBehaviorStateLoaded)
628
+ return;
629
+ networkBehaviorStateLoaded = true;
630
+ const statePath = behaviorStatePath();
631
+ try {
632
+ if (!(0, node_fs_1.existsSync)(statePath))
633
+ return;
634
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(statePath, 'utf8'));
635
+ if (!Array.isArray(parsed))
636
+ return;
637
+ const events = [];
638
+ for (const item of parsed) {
639
+ const event = parseNetworkBehaviorEvent(item);
640
+ if (event && now - event.timestamp <= TEN_MINUTES_MS)
641
+ events.push(event);
642
+ }
643
+ networkBehaviorEvents.push(...events);
644
+ }
645
+ catch {
646
+ networkBehaviorEvents.length = 0;
647
+ }
648
+ }
649
+ function saveNetworkBehaviorState() {
650
+ const statePath = behaviorStatePath();
651
+ try {
652
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(statePath), { recursive: true, mode: 0o700 });
653
+ const events = networkBehaviorEvents.slice(-MAX_PERSISTED_BEHAVIOR_EVENTS);
654
+ (0, node_fs_1.writeFileSync)(statePath, `${JSON.stringify(events)}\n`, { mode: 0o600 });
655
+ (0, node_fs_1.chmodSync)(statePath, 0o600);
297
656
  }
657
+ catch {
658
+ // Behavior state is best-effort; runtime protection still works without it.
659
+ }
660
+ }
661
+ function parseNetworkBehaviorEvent(value) {
662
+ if (!value || typeof value !== 'object' || Array.isArray(value))
663
+ return null;
664
+ const record = value;
665
+ if (typeof record.timestamp !== 'number' ||
666
+ typeof record.sessionId !== 'string' ||
667
+ typeof record.hostname !== 'string' ||
668
+ typeof record.method !== 'string') {
669
+ return null;
670
+ }
671
+ return {
672
+ timestamp: record.timestamp,
673
+ sessionId: record.sessionId,
674
+ hostname: record.hostname,
675
+ method: record.method,
676
+ fingerprint: typeof record.fingerprint === 'string' ? record.fingerprint : undefined,
677
+ tokenHashes: Array.isArray(record.tokenHashes)
678
+ ? record.tokenHashes.filter((item) => typeof item === 'string')
679
+ : [],
680
+ responseBytes: typeof record.responseBytes === 'number' ? record.responseBytes : undefined,
681
+ responseStatus: typeof record.responseStatus === 'number' ? record.responseStatus : undefined,
682
+ };
683
+ }
684
+ function pruneNetworkBehaviorEvents(now) {
685
+ const cutoff = now - TEN_MINUTES_MS;
686
+ while (networkBehaviorEvents.length > 0 && networkBehaviorEvents[0].timestamp < cutoff) {
687
+ networkBehaviorEvents.shift();
688
+ }
689
+ }
690
+ function isOddHour(timestamp) {
691
+ const hour = new Date(timestamp).getHours();
692
+ return hour >= 2 && hour < 6;
693
+ }
694
+ function requestFingerprint(input, target, method, headers, bodyPreview) {
695
+ if (!headers && !bodyPreview)
696
+ return undefined;
697
+ return hashValue(JSON.stringify({
698
+ method,
699
+ host: target.hostname,
700
+ path: target.pathname,
701
+ input,
702
+ headers: stableRecordString(headers),
703
+ bodyPreview,
704
+ }));
705
+ }
706
+ function extractCredentialValues(input, headers, bodyPreview) {
707
+ const values = new Set();
708
+ collectUrlCredentialValues(input, values);
709
+ if (headers) {
710
+ for (const [key, value] of Object.entries(headers)) {
711
+ if (!/(authorization|api[-_]?key|access[-_]?token|token|secret)/i.test(key))
712
+ continue;
713
+ const text = Array.isArray(value) ? value.join(',') : String(value ?? '');
714
+ for (const item of text.matchAll(/[A-Za-z0-9._~+/=-]{8,}/g)) {
715
+ values.add(item[0]);
716
+ }
717
+ }
718
+ }
719
+ if (bodyPreview) {
720
+ for (const match of bodyPreview.matchAll(/(?:api[-_]?key|access[-_]?token|token|authorization|secret)["':=\s]+([A-Za-z0-9._~+/=-]{8,})/gi)) {
721
+ values.add(match[1]);
722
+ }
723
+ }
724
+ return [...values];
725
+ }
726
+ function collectUrlCredentialValues(input, values) {
727
+ for (const reference of extractNetworkReferences(input).length > 0 ? extractNetworkReferences(input) : [input]) {
728
+ const token = trimNetworkToken(reference);
729
+ if (!token)
730
+ continue;
731
+ const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(token) ? token : `https://${token}`;
732
+ try {
733
+ const parsed = new URL(urlLike);
734
+ for (const [key, value] of parsed.searchParams.entries()) {
735
+ if (/(api[-_]?key|access[-_]?token|token|authorization|secret|auth|key)/i.test(key) && value.length >= 8) {
736
+ values.add(value);
737
+ }
738
+ }
739
+ }
740
+ catch {
741
+ // Ignore malformed references.
742
+ }
743
+ }
744
+ }
745
+ function stableRecordString(record) {
746
+ if (!record)
747
+ return undefined;
748
+ return Object.keys(record)
749
+ .sort((a, b) => a.localeCompare(b))
750
+ .map((key) => `${key.toLowerCase()}:${String(record[key])}`)
751
+ .join('\n');
752
+ }
753
+ function hashValue(value) {
754
+ return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
755
+ }
756
+ function contentTypeFromHeaders(headers) {
757
+ if (!headers)
758
+ return undefined;
759
+ for (const [key, value] of Object.entries(headers)) {
760
+ if (key.toLowerCase() === 'content-type' && typeof value === 'string')
761
+ return value;
762
+ }
763
+ return undefined;
764
+ }
765
+ function isBinaryOrMediaContentType(contentType) {
766
+ return /^(image|audio|video)\//i.test(contentType) ||
767
+ /application\/(?:octet-stream|pdf|zip|gzip|x-tar)/i.test(contentType);
768
+ }
769
+ function hasSuspiciousXssResponse(body) {
770
+ return /javascript:\s*(?:alert|confirm|prompt|eval|fetch|\w+\()/i.test(body) ||
771
+ /on(?:error|load|click|mouseover|focus)\s*=\s*["']?(?:alert|confirm|prompt|eval|fetch|javascript:)/i.test(body) ||
772
+ /<script\b(?![^>]*\bsrc=)[^>]*>[\s\S]{0,500}?(?:alert|confirm|prompt|document\.cookie|localStorage|eval|fetch\s*\()/i.test(body);
773
+ }
774
+ function looksLikeHtmlOrScript(body) {
775
+ return /^\s*(?:<!doctype\s+html|<html\b|<script\b)/i.test(body) ||
776
+ /javascript:|<script\b/i.test(body);
777
+ }
778
+ function dedupeReasons(items) {
779
+ const seen = new Set();
780
+ return items.filter((item) => {
781
+ if (seen.has(item.code))
782
+ return false;
783
+ seen.add(item.code);
784
+ return true;
785
+ });
298
786
  }
299
787
  function normalizeCommand(value) {
300
788
  return value.trim().replace(/\s+/g, ' ').toLowerCase();