Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
### Changed
- Web search actions now use a dedicated `web_search` runtime action across Claude Code, Hermes, OpenClaw, MCP, and the skill CLI, so query-only searches are handled separately from URL fetches and no longer trigger invalid-URL network approval flows.
- Direct web fetch and browser navigation GET requests keep the default `network.defaultOutbound: warn` behavior as audit-only, while mutating or high-risk network requests still require confirmation or blocking.
- Network request decisions now treat GET/HEAD/OPTIONS as low-risk reads, keep non-sensitive POST/PUT/PATCH requests at audit-level risk, require approval for DELETE, and warn when a cached policy uses an interruptive `network.defaultOutbound`.
- Runtime network evaluation now detects local behavior and response anomalies including request bursts, token domain sweeps, replayed requests, odd-hour bursts, large responses, malicious response bodies, MIME mismatches, and credential echo.
- `agentguard connect` and `agentguard subscribe` now support Hermes Agent JWT registration when Hermes is initialized or detected via `HERMES_HOME`/`~/.hermes`, while preserving the existing OpenClaw notification behavior.

### Fixed
- `agentguard init --agent hermes` now targets `HERMES_HOME` or `~/.hermes` for explicit installs instead of creating a nested `.hermes` directory under the current working directory, while only updating the root Hermes config and profile configs.
- Runtime network policies now enforce `network.defaultOutbound` and `network.blockedDomains` for direct network/browser tool calls instead of only checking shell commands.
- Runtime blocked-domain matching now compares structured URL hosts and paths instead of raw substrings, avoiding false positives such as `notexample.com` matching `example.com`; curl/wget download-and-execute commands are detected with real regex patterns.
- Hermes hook templates now split `web_search` from URL-bearing web/browser tools and recognize open-style URL tools consistently.

