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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/openclaw-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -521,6 +522,7 @@ export function registerOpenClawPlugin(
sessionId: readOpenClawSessionId(event, undefined),
decisionMode: options.decisionMode ?? 'local-first',
phase: 'post',
filesystemAllowlist: options.workspacePaths,
});
if (runtimeResult) return;
}
Expand Down
37 changes: 27 additions & 10 deletions src/runtime/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ interface NetworkBehaviorEvent {
responseStatus?: number;
}

export interface LocalActionEvaluationOptions {
filesystemAllowlist?: string[];
}

const networkBehaviorEvents: NetworkBehaviorEvent[] = [];
let networkBehaviorStateLoaded = false;

Expand Down Expand Up @@ -79,7 +83,8 @@ function reason(

export async function evaluateLocalAction(
policy: EffectiveRuntimePolicy,
action: RuntimeAction
action: RuntimeAction,
options: LocalActionEvaluationOptions = {}
): Promise<RuntimeDecision> {
if (isAllowedByCommandPolicy(policy, action)) {
return {
Expand All @@ -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)
);
Expand Down Expand Up @@ -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: {
Expand All @@ -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 } };
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ProtectOptions {
sessionId?: string;
decisionMode?: 'local-first' | 'cloud';
phase?: 'pre' | 'post';
filesystemAllowlist?: string[];
}

export interface ProtectResult {
Expand Down Expand Up @@ -51,7 +52,9 @@ export async function protectAction(options: ProtectOptions): Promise<ProtectRes
cachePath: options.config.policyCachePath,
fetchPolicy: client.connected ? () => 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'
Expand Down
1 change: 1 addition & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface EffectiveRuntimePolicy {
deployAction: CloudPolicyDecision;
};
protectedPaths: string[];
filesystemAllowlist?: string[];
blockedCommandPatterns: string[];
allowedCommandPatterns: string[];
approvalActionTypes: RuntimeActionType[];
Expand Down
2 changes: 1 addition & 1 deletion src/tests/feed-cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
Expand Down
30 changes: 30 additions & 0 deletions src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => {
actionType?: string;
toolName?: string;
sessionId?: string;
filesystemAllowlist?: string[];
rawInput?: unknown;
};
assert.equal(call.agentHost, 'openclaw');
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions src/tests/runtime-cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading