From de613c7f128992038027137d5755e0aac4e5255a Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Mon, 8 Jun 2026 15:58:26 +0800 Subject: [PATCH] fix: separate runtime protected paths from file allowlist --- CHANGELOG.md | 1 + src/adapters/openclaw-plugin.ts | 2 ++ src/runtime/evaluator.ts | 37 ++++++++++++++++++++++++--------- src/runtime/protect.ts | 5 ++++- src/runtime/types.ts | 1 + src/tests/feed-cron.test.ts | 2 +- src/tests/integration.test.ts | 30 ++++++++++++++++++++++++++ src/tests/runtime-cloud.test.ts | 31 +++++++++++++++++++++++++++ 8 files changed, 97 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ca174..4e4a887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - `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 +- Runtime file protection now keeps `protectedPaths` as a sensitive-path approval list instead of treating it as the general file allowlist, so ordinary workspace file reads and writes are no longer surfaced as `PATH_NOT_ALLOWED` under the default policy. - `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. diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 1087a56..5317419 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -454,6 +454,7 @@ export function registerOpenClawPlugin( toolName, sessionId: readOpenClawSessionId(event, ctx), decisionMode: options.decisionMode ?? 'local-first', + filesystemAllowlist: options.workspacePaths, }); const hookDecision = runtimeResultToBeforeToolCallResult(runtimeResult); if (hookDecision) { @@ -521,6 +522,7 @@ export function registerOpenClawPlugin( sessionId: readOpenClawSessionId(event, undefined), decisionMode: options.decisionMode ?? 'local-first', phase: 'post', + filesystemAllowlist: options.workspacePaths, }); if (runtimeResult) return; } diff --git a/src/runtime/evaluator.ts b/src/runtime/evaluator.ts index e55c1c1..c1a568e 100644 --- a/src/runtime/evaluator.ts +++ b/src/runtime/evaluator.ts @@ -45,6 +45,10 @@ interface NetworkBehaviorEvent { responseStatus?: number; } +export interface LocalActionEvaluationOptions { + filesystemAllowlist?: string[]; +} + const networkBehaviorEvents: NetworkBehaviorEvent[] = []; let networkBehaviorStateLoaded = false; @@ -79,7 +83,8 @@ function reason( export async function evaluateLocalAction( policy: EffectiveRuntimePolicy, - action: RuntimeAction + action: RuntimeAction, + options: LocalActionEvaluationOptions = {} ): Promise { if (isAllowedByCommandPolicy(policy, action)) { return { @@ -93,7 +98,7 @@ export async function evaluateLocalAction( } const customReasons = customPolicyReasons(policy, action); - const ossDecision = await evaluateWithOssActionScanner(policy, action); + const ossDecision = await evaluateWithOssActionScanner(policy, action, options); const ossReasons = (ossDecision?.risk_tags || []).map((tag, index) => normalizeOssReason(tag, ossDecision?.evidence?.[index], action) ); @@ -219,27 +224,32 @@ function customPolicyReasons(policy: EffectiveRuntimePolicy, action: RuntimeActi async function evaluateWithOssActionScanner( policy: EffectiveRuntimePolicy, - action: RuntimeAction + action: RuntimeAction, + options: LocalActionEvaluationOptions ) { const mapped = mapRuntimeAction(action); if (!mapped) return null; + const runtimeCapabilities = { + ...DEFAULT_CAPABILITY, + exec: 'allow' as const, + network_allowlist: policy.network.approvalDomains, + filesystem_allowlist: runtimeFilesystemAllowlist(policy, options), + }; const registry = { async lookup() { return { record: null, effective_trust_level: 'trusted', - effective_capabilities: { - ...DEFAULT_CAPABILITY, - exec: 'allow' as const, - network_allowlist: policy.network.approvalDomains, - filesystem_allowlist: policy.protectedPaths, - }, + effective_capabilities: runtimeCapabilities, }; }, }; - const scanner = new ActionScanner({ registry: registry as never }); + const scanner = new ActionScanner({ + registry: registry as never, + defaultCapabilities: runtimeCapabilities, + }); return scanner.decide({ actor: { skill: { @@ -260,6 +270,13 @@ async function evaluateWithOssActionScanner( }); } +function runtimeFilesystemAllowlist( + policy: EffectiveRuntimePolicy, + options: LocalActionEvaluationOptions +): string[] { + return options.filesystemAllowlist ?? policy.filesystemAllowlist ?? ['*']; +} + function mapRuntimeAction(action: RuntimeAction): { type: ActionType; data: ActionData } | null { if (action.actionType === 'shell') { return { type: 'exec_command', data: { command: action.input, cwd: action.cwd } }; diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 5136c67..a4159b6 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -19,6 +19,7 @@ export interface ProtectOptions { sessionId?: string; decisionMode?: 'local-first' | 'cloud'; phase?: 'pre' | 'post'; + filesystemAllowlist?: string[]; } export interface ProtectResult { @@ -51,7 +52,9 @@ export async function protectAction(options: ProtectOptions): Promise client.fetchEffectivePolicy() : undefined, }); - decision = normalizeRuntimeDecision(await evaluateLocalAction(policy, action)); + decision = normalizeRuntimeDecision(await evaluateLocalAction(policy, action, { + filesystemAllowlist: options.filesystemAllowlist, + })); policySource = source; } const approvedGrant = !postToolCall && decision.decision === 'require_approval' diff --git a/src/runtime/types.ts b/src/runtime/types.ts index d3eb825..60cc09e 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -45,6 +45,7 @@ export interface EffectiveRuntimePolicy { deployAction: CloudPolicyDecision; }; protectedPaths: string[]; + filesystemAllowlist?: string[]; blockedCommandPatterns: string[]; allowedCommandPatterns: string[]; approvalActionTypes: RuntimeActionType[]; diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index f822527..37f220d 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -765,7 +765,7 @@ describe('feed/cron', () => { host: '127.0.0.1', port: serverPort(server), token: 'gateway-test-token', - timeoutMs: 100, + timeoutMs: 1000, runCommand: async () => { throw new Error('explicit host/port should skip OpenClaw CLI'); }, diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index e8ee271..39772a9 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -307,6 +307,7 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { actionType?: string; toolName?: string; sessionId?: string; + filesystemAllowlist?: string[]; rawInput?: unknown; }; assert.equal(call.agentHost, 'openclaw'); @@ -348,6 +349,35 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { ]); }); + it('should pass OpenClaw workspace paths to runtime protection', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + const calls: unknown[] = []; + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + registry: ctx.agentguard.registry as never, + workspacePaths: ['/workspace/**'], + protectAction: async (options) => { + calls.push(options); + return null; + }, + }); + + await handlers['before_tool_call']({ + toolName: 'Read', + params: { path: '/workspace/src/index.ts' }, + }); + await handlers['after_tool_call']({ + toolName: 'Read', + params: { path: '/workspace/src/index.ts' }, + }); + + assert.deepEqual(calls.map((call) => (call as { filesystemAllowlist?: string[] }).filesystemAllowlist), [ + ['/workspace/**'], + ['/workspace/**'], + ]); + }); + it('should classify alternate OpenClaw tool name fields before runtime protection', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 93e91c3..dd1c85a 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -60,6 +60,37 @@ describe('Runtime Cloud bridge', () => { assert.ok(decision.reasons.some((reason) => reason.code === 'SECRET_ACCESS')); }); + it('allows ordinary workspace file reads under the default runtime policy', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'codex', + actionType: 'file_read', + toolName: 'Read', + input: '/workspace/src/index.ts', + }); + + assert.equal(decision.decision, 'allow'); + assert.equal(decision.riskLevel, 'safe'); + assert.ok(!decision.reasons.some((reason) => reason.code === 'PATH_NOT_ALLOWED')); + }); + + it('uses an explicit runtime filesystem allowlist separately from protected paths', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'file_read', + toolName: 'read', + input: '/tmp/outside-workspace.txt', + }, { + filesystemAllowlist: ['/workspace/**'], + }); + + assert.equal(decision.decision, 'require_approval'); + assert.ok(decision.reasons.some((reason) => reason.code === 'PATH_NOT_ALLOWED')); + }); + it('allows ordinary web search queries without treating them as URLs', async () => { const policy = getDefaultEffectiveRuntimePolicy(); const decision = await evaluateLocalAction(policy, {