## [1.1.27] - 2026-05-29
Expand Down
13 changes: 12 additions & 1 deletion skills/agentguard/scripts/hermes-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,18 @@ async function main() {

if (isPostHook(input)) {
try {
if (createAgentGuard && HermesAdapter && evaluateHook) {
if (protectAction) {
const config = loadRuntimeConfig();
await protectAction({
config,
rawInput: input,
agentHost: 'hermes',
actionType: runtimeActionTypeFrom(toolNameFrom(input)),
toolName: runtimeToolNameFrom(toolNameFrom(input)),
sessionId: typeof input.session_id === 'string' ? input.session_id : undefined,
phase: 'post',
});
} else if (createAgentGuard && HermesAdapter && evaluateHook) {
const adapter = new HermesAdapter();
const config = loadHookConfig ? loadHookConfig() : { level: loadRuntimeConfig().level };
const agentguard = createAgentGuard();
Expand Down
28 changes: 24 additions & 4 deletions src/action/detectors/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ const DANGEROUS_COMMANDS = [
'chmod -R 777',
'> /dev/sda',
'mv /* ',
'wget.*\\|.*sh',
'curl.*\\|.*sh',
'curl.*\\|.*bash',
'wget.*\\|.*bash',
];

const DOWNLOAD_AND_EXEC_PATTERNS = [
/\b(?:curl|wget)\b(?:(?!&&|\|\||;|\n|\r).)*\|\s*(?:sudo\s+)?(?:bash|sh)\b/i,
/\b(?:bash|sh)\s+<\s*\(\s*(?:curl|wget)\b[^;)\n\r]*\)/i,
/\beval\s+["']?\$\(\s*(?:curl|wget)\b[^;)\n\r]*\)/i,
];

/**
Expand Down Expand Up @@ -194,6 +196,24 @@ export function analyzeExecCommand(
}
}

if (riskLevel !== 'critical') {
for (const pattern of DOWNLOAD_AND_EXEC_PATTERNS) {
if (pattern.test(fullCommand)) {
riskTags.push('DANGEROUS_COMMAND');
evidence.push({
type: 'dangerous_command',
field: 'command',
match: 'download-and-execute',
description: 'Remote download piped or substituted into a shell',
});
riskLevel = 'critical';
shouldBlock = true;
blockReason = 'Dangerous command: remote download executed by shell';
break;
}
}
}

// Safe command check: if not dangerous, no shell metacharacters, and no sensitive paths, allow
if (riskLevel !== 'critical' && !SHELL_METACHAR_PATTERN.test(fullCommand)) {
const hasSensitivePath = SENSITIVE_COMMANDS.some(s => lowerCommand.includes(s.toLowerCase()));
Expand Down
68 changes: 59 additions & 9 deletions src/action/detectors/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export interface NetworkAnalysisResult {
block_reason?: string;
}

type NetworkRiskLevel = NetworkAnalysisResult['risk_level'];
type NetworkMethod = NetworkRequestData['method'];

/**
* Known webhook/exfiltration domains
*/
Expand Down Expand Up @@ -67,9 +70,12 @@ export function analyzeNetworkRequest(
): NetworkAnalysisResult {
const riskTags: string[] = [];
const evidence: ActionEvidence[] = [];
let riskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low';
let riskLevel: NetworkRiskLevel = 'low';
let shouldBlock = false;
let blockReason: string | undefined;
const method = normalizeMethod(request.method);
const readOnlyMethod = isReadOnlyMethod(method);
const mutatingMethod = method === 'POST' || method === 'PUT' || method === 'PATCH';

// Extract domain
const domain = extractDomain(request.url);
Expand Down Expand Up @@ -130,7 +136,7 @@ export function analyzeNetworkRequest(
}

// Check for untrusted domain
if (!isAllowed && !isWebhook) {
if (!isAllowed && !isWebhook && !readOnlyMethod) {
riskTags.push('UNTRUSTED_DOMAIN');
evidence.push({
type: 'untrusted_domain',
Expand Down Expand Up @@ -173,13 +179,26 @@ export function analyzeNetworkRequest(
}
}

// POST/PUT to untrusted domain is higher risk
if (
(request.method === 'POST' || request.method === 'PUT') &&
!isAllowed &&
riskLevel === 'medium'
) {
riskLevel = 'high';
if (method === 'DELETE') {
riskTags.push('DESTRUCTIVE_HTTP_METHOD');
evidence.push({
type: 'destructive_http_method',
field: 'method',
match: method,
description: 'DELETE requests can remove remote resources',
});
riskLevel = maxRisk(riskLevel, 'high');
}

if (mutatingMethod && !isAllowed && !riskTags.includes('CRITICAL_SECRET_EXFIL')) {
riskTags.push('MUTATING_UNTRUSTED_REQUEST');
evidence.push({
type: 'mutating_untrusted_request',
field: 'method',
match: method,
description: `${method} request to a non-allowlisted domain`,
});
riskLevel = maxRisk(riskLevel, 'medium');
}

return {
Expand All @@ -190,3 +209,34 @@ export function analyzeNetworkRequest(
block_reason: blockReason,
};
}

function normalizeMethod(method: string | undefined): NetworkMethod {
const normalized = method?.toUpperCase();
switch (normalized) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
case 'POST':
case 'PUT':
case 'DELETE':
case 'PATCH':
return normalized;
default:
return 'GET';
}
}

function isReadOnlyMethod(method: NetworkMethod): boolean {
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
}

const RISK_ORDER: Record<NetworkRiskLevel, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};

function maxRisk(current: NetworkRiskLevel, next: NetworkRiskLevel): NetworkRiskLevel {
return RISK_ORDER[next] > RISK_ORDER[current] ? next : current;
}
13 changes: 13 additions & 0 deletions src/adapters/openclaw-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,19 @@ export function registerOpenClawPlugin(
const input = adapter.parseInput(event);
const toolName = readOpenClawToolName(event);
const pluginId = toolName ? getPluginIdFromTool(toolName) : null;
if (runtimeProtectionEnabled) {
const runtimeResult = await runProtectAction({
config,
rawInput: event,
agentHost: 'openclaw',
actionType: mapOpenClawToolToRuntimeAction(toolName, event),
toolName,
sessionId: readOpenClawSessionId(event, undefined),
decisionMode: options.decisionMode ?? 'local-first',
phase: 'post',
});
if (runtimeResult) return;
}
writeAuditLog(input, null, pluginId);
} catch {
// Non-critical
Expand Down
8 changes: 8 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ async function main() {
source,
cachePath: config.policyCachePath,
policy: shownPolicy,
networkPolicyWarning: networkDefaultOutboundWarning(shownPolicy.network.defaultOutbound),
}, null, 2));
return;
}
Expand All @@ -318,6 +319,8 @@ async function main() {
console.log(`Network default outbound: ${shownPolicy.network.defaultOutbound}`);
console.log(`Blocked domains: ${shownPolicy.network.blockedDomains.length}`);
console.log(`Approval domains: ${shownPolicy.network.approvalDomains.length}`);
const networkWarning = networkDefaultOutboundWarning(shownPolicy.network.defaultOutbound);
if (networkWarning) console.log(`! ${networkWarning}`);
});

program
Expand Down Expand Up @@ -984,6 +987,11 @@ function printCloudAuthStatus(config: AgentGuardConfig): void {
console.log('Agent JWT: not configured');
}

function networkDefaultOutboundWarning(value: string): string | undefined {
if (value !== 'block' && value !== 'require_approval') return undefined;
return `network.defaultOutbound is ${value}; ordinary external GET/HEAD/OPTIONS requests may be interrupted unless domains are explicitly allowed.`;
}

async function printSubscribeConnectRequired(
options: { json?: boolean; cronNotifyRun?: boolean },
notifyOpenClaw: boolean
Expand Down
Loading
Loading