From 1a14a76e7703ef7204a538d1087ece4077bbf696 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Mon, 13 Apr 2026 17:44:50 +0200 Subject: [PATCH] feat: add network policy cli --- container/shared/network-policy.d.ts | 38 + container/shared/network-policy.js | 157 +++++ container/src/approval-policy.ts | 701 ++++++++++++++++--- package.json | 1 + presets/brave.yaml | 13 + presets/discord.yaml | 13 + presets/github.yaml | 18 + presets/huggingface.yaml | 13 + presets/jira.yaml | 13 + presets/npm.yaml | 13 + presets/outlook.yaml | 13 + presets/pypi.yaml | 13 + presets/slack.yaml | 13 + src/cli.ts | 14 + src/cli/help.ts | 28 + src/command-registry.ts | 275 ++++++++ src/commands/policy-command.ts | 506 +++++++++++++ src/gateway/gateway-service.ts | 24 + src/infra/container-runner.ts | 2 + src/infra/host-runner.ts | 1 + src/policy/network-policy.ts | 16 + src/policy/policy-cli.ts | 12 + src/policy/policy-presets.ts | 80 +++ src/policy/policy-store.ts | 308 ++++++++ src/tui-banner.ts | 1 + src/workspace.ts | 13 +- tests/approval-policy.test.ts | 219 ++++-- tests/command-registry.test.ts | 73 ++ tests/gateway-service.policy-command.test.ts | 83 +++ tests/policy-cli.test.ts | 187 +++++ tests/policy-store.test.ts | 197 ++++++ tests/tui-banner.test.ts | 1 + 32 files changed, 2937 insertions(+), 122 deletions(-) create mode 100644 container/shared/network-policy.d.ts create mode 100644 container/shared/network-policy.js create mode 100644 presets/brave.yaml create mode 100644 presets/discord.yaml create mode 100644 presets/github.yaml create mode 100644 presets/huggingface.yaml create mode 100644 presets/jira.yaml create mode 100644 presets/npm.yaml create mode 100644 presets/outlook.yaml create mode 100644 presets/pypi.yaml create mode 100644 presets/slack.yaml create mode 100644 src/commands/policy-command.ts create mode 100644 src/policy/network-policy.ts create mode 100644 src/policy/policy-cli.ts create mode 100644 src/policy/policy-presets.ts create mode 100644 src/policy/policy-store.ts create mode 100644 tests/gateway-service.policy-command.test.ts create mode 100644 tests/policy-cli.test.ts create mode 100644 tests/policy-store.test.ts diff --git a/container/shared/network-policy.d.ts b/container/shared/network-policy.d.ts new file mode 100644 index 00000000..3cdfe936 --- /dev/null +++ b/container/shared/network-policy.d.ts @@ -0,0 +1,38 @@ +export type NetworkPolicyAction = 'allow' | 'deny'; + +export interface NetworkRule { + action: NetworkPolicyAction; + host: string; + port: number; + methods: string[]; + paths: string[]; + agent: string; + comment?: string; +} + +export interface NetworkRuleInput { + action?: unknown; + host?: unknown; + port?: unknown; + methods?: unknown; + paths?: unknown; + agent?: unknown; + comment?: unknown; +} + +export interface NetworkPolicyState { + defaultAction: NetworkPolicyAction; + rules: NetworkRule[]; + presets: string[]; +} + +export const DEFAULT_NETWORK_DEFAULT: NetworkPolicyAction; +export const DEFAULT_NETWORK_RULES: NetworkRule[]; + +export function normalizeNetworkPathPattern(rawPath: unknown): string; +export function normalizeNetworkAgent(raw: unknown): string; +export function normalizeNetworkPort(raw: unknown): number; +export function normalizeNetworkRule(raw: NetworkRuleInput): NetworkRule | null; +export function readNetworkPolicyState( + document: Record, +): NetworkPolicyState; diff --git a/container/shared/network-policy.js b/container/shared/network-policy.js new file mode 100644 index 00000000..2f71de27 --- /dev/null +++ b/container/shared/network-policy.js @@ -0,0 +1,157 @@ +export const DEFAULT_NETWORK_DEFAULT = 'deny' + +export const DEFAULT_NETWORK_RULES = [ + { + action: 'allow', + host: 'hybridclaw.io', + port: 443, + methods: ['*'], + paths: ['/**'], + agent: '*', + }, +] + +function asRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return value +} + +function normalizeNetworkAction(raw) { + const normalized = String(raw || '') + .trim() + .toLowerCase() + return normalized === 'deny' ? 'deny' : 'allow' +} + +function normalizeCsvOrList(raw) { + if (Array.isArray(raw)) { + return raw.map((entry) => String(entry || '').trim()).filter(Boolean) + } + if (typeof raw === 'string') { + return raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + } + return [] +} + +function normalizeNetworkMethods(raw) { + const normalized = normalizeCsvOrList(raw).map((entry) => + entry.toUpperCase(), + ) + if (normalized.length === 0) return ['*'] + if (normalized.includes('*')) return ['*'] + return [...new Set(normalized)] +} + +export function normalizeNetworkPathPattern(rawPath) { + const trimmed = String(rawPath || '') + .trim() + .replace(/\\/g, '/') + if (!trimmed) return '/**' + if (trimmed.startsWith('/')) return trimmed + return `/${trimmed.replace(/^\/+/, '')}` +} + +function normalizeNetworkPaths(raw) { + const normalized = normalizeCsvOrList(raw).map((entry) => + normalizeNetworkPathPattern(entry), + ) + if (normalized.length === 0) return ['/**'] + return [...new Set(normalized)] +} + +export function normalizeNetworkAgent(raw) { + const normalized = String(raw || '') + .trim() + .toLowerCase() + return normalized || '*' +} + +export function normalizeNetworkPort(raw) { + const parsed = + typeof raw === 'number' ? raw : Number.parseInt(String(raw || ''), 10) + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) return 443 + return Math.trunc(parsed) +} + +export function normalizeNetworkRule(raw) { + const host = String(raw?.host || '') + .trim() + .toLowerCase() + .replace(/\.$/, '') + if (!host) return null + const comment = String(raw?.comment || '').trim() + return { + action: normalizeNetworkAction(raw?.action), + host, + port: normalizeNetworkPort(raw?.port), + methods: normalizeNetworkMethods(raw?.methods), + paths: normalizeNetworkPaths(raw?.paths), + agent: normalizeNetworkAgent(raw?.agent), + ...(comment ? { comment } : {}), + } +} + +function normalizePresetNames(presets) { + if (!Array.isArray(presets)) return [] + return [ + ...new Set( + presets + .map((preset) => + String(preset || '') + .trim() + .toLowerCase(), + ) + .filter(Boolean), + ), + ] +} + +export function readNetworkPolicyState(document) { + const network = asRecord(document?.network) + const approval = asRecord(document?.approval) + const rulesDeclared = Array.isArray(network.rules) + const networkRules = rulesDeclared + ? network.rules + .map((rule) => normalizeNetworkRule(asRecord(rule))) + .filter(Boolean) + : [] + const legacyTrustedHosts = + !rulesDeclared && Array.isArray(approval.trusted_network_hosts) + ? approval.trusted_network_hosts + .map((host) => + normalizeNetworkRule({ + action: 'allow', + host: String(host || ''), + methods: ['*'], + paths: ['/**'], + agent: '*', + }), + ) + .filter(Boolean) + : [] + + return { + defaultAction: + String(network.default || '') + .trim() + .toLowerCase() === 'allow' + ? 'allow' + : DEFAULT_NETWORK_DEFAULT, + rules: + networkRules.length > 0 || rulesDeclared + ? networkRules + : legacyTrustedHosts.length > 0 + ? legacyTrustedHosts + : DEFAULT_NETWORK_RULES.map((rule) => ({ + ...rule, + methods: [...rule.methods], + paths: [...rule.paths], + })), + presets: normalizePresetNames(network.presets), + } +} diff --git a/container/src/approval-policy.ts b/container/src/approval-policy.ts index 6a4887a5..211a742e 100644 --- a/container/src/approval-policy.ts +++ b/container/src/approval-policy.ts @@ -4,9 +4,28 @@ import os from 'node:os'; import path from 'node:path'; import { URL } from 'node:url'; +import { + DEFAULT_NETWORK_DEFAULT, + DEFAULT_NETWORK_RULES, + normalizeNetworkAgent, + normalizeNetworkPathPattern, + normalizeNetworkPort, + readNetworkPolicyState, +} from '../shared/network-policy.js'; import { classifyMcpTool } from './mcp/tool-classifier.js'; import { WORKSPACE_ROOT, WORKSPACE_ROOT_DISPLAY } from './runtime-paths.js'; import type { ChatMessage } from './types.js'; +import type { + NetworkPolicyAction, + NetworkRule, +} from '../shared/network-policy.js'; + +export { + DEFAULT_NETWORK_DEFAULT, + DEFAULT_NETWORK_RULES, + normalizeNetworkRule, +} from '../shared/network-policy.js'; +export type { NetworkPolicyAction, NetworkRule } from '../shared/network-policy.js'; export type ApprovalTier = 'green' | 'yellow' | 'red'; @@ -30,7 +49,9 @@ export interface ApprovalPolicyRule { export interface ApprovalPolicyConfig { pinnedRed: ApprovalPolicyRule[]; - trustedNetworkHosts: string[]; + networkDefault: NetworkPolicyAction; + networkRules: NetworkRule[]; + networkPresets: string[]; workspaceFence: boolean; maxPendingApprovals: number; approvalTimeoutSecs: number; @@ -52,6 +73,7 @@ interface ClassifiedAction { writeIntent: boolean; promotableRed: boolean; stickyYellow: boolean; + hardDeny?: boolean; } interface PendingApproval { @@ -115,6 +137,7 @@ const LEGACY_AGENT_TRUST_STORE_PATH = path.join( '.hybridclaw', 'approval-trust.json', ); +const AGENT_ID_ENV = 'HYBRIDCLAW_AGENT_ID'; const YELLOW_IMPLICIT_DELAY_MS = 5_000; const YELLOW_IMPLICIT_DELAY_SECS = Math.max( 1, @@ -136,13 +159,15 @@ const SCRATCH_ROOTS = Array.from( ), ); -const DEFAULT_POLICY: ApprovalPolicyConfig = { +export const DEFAULT_POLICY: ApprovalPolicyConfig = { pinnedRed: [ { pattern: 'rm\\s+-rf\\s+/' }, { paths: ['~/.ssh/**', '/etc/**', '.env*'] }, { tools: ['force_push'] }, ], - trustedNetworkHosts: ['hybridclaw.io'], + networkDefault: DEFAULT_NETWORK_DEFAULT, + networkRules: DEFAULT_NETWORK_RULES, + networkPresets: [], workspaceFence: true, maxPendingApprovals: 3, approvalTimeoutSecs: 120, @@ -275,16 +300,28 @@ function matchesPathPattern(candidatePath: string, pattern: string): boolean { return absoluteRe.test(absoluteCandidate); } -function parsePolicyYaml(raw: string): Partial { +export function parsePolicyYaml(raw: string): Partial { const policy: Partial = {}; const lines = raw.split(/\r?\n/); - let section: 'approval' | 'audit' | '' = ''; + let section: 'approval' | 'audit' | 'network' | '' = ''; let inPinnedRed = false; let pinnedRuleIndent = -1; let currentRule: ApprovalPolicyRule | null = null; let currentListKey: 'tools' | 'paths' | null = null; const pinnedRules: ApprovalPolicyRule[] = []; + let inNetworkRules = false; + let networkRulesDeclared = false; + let networkRuleIndent = -1; + let networkSectionSeen = false; + let currentNetworkRule: Record | null = null; + let currentNetworkListKey: 'methods' | 'paths' | null = null; + const rawNetworkRules: Record[] = []; + let inNetworkPresets = false; + let networkPresetsIndent = -1; + let networkDefaultRaw: string | undefined; + const networkPresets: string[] = []; + let legacyTrustedHosts: string[] = []; const flushRule = (): void => { if (!currentRule) return; @@ -298,6 +335,15 @@ function parsePolicyYaml(raw: string): Partial { currentListKey = null; }; + const flushNetworkRule = (): void => { + if (!currentNetworkRule) return; + if (String(currentNetworkRule.host || '').trim()) { + rawNetworkRules.push({ ...currentNetworkRule }); + } + currentNetworkRule = null; + currentNetworkListKey = null; + }; + const applyRuleField = ( rule: ApprovalPolicyRule, key: string, @@ -322,6 +368,61 @@ function parsePolicyYaml(raw: string): Partial { } }; + const applyNetworkRuleField = ( + rule: Record, + key: string, + rawValue: string, + ): void => { + const value = rawValue.trim(); + if (key === 'action') { + rule.action = value.replace(/^['"]|['"]$/g, ''); + return; + } + if (key === 'host') { + rule.host = value.replace(/^['"]|['"]$/g, ''); + return; + } + if (key === 'port') { + const unquoted = value.replace(/^['"]|['"]$/g, ''); + rule.port = /^\d+$/.test(unquoted) + ? Number.parseInt(unquoted, 10) + : unquoted; + return; + } + if (key === 'agent') { + rule.agent = value.replace(/^['"]|['"]$/g, ''); + return; + } + if (key === 'comment') { + rule.comment = value.replace(/^['"]|['"]$/g, ''); + return; + } + if (key === 'methods' || key === 'paths') { + const parsed = parseInlineList(value); + if (parsed.length > 0) { + if (key === 'methods') rule.methods = parsed; + if (key === 'paths') rule.paths = parsed; + currentNetworkListKey = null; + return; + } + const scalarList = value + ? value + .split(',') + .map((item) => item.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean) + : []; + if (scalarList.length > 0) { + if (key === 'methods') rule.methods = scalarList; + if (key === 'paths') rule.paths = scalarList; + currentNetworkListKey = null; + return; + } + if (key === 'methods') rule.methods = []; + if (key === 'paths') rule.paths = []; + currentNetworkListKey = key; + } + }; + for (const rawLine of lines) { const noComment = rawLine.replace(/\s+#.*$/, ''); if (!noComment.trim()) continue; @@ -330,14 +431,30 @@ function parsePolicyYaml(raw: string): Partial { if (line === 'approval:') { flushRule(); + flushNetworkRule(); section = 'approval'; inPinnedRed = false; + inNetworkRules = false; + inNetworkPresets = false; + continue; + } + if (line === 'network:') { + flushRule(); + flushNetworkRule(); + section = 'network'; + networkSectionSeen = true; + inPinnedRed = false; + inNetworkRules = false; + inNetworkPresets = false; continue; } if (line === 'audit:') { flushRule(); + flushNetworkRule(); section = 'audit'; inPinnedRed = false; + inNetworkRules = false; + inNetworkPresets = false; continue; } if (section === '' && line.endsWith(':')) continue; @@ -396,7 +513,7 @@ function parsePolicyYaml(raw: string): Partial { DEFAULT_POLICY.workspaceFence, ); } else if (key === 'trusted_network_hosts') { - policy.trustedNetworkHosts = + legacyTrustedHosts = parseInlineList(rawValue).length > 0 ? parseInlineList(rawValue) : []; } else if (key === 'max_pending_approvals') { policy.maxPendingApprovals = Math.max( @@ -412,6 +529,99 @@ function parsePolicyYaml(raw: string): Partial { continue; } + if (section === 'network') { + if (line === 'rules:') { + flushNetworkRule(); + inNetworkRules = true; + inNetworkPresets = false; + networkRulesDeclared = true; + networkRuleIndent = -1; + continue; + } + if (line === 'presets:') { + flushNetworkRule(); + inNetworkRules = false; + inNetworkPresets = true; + networkPresetsIndent = indent; + continue; + } + if (inNetworkRules && line.startsWith('-')) { + if (networkRuleIndent < 0) networkRuleIndent = indent; + if (indent <= networkRuleIndent) { + flushNetworkRule(); + currentNetworkRule = {}; + const rest = line.slice(1).trim(); + if (!rest) continue; + const kv = rest.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/); + if (!kv) continue; + applyNetworkRuleField(currentNetworkRule, kv[1], kv[2]); + continue; + } + } + if (inNetworkRules && currentNetworkRule) { + if (line.startsWith('-') && currentNetworkListKey) { + const item = line + .slice(1) + .trim() + .replace(/^['"]|['"]$/g, ''); + if (item) { + if (currentNetworkListKey === 'methods') { + const existingMethods = Array.isArray(currentNetworkRule.methods) + ? currentNetworkRule.methods + : []; + currentNetworkRule.methods = [ + ...existingMethods, + item, + ]; + } else { + const existingPaths = Array.isArray(currentNetworkRule.paths) + ? currentNetworkRule.paths + : []; + currentNetworkRule.paths = [ + ...existingPaths, + item, + ]; + } + } + continue; + } + const kv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/); + if (kv) { + applyNetworkRuleField(currentNetworkRule, kv[1], kv[2]); + continue; + } + } + if ( + inNetworkRules && + networkRuleIndent >= 0 && + indent <= networkRuleIndent && + !line.startsWith('-') + ) { + flushNetworkRule(); + inNetworkRules = false; + } + if (inNetworkPresets) { + if (line.startsWith('-')) { + const preset = line + .slice(1) + .trim() + .replace(/^['"]|['"]$/g, ''); + if (preset) networkPresets.push(preset); + continue; + } + if (indent <= networkPresetsIndent) { + inNetworkPresets = false; + } + } + const kv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/); + if (!kv) continue; + const [, key, rawValue] = kv; + if (key === 'default') { + networkDefaultRaw = rawValue.replace(/^['"]|['"]$/g, ''); + } + continue; + } + if (section === 'audit') { const kv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/); if (!kv) continue; @@ -430,11 +640,43 @@ function parsePolicyYaml(raw: string): Partial { } flushRule(); + flushNetworkRule(); if (pinnedRules.length > 0) policy.pinnedRed = pinnedRules; + if (networkSectionSeen || legacyTrustedHosts.length > 0) { + const document: Record = {}; + const networkDocument: Record = {}; + if (networkDefaultRaw !== undefined) { + networkDocument.default = networkDefaultRaw; + } + if (networkRulesDeclared) { + networkDocument.rules = rawNetworkRules; + } + if (networkPresets.length > 0) { + networkDocument.presets = networkPresets; + } + if (legacyTrustedHosts.length > 0) { + document.approval = { + trusted_network_hosts: legacyTrustedHosts, + }; + } + if (networkSectionSeen) { + document.network = networkDocument; + } + const networkState = readNetworkPolicyState(document); + policy.networkDefault = networkState.defaultAction; + policy.networkRules = networkState.rules.map((rule) => ({ + ...rule, + methods: [...rule.methods], + paths: [...rule.paths], + })); + if (networkState.presets.length > 0) { + policy.networkPresets = [...networkState.presets]; + } + } return policy; } -function loadPolicyFromDisk(policyPath: string): ApprovalPolicyConfig { +export function loadPolicyFromDisk(policyPath: string): ApprovalPolicyConfig { let filePolicy: Partial = {}; try { if (fs.existsSync(policyPath)) { @@ -450,11 +692,25 @@ function loadPolicyFromDisk(policyPath: string): ApprovalPolicyConfig { Array.isArray(filePolicy.pinnedRed) && filePolicy.pinnedRed.length > 0 ? filePolicy.pinnedRed : DEFAULT_POLICY.pinnedRed, - trustedNetworkHosts: - Array.isArray(filePolicy.trustedNetworkHosts) && - filePolicy.trustedNetworkHosts.length > 0 - ? filePolicy.trustedNetworkHosts.map(normalizeHostScope) - : DEFAULT_POLICY.trustedNetworkHosts.map(normalizeHostScope), + networkDefault: + filePolicy.networkDefault === 'allow' || + filePolicy.networkDefault === 'deny' + ? filePolicy.networkDefault + : DEFAULT_POLICY.networkDefault, + networkRules: Array.isArray(filePolicy.networkRules) + ? filePolicy.networkRules.map((rule) => ({ + ...rule, + methods: [...rule.methods], + paths: [...rule.paths], + })) + : DEFAULT_POLICY.networkRules.map((rule) => ({ + ...rule, + methods: [...rule.methods], + paths: [...rule.paths], + })), + networkPresets: Array.isArray(filePolicy.networkPresets) + ? [...filePolicy.networkPresets] + : [], workspaceFence: typeof filePolicy.workspaceFence === 'boolean' ? filePolicy.workspaceFence @@ -522,7 +778,7 @@ function extractHostsFromUrlLikeText(input: string): string[] { return [...hosts]; } -function normalizeHostScope(host: string): string { +export function normalizeHostScope(host: string): string { const normalized = host.trim().toLowerCase().replace(/\.$/, ''); if (!normalized) return 'unknown-host'; if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalized)) return normalized; @@ -552,12 +808,102 @@ function normalizeHostScope(host: string): string { return labels.slice(-2).join('.'); } -function extractHostScopes(hosts: string[]): string[] { - const scopes = new Set(); - for (const host of hosts) { - scopes.add(normalizeHostScope(host)); +function defaultPortForProtocol(protocol: string): number { + const normalized = protocol.trim().toLowerCase(); + if (normalized === 'http:') return 80; + if (normalized === 'https:') return 443; + return 443; +} + +function globHostPatternToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`, 'i'); +} + +function matchesHostPattern(pattern: string, candidateHost: string): boolean { + const normalizedPattern = pattern.trim().toLowerCase().replace(/\.$/, ''); + const normalizedCandidate = candidateHost + .trim() + .toLowerCase() + .replace(/\.$/, ''); + if (!normalizedPattern || !normalizedCandidate) return false; + if (normalizedPattern === normalizedCandidate) return true; + if (normalizedPattern.includes('*')) { + return globHostPatternToRegExp(normalizedPattern).test(normalizedCandidate); + } + if ( + /^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalizedPattern) || + normalizedPattern.includes(':') + ) { + return false; + } + if (normalizedPattern === normalizeHostScope(normalizedPattern)) { + return normalizeHostScope(normalizedCandidate) === normalizedPattern; + } + return false; +} + +function matchesMethodPattern( + allowedMethods: string[], + candidateMethod: string, +): boolean { + if (allowedMethods.includes('*')) return true; + const normalizedCandidate = candidateMethod.trim().toUpperCase() || 'GET'; + return allowedMethods.includes(normalizedCandidate); +} + +function matchesNetworkPathPattern( + allowedPaths: string[], + candidatePath: string, +): boolean { + const normalizedCandidate = normalizeNetworkPathPattern(candidatePath || '/'); + return allowedPaths.some((pattern) => + globPatternToRegExp(normalizeNetworkPathPattern(pattern)).test( + normalizedCandidate, + ), + ); +} + +function matchesAgentPattern( + ruleAgent: string, + candidateAgent: string, +): boolean { + if (ruleAgent === '*') return true; + return ruleAgent === normalizeNetworkAgent(candidateAgent); +} + +function parseUrlNetworkTarget(rawUrl: string): { + host: string; + port: number; + path: string; +} | null { + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname.trim().toLowerCase(); + if (!host) return null; + const pathValue = parsed.pathname || '/'; + return { + host, + port: parsed.port + ? normalizeNetworkPort(parsed.port) + : defaultPortForProtocol(parsed.protocol), + path: pathValue || '/', + }; + } catch { + return null; } - return [...scopes]; +} + +function inferBashHttpMethod(command: string): string { + const explicit = command.match(/\b(?:-X|--request)\s+([A-Za-z]+)/i); + if (explicit?.[1]) return explicit[1].toUpperCase(); + if (/\b(?:--data(?:-raw|-binary)?|-d|--form|-F)\b/i.test(command)) { + return 'POST'; + } + if (/\bwget\b/i.test(command)) return 'GET'; + return 'GET'; } function extractAbsolutePaths(input: string): string[] { @@ -1010,9 +1356,45 @@ export class TrustedCoworkerApprovalRuntime { ); } - private isTrustedNetworkHost(host: string): boolean { - const normalized = normalizeHostScope(host); - return this.loadedPolicy.trustedNetworkHosts.includes(normalized); + private getCurrentAgentId(): string { + return String(process.env[AGENT_ID_ENV] || '') + .trim() + .toLowerCase(); + } + + private evaluateNetworkAccess(params: { + host: string; + port: number; + method: string; + path: string; + agentId?: string; + }): { decision: NetworkPolicyAction | 'prompt'; matchedRule?: NetworkRule } { + const normalizedHost = String(params.host || '') + .trim() + .toLowerCase() + .replace(/\.$/, ''); + const normalizedMethod = + String(params.method || '') + .trim() + .toUpperCase() || 'GET'; + const normalizedPath = params.path || '/'; + const normalizedAgent = normalizeNetworkAgent( + params.agentId || this.getCurrentAgentId(), + ); + + for (const rule of this.loadedPolicy.networkRules) { + if (!matchesHostPattern(rule.host, normalizedHost)) continue; + if (rule.port !== params.port) continue; + if (!matchesMethodPattern(rule.methods, normalizedMethod)) continue; + if (!matchesNetworkPathPattern(rule.paths, normalizedPath)) continue; + if (!matchesAgentPattern(rule.agent, normalizedAgent)) continue; + return { decision: rule.action, matchedRule: rule }; + } + + return { + decision: + this.loadedPolicy.networkDefault === 'allow' ? 'allow' : 'prompt', + }; } reloadPolicyIfNeeded(force = false): ApprovalPolicyConfig { @@ -1219,6 +1601,22 @@ export class TrustedCoworkerApprovalRuntime { let decision: ApprovalDecision = 'auto'; if (baseTier === 'red') { + if (classified.hardDeny) { + return { + baseTier, + tier: 'red', + decision: 'denied', + actionKey: classified.actionKey, + fingerprint, + intent: classified.intent, + consequenceIfDenied: classified.consequenceIfDenied, + reason: classified.reason, + commandPreview: classified.commandPreview, + pinned: pinnedByPolicy, + hostHints: classified.hostHints, + }; + } + const oneShotApproved = this.oneShotFingerprints.has(fingerprint); const sessionApproved = !pinnedByPolicy && @@ -1451,6 +1849,80 @@ export class TrustedCoworkerApprovalRuntime { map.set(key, (map.get(key) || 0) + 1); } + private classifyNetworkTargets(params: { + targets: Array<{ + host: string; + port: number; + path: string; + method: string; + }>; + intent: string; + consequenceIfDenied: string; + commandPreview: string; + }): ClassifiedAction { + const primaryHost = normalizeHostScope(params.targets[0]?.host || ''); + const hostHints = [ + ...new Set( + params.targets.map((target) => normalizeHostScope(target.host)), + ), + ]; + let matchedAllowRule = false; + + for (const target of params.targets) { + const evaluation = this.evaluateNetworkAccess(target); + if (evaluation.decision === 'deny') { + return { + tier: 'red', + actionKey: `network:${primaryHost}`, + intent: params.intent, + consequenceIfDenied: params.consequenceIfDenied, + reason: 'this host is blocked by approval policy', + commandPreview: params.commandPreview, + pathHints: [], + hostHints, + writeIntent: false, + promotableRed: false, + stickyYellow: true, + hardDeny: true, + }; + } + if (evaluation.decision === 'prompt') { + return { + tier: 'yellow', + actionKey: `network:${primaryHost}`, + intent: params.intent, + consequenceIfDenied: params.consequenceIfDenied, + reason: 'network default policy denies unlisted hosts', + commandPreview: params.commandPreview, + pathHints: [], + hostHints, + writeIntent: false, + promotableRed: false, + stickyYellow: true, + }; + } + if (evaluation.matchedRule?.action === 'allow') { + matchedAllowRule = true; + } + } + + return { + tier: 'green', + actionKey: `network:${primaryHost}`, + intent: params.intent, + consequenceIfDenied: params.consequenceIfDenied, + reason: matchedAllowRule + ? 'this host is allowlisted in approval policy' + : 'network default policy allows this host', + commandPreview: params.commandPreview, + pathHints: [], + hostHints, + writeIntent: false, + promotableRed: false, + stickyYellow: true, + }; + } + private classifyAction( toolName: string, args: Record, @@ -1562,55 +2034,95 @@ export class TrustedCoworkerApprovalRuntime { if (lowerTool === 'web_search') { const provider = normalizeText(args.provider).toLowerCase(); - const providerHosts = (() => { + const providerTargets = (() => { switch (provider) { case 'brave': - return ['api.search.brave.com']; + return [ + { + host: 'api.search.brave.com', + port: 443, + path: '/', + method: 'GET', + }, + ]; case 'perplexity': - return ['api.perplexity.ai']; + return [ + { + host: 'api.perplexity.ai', + port: 443, + path: '/', + method: 'POST', + }, + ]; case 'tavily': - return ['api.tavily.com']; + return [ + { host: 'api.tavily.com', port: 443, path: '/', method: 'POST' }, + ]; case 'duckduckgo': - return ['html.duckduckgo.com']; + return [ + { + host: 'html.duckduckgo.com', + port: 443, + path: '/', + method: 'GET', + }, + ]; case 'searxng': - return extractHostScopes( - extractHostsFromUrlLikeText(process.env.SEARXNG_BASE_URL || ''), - ); + return extractHostsFromUrlLikeText( + process.env.SEARXNG_BASE_URL || '', + ).map((host) => ({ + host, + port: 443, + path: '/', + method: 'GET', + })); default: return [ - 'api.search.brave.com', - 'api.perplexity.ai', - 'api.tavily.com', - 'html.duckduckgo.com', + { + host: 'api.search.brave.com', + port: 443, + path: '/', + method: 'GET', + }, + { + host: 'api.perplexity.ai', + port: 443, + path: '/', + method: 'POST', + }, + { host: 'api.tavily.com', port: 443, path: '/', method: 'POST' }, + { + host: 'html.duckduckgo.com', + port: 443, + path: '/', + method: 'GET', + }, ]; } })(); - const primaryHost = providerHosts[0] || 'web-search'; - const unseen = providerHosts.filter( - (host) => - !this.isTrustedNetworkHost(host) && !this.seenNetworkHosts.has(host), - ); - const allTrusted = - providerHosts.length > 0 && - providerHosts.every((host) => this.isTrustedNetworkHost(host)); - return { - tier: allTrusted ? 'green' : unseen.length > 0 ? 'red' : 'yellow', - actionKey: `network:${primaryHost}`, + if (providerTargets.length === 0) { + return { + tier: 'yellow', + actionKey: 'network:web-search', + intent: `search the web via ${provider || 'configured providers'}`, + consequenceIfDenied: + 'I will avoid external search providers and continue with local context only.', + reason: 'this is an external network action', + commandPreview: normalizePreview(JSON.stringify(args)), + pathHints: [], + hostHints: [], + writeIntent: false, + promotableRed: false, + stickyYellow: true, + }; + } + return this.classifyNetworkTargets({ + targets: providerTargets, intent: `search the web via ${provider || 'configured providers'}`, consequenceIfDenied: 'I will avoid external search providers and continue with local context only.', - reason: allTrusted - ? 'this host is allowlisted in approval policy' - : unseen.length > 0 - ? 'this would contact a new external host' - : 'this is an external network action', commandPreview: normalizePreview(JSON.stringify(args)), - pathHints: [], - hostHints: providerHosts, - writeIntent: false, - promotableRed: unseen.length > 0, - stickyYellow: true, - }; + }); } if ( @@ -1620,33 +2132,38 @@ export class TrustedCoworkerApprovalRuntime { lowerTool === 'browser_navigate' ) { const rawUrl = normalizeText(args.url); - const hostScopes = extractHostScopes(extractHostsFromUrlLikeText(rawUrl)); - const primaryHost = hostScopes[0] || 'unknown-host'; - const unseen = hostScopes.filter( - (host) => - !this.isTrustedNetworkHost(host) && !this.seenNetworkHosts.has(host), - ); - const allTrusted = - hostScopes.length > 0 && - hostScopes.every((host) => this.isTrustedNetworkHost(host)); - return { - tier: allTrusted ? 'green' : unseen.length > 0 ? 'red' : 'yellow', - actionKey: `network:${primaryHost}`, - intent: `access ${primaryHost}`, + const target = parseUrlNetworkTarget(rawUrl); + if (!target) { + return { + tier: 'yellow', + actionKey: 'network:unknown-host', + intent: 'access external host', + consequenceIfDenied: + 'I will avoid contacting that host and use existing local context only.', + reason: 'this is an external network action', + commandPreview: normalizePreview(rawUrl), + pathHints: [], + hostHints: [], + writeIntent: false, + promotableRed: false, + stickyYellow: true, + }; + } + return this.classifyNetworkTargets({ + targets: [ + { + ...target, + method: + lowerTool === 'http_request' + ? String(args.method || 'GET') + : 'GET', + }, + ], + intent: `access ${normalizeHostScope(target.host)}`, consequenceIfDenied: 'I will avoid contacting that host and use existing local context only.', - reason: allTrusted - ? 'this host is allowlisted in approval policy' - : unseen.length > 0 - ? 'this would contact a new external host' - : 'this is an external network action', commandPreview: normalizePreview(rawUrl), - pathHints: [], - hostHints: hostScopes, - writeIntent: false, - promotableRed: unseen.length > 0, - stickyYellow: true, - }; + }); } if (lowerTool === 'vision_analyze' || lowerTool === 'image') { @@ -1768,8 +2285,20 @@ export class TrustedCoworkerApprovalRuntime { const inspectionSurface = buildBashInspectionSurface(command); const lower = command.toLowerCase(); const hosts = extractHostsFromUrlLikeText(command); + const httpTargets = [...command.matchAll(URL_RE)] + .map((match) => parseUrlNetworkTarget(match[0])) + .filter( + ( + target, + ): target is { + host: string; + port: number; + path: string; + } => Boolean(target), + ); + const httpHostSet = new Set(httpTargets.map((target) => target.host)); const unseenHosts = hosts.filter( - (host) => !this.seenNetworkHosts.has(host), + (host) => !httpHostSet.has(host) && !this.seenNetworkHosts.has(host), ); const absPaths = extractAbsolutePaths(inspectionSurface); const likelyWritePaths = extractLikelyWritePaths(inspectionSurface); @@ -1857,6 +2386,18 @@ export class TrustedCoworkerApprovalRuntime { }; } + if (httpTargets.length > 0 && NETWORK_COMMAND_RE.test(inspectionSurface)) { + return this.classifyNetworkTargets({ + targets: httpTargets.map((target) => ({ + ...target, + method: inferBashHttpMethod(command), + })), + intent: `contact ${normalizeHostScope(httpTargets[0]?.host || 'unknown-host')}`, + consequenceIfDenied: 'I will keep the task local and avoid that host.', + commandPreview: normalizePreview(command), + }); + } + if (unseenHosts.length > 0 && NETWORK_COMMAND_RE.test(inspectionSurface)) { return { tier: 'red', diff --git a/package.json b/package.json index 68651f53..3ec98ac7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "container/dist/", "skills/", "community-skills/", + "presets/", "templates/", "docs/", "container/Dockerfile", diff --git a/presets/brave.yaml b/presets/brave.yaml new file mode 100644 index 00000000..fac1bfa0 --- /dev/null +++ b/presets/brave.yaml @@ -0,0 +1,13 @@ +name: brave +description: Brave Search API and web search pages +rules: + - action: allow + host: api.search.brave.com + port: 443 + methods: ["GET"] + paths: ["/**"] + - action: allow + host: search.brave.com + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/discord.yaml b/presets/discord.yaml new file mode 100644 index 00000000..b19bd608 --- /dev/null +++ b/presets/discord.yaml @@ -0,0 +1,13 @@ +name: discord +description: Discord API and CDN endpoints +rules: + - action: allow + host: discord.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: cdn.discordapp.com + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/github.yaml b/presets/github.yaml new file mode 100644 index 00000000..310ab836 --- /dev/null +++ b/presets/github.yaml @@ -0,0 +1,18 @@ +name: github +description: GitHub API, repo pages, and raw content +rules: + - action: allow + host: github.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: api.github.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: raw.githubusercontent.com + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/huggingface.yaml b/presets/huggingface.yaml new file mode 100644 index 00000000..d36705ee --- /dev/null +++ b/presets/huggingface.yaml @@ -0,0 +1,13 @@ +name: huggingface +description: Hugging Face Hub and model content +rules: + - action: allow + host: huggingface.co + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: cdn-lfs.huggingface.co + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/jira.yaml b/presets/jira.yaml new file mode 100644 index 00000000..a335b696 --- /dev/null +++ b/presets/jira.yaml @@ -0,0 +1,13 @@ +name: jira +description: Jira Cloud and Atlassian API access +rules: + - action: allow + host: atlassian.net + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: api.atlassian.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] diff --git a/presets/npm.yaml b/presets/npm.yaml new file mode 100644 index 00000000..d2bbbdb8 --- /dev/null +++ b/presets/npm.yaml @@ -0,0 +1,13 @@ +name: npm +description: npm registry and package metadata +rules: + - action: allow + host: registry.npmjs.org + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: www.npmjs.com + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/outlook.yaml b/presets/outlook.yaml new file mode 100644 index 00000000..3af02ad0 --- /dev/null +++ b/presets/outlook.yaml @@ -0,0 +1,13 @@ +name: outlook +description: Outlook and Microsoft Graph mail APIs +rules: + - action: allow + host: outlook.office.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: graph.microsoft.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] diff --git a/presets/pypi.yaml b/presets/pypi.yaml new file mode 100644 index 00000000..ca0525a9 --- /dev/null +++ b/presets/pypi.yaml @@ -0,0 +1,13 @@ +name: pypi +description: Python package index and hosted package files +rules: + - action: allow + host: pypi.org + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: files.pythonhosted.org + port: 443 + methods: ["GET"] + paths: ["/**"] diff --git a/presets/slack.yaml b/presets/slack.yaml new file mode 100644 index 00000000..9537314f --- /dev/null +++ b/presets/slack.yaml @@ -0,0 +1,13 @@ +name: slack +description: Slack API and file endpoints +rules: + - action: allow + host: slack.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] + - action: allow + host: files.slack.com + port: 443 + methods: ["GET", "POST"] + paths: ["/**"] diff --git a/src/cli.ts b/src/cli.ts index b71e1fa1..3244510b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { printMainUsage, printMigrationUsage, printOnboardingUsage, + printPolicyUsage, printTuiUsage, } from './cli/help.js'; import { ensureOnboardingApi } from './cli/onboarding-api.js'; @@ -1286,6 +1287,16 @@ async function handleConfigCommand(args: string[]): Promise { await runRuntimeConfigFileCheck(); } +async function handlePolicyCommand(args: string[]): Promise { + const normalized = normalizeArgs(args); + if (isHelpRequest(normalized)) { + printPolicyUsage(); + return; + } + const policyCli = await import('./policy/policy-cli.js'); + await policyCli.handlePolicyCommand(normalized); +} + async function handleLocalCommand(args: string[]): Promise { const cliAuth = await import('./cli/auth-command.js'); await cliAuth.handleLocalCommand(args); @@ -1479,6 +1490,9 @@ export async function main( case 'config': await handleConfigCommand(subargs); break; + case 'policy': + await handlePolicyCommand(subargs); + break; case 'gateway': await handleGatewayCommand(subargs); break; diff --git a/src/cli/help.ts b/src/cli/help.ts index 2b2d53c5..ab034031 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -9,6 +9,7 @@ export function printMainUsage(): void { agent Export, inspect, install, or uninstall portable agent archives auth Unified provider login/logout/status config Show or edit the local runtime config + policy Manage workspace HTTP/network access rules gateway Manage core runtime (start/stop/status) or run gateway commands eval Run local eval recipes or launch detached benchmark commands tui Start terminal adapter (starts gateway automatically when needed) @@ -142,6 +143,7 @@ Interactive slash commands inside TUI: /memory inspect [sessionId] /memory query /model [name] /model info|list [provider]|set |clear|default [name] /paste + /policy [status|list|allow|deny|delete|preset|default|reset] /plugin [list|enable|disable|config|install|reinstall|reload|uninstall] /rag [on|off] /ralph [info|on|off|set n] @@ -501,6 +503,28 @@ Notes: - \`--json\` prints a machine-readable report and still uses exit code 1 when any errors remain.`); } +export function printPolicyUsage(): void { + console.log(`Usage: hybridclaw policy + +Commands: + hybridclaw policy status + hybridclaw policy list [--agent ] [--json] + hybridclaw policy allow [--agent ] [--methods ] [--paths ] [--port ] [--comment ] + hybridclaw policy deny [--agent ] [--methods ] [--paths ] [--port ] [--comment ] + hybridclaw policy delete + hybridclaw policy reset + hybridclaw policy preset list + hybridclaw policy preset add [--dry-run] + hybridclaw policy preset remove + hybridclaw policy default + +Notes: + - Rules are evaluated in order; first match wins. + - Rule fields default to \`port=443\`, \`methods=*\`, \`paths=/**\`, and \`agent=*\`. + - \`list --agent \` shows both global (\`*\`) rules and rules scoped to that agent. + - \`preset add --dry-run\` previews bundled endpoints without modifying policy.yaml.`); +} + export function printSkillUsage(): void { console.log(`Usage: hybridclaw skill @@ -668,6 +692,7 @@ Topics: openclaw Help for OpenClaw migration hermes Help for Hermes Agent migration config Help for local runtime config commands + policy Help for workspace network policy commands plugin Help for plugin management msteams Help for Microsoft Teams auth/setup commands slack Help for Slack auth/setup commands @@ -747,6 +772,9 @@ export async function printHelpTopic(topic: string): Promise { case 'config': printConfigUsage(); return true; + case 'policy': + printPolicyUsage(); + return true; case 'plugin': printPluginUsage(); return true; diff --git a/src/command-registry.ts b/src/command-registry.ts index 8573cef5..8cd00abc 100644 --- a/src/command-registry.ts +++ b/src/command-registry.ts @@ -77,6 +77,7 @@ const REGISTERED_TEXT_COMMAND_NAMES = new Set([ 'auth', 'bot', 'config', + 'policy', 'dream', 'secret', 'concierge', @@ -197,6 +198,10 @@ const LOCAL_SESSION_HELP_PRESENTATIONS: Record< command: '/config [check|reload|set ]', description: 'Show or update local runtime config', }, + policy: { + command: '/policy [status|list|allow|deny|delete|preset|default|reset]', + description: 'Inspect or update workspace HTTP/network policy', + }, export: { command: '/export session [sessionId] | /export trace [sessionId|all]', description: 'Export session snapshot or trace JSONL', @@ -505,6 +510,12 @@ export function mapCanonicalCommandToGatewayArgs( return null; } + case 'policy': { + const sub = (parts[1] || '').trim().toLowerCase(); + if (!sub || sub === 'status') return ['policy']; + return ['policy', ...parts.slice(1)]; + } + case 'secret': return parts.length > 1 ? ['secret', ...parts.slice(1)] : ['secret']; @@ -1138,6 +1149,203 @@ function buildSlashCommandCatalogDefinitions( }, ], }, + { + name: 'policy', + description: 'Inspect or update workspace HTTP/network access policy', + tuiOnly: true, + localSurfaces: ['tui', 'web'], + tuiMenuEntries: [ + { + id: 'policy.status', + label: '/policy', + insertText: '/policy', + description: + 'Show the current default stance, rule count, and presets', + }, + { + id: 'policy.list', + label: '/policy list', + insertText: '/policy list', + description: 'List current workspace policy rules', + }, + { + id: 'policy.allow', + label: '/policy allow ', + insertText: '/policy allow ', + description: 'Add an allow rule for one host or host glob', + }, + { + id: 'policy.preset.list', + label: '/policy preset list', + insertText: '/policy preset list', + description: 'List bundled network policy presets', + }, + ], + options: [ + { + kind: 'subcommand', + name: 'status', + description: 'Show the current default stance and preset summary', + }, + { + kind: 'subcommand', + name: 'list', + description: 'List current workspace policy rules', + options: [ + { + kind: 'string', + name: 'agent', + description: 'Optional agent filter', + }, + { + kind: 'string', + name: 'json', + description: 'Optional --json flag', + choices: [{ name: '--json', value: '--json' }], + }, + ], + }, + { + kind: 'subcommand', + name: 'allow', + description: 'Add an allow rule', + options: [ + { + kind: 'string', + name: 'host', + description: 'Host or host glob', + required: true, + }, + { + kind: 'string', + name: 'agent', + description: 'Optional agent id', + }, + { + kind: 'string', + name: 'methods', + description: 'Comma-separated HTTP methods', + }, + { + kind: 'string', + name: 'paths', + description: 'Comma-separated URL path globs', + }, + { + kind: 'string', + name: 'port', + description: 'Optional port number', + }, + { + kind: 'string', + name: 'comment', + description: 'Optional human-readable note', + }, + ], + }, + { + kind: 'subcommand', + name: 'deny', + description: 'Add a deny rule', + options: [ + { + kind: 'string', + name: 'host', + description: 'Host or host glob', + required: true, + }, + { + kind: 'string', + name: 'agent', + description: 'Optional agent id', + }, + { + kind: 'string', + name: 'methods', + description: 'Comma-separated HTTP methods', + }, + { + kind: 'string', + name: 'paths', + description: 'Comma-separated URL path globs', + }, + { + kind: 'string', + name: 'port', + description: 'Optional port number', + }, + { + kind: 'string', + name: 'comment', + description: 'Optional human-readable note', + }, + ], + }, + { + kind: 'subcommand', + name: 'delete', + description: 'Delete one rule by list index or host', + options: [ + { + kind: 'string', + name: 'target', + description: 'Rule index or host pattern', + required: true, + }, + ], + }, + { + kind: 'subcommand', + name: 'reset', + description: 'Reset workspace policy to the default network rules', + }, + { + kind: 'subcommand', + name: 'default', + description: 'Set the default allow or deny stance', + options: [ + { + kind: 'string', + name: 'mode', + description: 'Default network stance', + required: true, + choices: [ + { name: 'allow', value: 'allow' }, + { name: 'deny', value: 'deny' }, + ], + }, + ], + }, + { + kind: 'subcommand', + name: 'preset', + description: 'List, apply, or remove bundled network presets', + options: [ + { + kind: 'string', + name: 'action', + description: 'Preset action', + choices: [ + { name: 'list', value: 'list' }, + { name: 'add', value: 'add' }, + { name: 'remove', value: 'remove' }, + ], + }, + { + kind: 'string', + name: 'name', + description: 'Preset name', + }, + { + kind: 'string', + name: 'dry-run', + description: 'Optional --dry-run flag for preset add', + choices: [{ name: '--dry-run', value: '--dry-run' }], + }, + ], + }, + ], + }, { name: 'secret', description: @@ -2207,6 +2415,73 @@ export function parseCanonicalSlashCommandArgs( return ['config', 'set', key, value]; } + case 'policy': { + const subcommand = normalizeSubcommand(interaction); + if (!subcommand || subcommand === 'status') return ['policy']; + if (subcommand === 'list') { + const agent = normalizeStringOption(interaction, 'agent'); + const json = normalizeStringOption(interaction, 'json'); + return [ + 'policy', + 'list', + ...(agent ? ['--agent', agent] : []), + ...(json === '--json' ? ['--json'] : []), + ]; + } + if (subcommand === 'allow' || subcommand === 'deny') { + const host = normalizeStringOption(interaction, 'host', true); + if (!host) return null; + const agent = normalizeStringOption(interaction, 'agent'); + const methods = normalizeStringOption(interaction, 'methods'); + const paths = normalizeStringOption(interaction, 'paths'); + const port = normalizeStringOption(interaction, 'port'); + const comment = normalizeStringOption(interaction, 'comment'); + return [ + 'policy', + subcommand, + host, + ...(agent ? ['--agent', agent] : []), + ...(methods ? ['--methods', methods] : []), + ...(paths ? ['--paths', paths] : []), + ...(port ? ['--port', port] : []), + ...(comment ? ['--comment', comment] : []), + ]; + } + if (subcommand === 'delete') { + const target = normalizeStringOption(interaction, 'target', true); + return target ? ['policy', 'delete', target] : null; + } + if (subcommand === 'reset') return ['policy', 'reset']; + if (subcommand === 'default') { + const mode = normalizeStringOption(interaction, 'mode', true); + return mode === 'allow' || mode === 'deny' + ? ['policy', 'default', mode] + : null; + } + if (subcommand === 'preset') { + const action = normalizeStringOption(interaction, 'action'); + const name = normalizeStringOption(interaction, 'name'); + const dryRun = normalizeStringOption(interaction, 'dry-run'); + if (!action || action === 'list') return ['policy', 'preset', 'list']; + if (action === 'add') { + return name + ? [ + 'policy', + 'preset', + 'add', + name, + ...(dryRun === '--dry-run' ? ['--dry-run'] : []), + ] + : null; + } + if (action === 'remove') { + return name ? ['policy', 'preset', 'remove', name] : null; + } + return null; + } + return null; + } + case 'secret': { const action = normalizeStringOption(interaction, 'action'); const name = normalizeStringOption(interaction, 'name'); diff --git a/src/commands/policy-command.ts b/src/commands/policy-command.ts new file mode 100644 index 00000000..812fa63d --- /dev/null +++ b/src/commands/policy-command.ts @@ -0,0 +1,506 @@ +import { + type NetworkPolicyAction, + type NetworkRule, + normalizeNetworkRule, +} from '../policy/network-policy.js'; +import { + listPolicyPresetSummaries, + loadPolicyPreset, +} from '../policy/policy-presets.js'; +import { + addPolicyRule, + deletePolicyRule, + type IndexedNetworkRule, + type ManagedNetworkRule, + type PolicyNetworkState, + readPolicyState, + resetPolicyNetwork, + setPolicyDefault, + setPolicyPresets, +} from '../policy/policy-store.js'; + +export interface PolicyCommandOutput { + kind: 'plain' | 'info' | 'error'; + title?: string; + text: string; +} + +function stripWrappedQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function parseFlagValue( + args: string[], + index: number, + name: string, +): { value: string; nextIndex: number } | null { + const arg = args[index] || ''; + if (arg === name) { + const value = stripWrappedQuotes(String(args[index + 1] || '')); + if (!value) { + throw new Error(`Missing value for \`${name}\`.`); + } + return { value, nextIndex: index + 1 }; + } + if (arg.startsWith(`${name}=`)) { + const value = stripWrappedQuotes(arg.slice(`${name}=`.length)); + if (!value) { + throw new Error(`Missing value for \`${name}\`.`); + } + return { value, nextIndex: index }; + } + return null; +} + +function parseCsvList(value: string): string[] { + return value + .split(',') + .map((entry) => stripWrappedQuotes(entry)) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function matchesAgentFilter( + rule: IndexedNetworkRule, + agentFilter?: string, +): boolean { + if (!agentFilter) return true; + const normalized = agentFilter.trim().toLowerCase(); + if (!normalized) return true; + return rule.agent === '*' || rule.agent === normalized; +} + +function networkRuleKey(rule: NetworkRule): string { + return JSON.stringify({ + action: rule.action, + host: rule.host, + port: rule.port, + methods: [...rule.methods], + paths: [...rule.paths], + agent: rule.agent, + }); +} + +function stripRuleIndex(rule: IndexedNetworkRule): NetworkRule { + return { + action: rule.action, + host: rule.host, + port: rule.port, + methods: [...rule.methods], + paths: [...rule.paths], + agent: rule.agent, + ...(rule.comment ? { comment: rule.comment } : {}), + }; +} + +function stripRuleIndexWithMetadata(rule: IndexedNetworkRule): ManagedNetworkRule { + return { + ...stripRuleIndex(rule), + ...(rule.managedByPreset + ? { managedByPreset: rule.managedByPreset } + : {}), + }; +} + +function formatRuleAction(action: NetworkPolicyAction): string { + return action.toUpperCase(); +} + +function formatRuleLine(rule: NetworkRule, index?: number): string { + const prefix = typeof index === 'number' ? `[${index}] ` : ''; + const commentSuffix = rule.comment ? ` # ${rule.comment}` : ''; + return `${prefix}${formatRuleAction(rule.action)} ${rule.host}:${rule.port} ${rule.methods.join(',')} ${rule.paths.join(',')} (agent: ${rule.agent})${commentSuffix}`; +} + +function formatRuleTable( + state: PolicyNetworkState, + agentFilter?: string, +): string { + const visibleRules = state.rules.filter((rule) => + matchesAgentFilter(rule, agentFilter), + ); + const lines = [ + `Default: ${state.defaultAction}`, + `Presets: ${state.presets.length > 0 ? state.presets.join(', ') : '(none)'}`, + ]; + if (visibleRules.length === 0) { + lines.push('(no matching rules)'); + return lines.join('\n'); + } + + const rows = visibleRules.map((rule) => ({ + index: String(rule.index), + action: formatRuleAction(rule.action), + host: rule.host, + port: String(rule.port), + methods: rule.methods.join(','), + paths: rule.paths.join(','), + agent: rule.agent, + comment: rule.comment || '', + })); + const widths = { + index: Math.max(1, ...rows.map((row) => row.index.length)), + action: Math.max(6, ...rows.map((row) => row.action.length)), + host: Math.max(4, ...rows.map((row) => row.host.length)), + port: Math.max(4, ...rows.map((row) => row.port.length)), + methods: Math.max(7, ...rows.map((row) => row.methods.length)), + paths: Math.max(5, ...rows.map((row) => row.paths.length)), + agent: Math.max(5, ...rows.map((row) => row.agent.length)), + }; + lines.push( + [ + '#'.padEnd(widths.index), + 'Action'.padEnd(widths.action), + 'Host'.padEnd(widths.host), + 'Port'.padEnd(widths.port), + 'Methods'.padEnd(widths.methods), + 'Paths'.padEnd(widths.paths), + 'Agent'.padEnd(widths.agent), + 'Comment', + ].join(' '), + ); + for (const row of rows) { + lines.push( + [ + row.index.padEnd(widths.index), + row.action.padEnd(widths.action), + row.host.padEnd(widths.host), + row.port.padEnd(widths.port), + row.methods.padEnd(widths.methods), + row.paths.padEnd(widths.paths), + row.agent.padEnd(widths.agent), + row.comment, + ].join(' '), + ); + } + return lines.join('\n'); +} + +function parseRuleCommand( + action: NetworkPolicyAction, + args: string[], +): NetworkRule { + const host = stripWrappedQuotes(String(args[0] || '')); + if (!host) { + throw new Error( + `Usage: \`policy ${action} [--agent ] [--methods ] [--paths ] [--port ] [--comment ]\``, + ); + } + let agent = '*'; + let methods: string[] = ['*']; + let paths: string[] = ['/**']; + let port = 443; + let comment = ''; + + for (let index = 1; index < args.length; index += 1) { + const agentFlag = parseFlagValue(args, index, '--agent'); + if (agentFlag) { + agent = agentFlag.value; + index = agentFlag.nextIndex; + continue; + } + const methodsFlag = parseFlagValue(args, index, '--methods'); + if (methodsFlag) { + methods = parseCsvList(methodsFlag.value); + index = methodsFlag.nextIndex; + continue; + } + const pathsFlag = parseFlagValue(args, index, '--paths'); + if (pathsFlag) { + paths = parseCsvList(pathsFlag.value); + index = pathsFlag.nextIndex; + continue; + } + const portFlag = parseFlagValue(args, index, '--port'); + if (portFlag) { + const parsed = Number.parseInt(portFlag.value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error('`--port` must be a positive integer.'); + } + port = parsed; + index = portFlag.nextIndex; + continue; + } + const commentFlag = parseFlagValue(args, index, '--comment'); + if (commentFlag) { + comment = commentFlag.value; + index = commentFlag.nextIndex; + continue; + } + throw new Error(`Unknown flag: ${args[index]}`); + } + + const normalized = normalizeNetworkRule({ + action, + host, + port, + methods, + paths, + agent, + ...(comment ? { comment } : {}), + }); + if (!normalized) { + throw new Error('Rule host is required.'); + } + return normalized; +} + +function buildListJson( + state: PolicyNetworkState, + agentFilter?: string, +): string { + return JSON.stringify( + { + default: state.defaultAction, + presets: state.presets, + rules: state.rules + .filter((rule) => matchesAgentFilter(rule, agentFilter)) + .map((rule) => ({ + index: rule.index, + action: rule.action, + host: rule.host, + port: rule.port, + methods: rule.methods, + paths: rule.paths, + agent: rule.agent, + ...(rule.comment ? { comment: rule.comment } : {}), + })), + }, + null, + 2, + ); +} + +function parseListFlags(args: string[]): { + agent?: string; + json: boolean; +} { + let agent: string | undefined; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const agentFlag = parseFlagValue(args, index, '--agent'); + if (agentFlag) { + agent = agentFlag.value; + index = agentFlag.nextIndex; + continue; + } + const arg = args[index] || ''; + if (arg === '--json') { + json = true; + continue; + } + throw new Error(`Unknown flag: ${arg}`); + } + return { agent, json }; +} + +export function runPolicyCommand( + args: string[], + params: { + workspacePath: string; + }, +): PolicyCommandOutput { + const subcommand = String(args[0] || 'status') + .trim() + .toLowerCase(); + const workspacePath = params.workspacePath; + + try { + if (!subcommand || subcommand === 'status') { + const state = readPolicyState(workspacePath); + return { + kind: 'info', + title: 'Policy Status', + text: [ + `Default: ${state.defaultAction}`, + `Rules: ${state.rules.length}`, + `Presets: ${state.presets.length > 0 ? state.presets.join(', ') : '(none)'}`, + ].join('\n'), + }; + } + + if (subcommand === 'list') { + const flags = parseListFlags(args.slice(1)); + const state = readPolicyState(workspacePath); + return { + kind: 'info', + title: 'Policy Rules', + text: flags.json + ? buildListJson(state, flags.agent) + : formatRuleTable(state, flags.agent), + }; + } + + if (subcommand === 'allow' || subcommand === 'deny') { + const rule = parseRuleCommand(subcommand, args.slice(1)); + const state = addPolicyRule(workspacePath, rule); + const added = state.rules[state.rules.length - 1]; + return { + kind: 'plain', + text: `Rule added: ${formatRuleLine(added, added.index)}`, + }; + } + + if (subcommand === 'delete' || subcommand === 'remove') { + const target = stripWrappedQuotes(String(args[1] || '')); + if (!target) { + throw new Error('Usage: `policy delete `'); + } + const { deleted } = deletePolicyRule(workspacePath, target); + if (deleted.length === 1) { + return { + kind: 'plain', + text: `Deleted rule #${deleted[0].index}: ${deleted[0].host} (agent: ${deleted[0].agent})`, + }; + } + return { + kind: 'plain', + text: `Deleted ${deleted.length} rules for ${target}.`, + }; + } + + if (subcommand === 'reset') { + const state = resetPolicyNetwork(workspacePath); + return { + kind: 'plain', + text: `Policy reset. Default: ${state.defaultAction}. Rules: ${state.rules.length}.`, + }; + } + + if (subcommand === 'default') { + const nextDefault = String(args[1] || '') + .trim() + .toLowerCase(); + if (nextDefault !== 'allow' && nextDefault !== 'deny') { + throw new Error('Usage: `policy default `'); + } + const state = setPolicyDefault(workspacePath, nextDefault); + return { + kind: 'plain', + text: `Default policy: ${state.defaultAction}`, + }; + } + + if (subcommand === 'preset') { + const action = String(args[1] || 'list') + .trim() + .toLowerCase(); + if (!action || action === 'list') { + const state = readPolicyState(workspacePath); + const summaries = listPolicyPresetSummaries(); + return { + kind: 'info', + title: 'Policy Presets', + text: + summaries.length > 0 + ? summaries + .map( + (preset) => + `${preset.name} — ${preset.description || '(no description)'}${state.presets.includes(preset.name) ? ' (applied)' : ''}`, + ) + .join('\n') + : '(none)', + }; + } + + if (action === 'add') { + const presetName = stripWrappedQuotes(String(args[2] || '')); + if (!presetName) { + throw new Error('Usage: `policy preset add [--dry-run]`'); + } + let dryRun = false; + for (let index = 3; index < args.length; index += 1) { + const arg = args[index] || ''; + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown flag: ${arg}`); + } + const preset = loadPolicyPreset(presetName); + const state = readPolicyState(workspacePath); + const currentPresetKeys = new Set( + state.rules + .filter((rule) => rule.managedByPreset === preset.name) + .map((rule) => networkRuleKey(stripRuleIndex(rule))), + ); + const addedRules = preset.rules + .filter((rule) => !currentPresetKeys.has(networkRuleKey(rule))) + .map( + (rule) => + ({ + ...rule, + managedByPreset: preset.name, + }) satisfies ManagedNetworkRule, + ); + if (dryRun) { + return { + kind: 'info', + title: 'Policy Preset Dry Run', + text: [ + `Preset '${preset.name}' would add:`, + ...(addedRules.length > 0 + ? addedRules.map((rule) => ` ${formatRuleLine(rule)}`) + : [' (no new rules)']), + ].join('\n'), + }; + } + setPolicyPresets(workspacePath, { + presets: [...new Set([...state.presets, preset.name])], + rules: [ + ...state.rules.map((rule) => stripRuleIndexWithMetadata(rule)), + ...addedRules, + ], + }); + return { + kind: 'plain', + text: `Applied preset '${preset.name}' (${addedRules.length} rules added)`, + }; + } + + if (action === 'remove' || action === 'delete') { + const presetName = stripWrappedQuotes(String(args[2] || '')); + if (!presetName) { + throw new Error('Usage: `policy preset remove `'); + } + const preset = loadPolicyPreset(presetName); + const state = readPolicyState(workspacePath); + const keptRules = state.rules + .filter((rule) => rule.managedByPreset !== preset.name) + .map((rule) => stripRuleIndexWithMetadata(rule)); + const removedCount = state.rules.filter( + (rule) => rule.managedByPreset === preset.name, + ).length; + setPolicyPresets(workspacePath, { + presets: state.presets.filter((name) => name !== preset.name), + rules: keptRules, + }); + return { + kind: 'plain', + text: `Removed preset '${preset.name}' (${removedCount} rules removed)`, + }; + } + + throw new Error( + 'Usage: `policy preset list`, `policy preset add [--dry-run]`, or `policy preset remove `', + ); + } + + throw new Error( + 'Usage: `policy [status|list|allow|deny|delete|reset|preset|default] ...`', + ); + } catch (error) { + return { + kind: 'error', + title: 'Policy Command Failed', + text: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/gateway/gateway-service.ts b/src/gateway/gateway-service.ts index 306e115a..dc839c65 100644 --- a/src/gateway/gateway-service.ts +++ b/src/gateway/gateway-service.ts @@ -57,6 +57,7 @@ import { import { getWhatsAppAuthStatus } from '../channels/whatsapp/auth.js'; import { getWhatsAppPairingState } from '../channels/whatsapp/pairing-state.js'; import { buildLocalSessionSlashHelpEntries } from '../command-registry.js'; +import { runPolicyCommand } from '../commands/policy-command.js'; import { APP_VERSION, DATA_DIR, @@ -7058,6 +7059,29 @@ export async function handleGatewayCommand( ); } + case 'policy': { + if (!isLocalSession(req)) { + return badCommand( + 'Policy Restricted', + '`policy` manages local workspace network rules and is only available from local TUI/web sessions.', + ); + } + const runtime = resolveSessionRuntimeTarget(session); + const result = runPolicyCommand(req.args.slice(1), { + workspacePath: runtime.workspacePath, + }); + if (result.kind === 'error') { + return badCommand( + result.title || 'Policy Command Failed', + result.text, + ); + } + if (result.kind === 'info') { + return infoCommand(result.title || 'Policy', result.text); + } + return plainCommand(result.text); + } + case 'stop': case 'abort': { await disableFullAutoSession({ sessionId: session.id }); diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index af34668d..3fda4dd8 100644 --- a/src/infra/container-runner.ts +++ b/src/infra/container-runner.ts @@ -531,6 +531,8 @@ function getOrSpawnContainer( '-e', `BROWSER_SHARED_PROFILE_DIR=${CONTAINER_BROWSER_PROFILE_PATH}`, '-e', + `HYBRIDCLAW_AGENT_ID=${agentId}`, + '-e', `HYBRIDCLAW_AGENT_WORKSPACE_ROOT=${CONTAINER_WORKSPACE_ROOT}`, '-e', `HYBRIDCLAW_AGENT_WORKSPACE_DISPLAY_ROOT=${params.workspaceDisplayRootOverride?.trim() || CONTAINER_WORKSPACE_ROOT}`, diff --git a/src/infra/host-runner.ts b/src/infra/host-runner.ts index a6dcc9b4..135882d9 100644 --- a/src/infra/host-runner.ts +++ b/src/infra/host-runner.ts @@ -473,6 +473,7 @@ function getOrSpawnHostProcess( BRAVE_API_KEY: process.env.BRAVE_API_KEY, PERPLEXITY_API_KEY: process.env.PERPLEXITY_API_KEY, TAVILY_API_KEY: process.env.TAVILY_API_KEY, + HYBRIDCLAW_AGENT_ID: agentId, HYBRIDCLAW_AGENT_WORKSPACE_ROOT: workspacePath, HYBRIDCLAW_AGENT_WORKSPACE_DISPLAY_ROOT: params.workspaceDisplayRootOverride?.trim() || '/workspace', diff --git a/src/policy/network-policy.ts b/src/policy/network-policy.ts new file mode 100644 index 00000000..88970184 --- /dev/null +++ b/src/policy/network-policy.ts @@ -0,0 +1,16 @@ +export type { + NetworkPolicyAction, + NetworkPolicyState, + NetworkRule, + NetworkRuleInput, +} from '../../container/shared/network-policy.js'; + +export { + DEFAULT_NETWORK_DEFAULT, + DEFAULT_NETWORK_RULES, + normalizeNetworkAgent, + normalizeNetworkPathPattern, + normalizeNetworkPort, + normalizeNetworkRule, + readNetworkPolicyState, +} from '../../container/shared/network-policy.js'; diff --git a/src/policy/policy-cli.ts b/src/policy/policy-cli.ts new file mode 100644 index 00000000..c6aa8793 --- /dev/null +++ b/src/policy/policy-cli.ts @@ -0,0 +1,12 @@ +import { normalizeArgs } from '../cli/common.js'; +import { runPolicyCommand } from '../commands/policy-command.js'; + +export async function handlePolicyCommand(args: string[]): Promise { + const result = runPolicyCommand(normalizeArgs(args), { + workspacePath: process.cwd(), + }); + if (result.kind === 'error') { + throw new Error(result.text); + } + console.log(result.text); +} diff --git a/src/policy/policy-presets.ts b/src/policy/policy-presets.ts new file mode 100644 index 00000000..82feaaa9 --- /dev/null +++ b/src/policy/policy-presets.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { parse as parseYaml } from 'yaml'; +import { resolveInstallPath } from '../infra/install-root.js'; +import { type NetworkRule, normalizeNetworkRule } from './network-policy.js'; + +export interface PolicyPreset { + name: string; + description: string; + rules: NetworkRule[]; +} + +export interface PolicyPresetSummary { + name: string; + description: string; +} + +const PRESETS_DIR = resolveInstallPath('presets'); + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function readPresetFile(name: string): string { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + throw new Error('Preset name is required.'); + } + const filePath = path.join(PRESETS_DIR, `${normalized}.yaml`); + if (!fs.existsSync(filePath)) { + throw new Error(`Unknown policy preset: ${name}`); + } + return filePath; +} + +function parsePresetFile(filePath: string): PolicyPreset { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = asRecord(parseYaml(raw)); + const name = + String(parsed.name || path.basename(filePath, path.extname(filePath))) + .trim() + .toLowerCase() || path.basename(filePath, path.extname(filePath)); + const description = String(parsed.description || '').trim(); + const rules = Array.isArray(parsed.rules) + ? parsed.rules + .map((rule) => + normalizeNetworkRule(asRecord(rule) as Partial), + ) + .filter((rule): rule is NetworkRule => Boolean(rule)) + : []; + if (rules.length === 0) { + throw new Error(`Policy preset "${name}" has no valid rules.`); + } + return { + name, + description, + rules, + }; +} + +export function listPolicyPresetSummaries(): PolicyPresetSummary[] { + if (!fs.existsSync(PRESETS_DIR)) return []; + return fs + .readdirSync(PRESETS_DIR) + .filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml')) + .map((entry) => parsePresetFile(path.join(PRESETS_DIR, entry))) + .map((preset) => ({ + name: preset.name, + description: preset.description, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function loadPolicyPreset(name: string): PolicyPreset { + return parsePresetFile(readPresetFile(name)); +} diff --git a/src/policy/policy-store.ts b/src/policy/policy-store.ts new file mode 100644 index 00000000..b95ed52b --- /dev/null +++ b/src/policy/policy-store.ts @@ -0,0 +1,308 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import YAML from 'yaml'; +import { + DEFAULT_NETWORK_DEFAULT, + DEFAULT_NETWORK_RULES, + type NetworkPolicyAction, + type NetworkRule, + normalizeNetworkRule, + readNetworkPolicyState, +} from './network-policy.js'; + +const MANAGED_BY_PRESET_FIELD = 'managed_by_preset'; + +export interface ManagedNetworkRule extends NetworkRule { + managedByPreset?: string; +} + +export interface IndexedNetworkRule extends ManagedNetworkRule { + index: number; +} + +export interface PolicyNetworkState { + exists: boolean; + policyPath: string; + workspacePath: string; + defaultAction: NetworkPolicyAction; + presets: string[]; + rules: IndexedNetworkRule[]; +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function normalizeManagedByPreset(value: unknown): string | undefined { + const normalized = String(value || '') + .trim() + .toLowerCase(); + return normalized || undefined; +} + +function toYamlNetworkRule(rule: ManagedNetworkRule): Record { + return { + action: rule.action, + host: rule.host, + port: rule.port, + methods: [...rule.methods], + paths: [...rule.paths], + agent: rule.agent, + ...(rule.comment ? { comment: rule.comment } : {}), + ...(rule.managedByPreset + ? { [MANAGED_BY_PRESET_FIELD]: rule.managedByPreset } + : {}), + }; +} + +function readRawPolicyObject(policyPath: string): Record { + if (!fs.existsSync(policyPath)) return {}; + const raw = fs.readFileSync(policyPath, 'utf-8'); + const parsed = YAML.parse(raw) as unknown; + if (!parsed) return {}; + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Policy file must contain a YAML mapping: ${policyPath}`); + } + return parsed as Record; +} + +function buildWritablePolicyObject(params: { + base: Record; + defaultAction: NetworkPolicyAction; + rules: ManagedNetworkRule[]; + presets: string[]; +}): Record { + const next = { ...params.base }; + const approval = asRecord(next.approval); + delete approval.trusted_network_hosts; + if (Object.keys(approval).length > 0) { + next.approval = approval; + } else { + delete next.approval; + } + next.network = { + default: params.defaultAction, + rules: params.rules.map((rule) => toYamlNetworkRule(rule)), + presets: [...params.presets], + }; + if (Object.keys(asRecord(next.audit)).length === 0) { + delete next.audit; + } + + const ordered: Record = {}; + if (next.approval) ordered.approval = next.approval; + if (next.network) ordered.network = next.network; + if (next.audit) ordered.audit = next.audit; + for (const [key, value] of Object.entries(next)) { + if (key === 'approval' || key === 'network' || key === 'audit') continue; + ordered[key] = value; + } + return ordered; +} + +function normalizePresetNames(presets: string[]): string[] { + return [ + ...new Set( + presets.map((preset) => preset.trim().toLowerCase()).filter(Boolean), + ), + ]; +} + +function toPolicyState(policyPath: string): PolicyNetworkState { + const document = readRawPolicyObject(policyPath); + const config = readNetworkPolicyState(document); + const rawNetwork = asRecord(document.network); + const rawRules = Array.isArray(rawNetwork.rules) ? rawNetwork.rules : null; + const rules = + rawRules !== null + ? rawRules + .map((entry) => { + const rawRule = asRecord(entry); + const normalized = normalizeNetworkRule(rawRule); + if (!normalized) return null; + return { + ...normalized, + ...(normalizeManagedByPreset(rawRule[MANAGED_BY_PRESET_FIELD]) + ? { + managedByPreset: normalizeManagedByPreset( + rawRule[MANAGED_BY_PRESET_FIELD], + ), + } + : {}), + } satisfies ManagedNetworkRule; + }) + .filter((rule): rule is ManagedNetworkRule => Boolean(rule)) + : config.rules.map((rule) => ({ ...rule })); + const workspacePath = path.dirname(path.dirname(policyPath)); + return { + exists: fs.existsSync(policyPath), + policyPath, + workspacePath, + defaultAction: config.defaultAction, + presets: [...config.presets], + rules: rules.map((rule, index) => ({ + ...rule, + index: index + 1, + })), + }; +} + +function stripRuleIndex(rule: IndexedNetworkRule): ManagedNetworkRule { + return { + action: rule.action, + host: rule.host, + port: rule.port, + methods: [...rule.methods], + paths: [...rule.paths], + agent: rule.agent, + ...(rule.comment ? { comment: rule.comment } : {}), + ...(rule.managedByPreset + ? { managedByPreset: rule.managedByPreset } + : {}), + }; +} + +function writePolicyState(params: { + policyPath: string; + base: Record; + defaultAction: NetworkPolicyAction; + rules: ManagedNetworkRule[]; + presets: string[]; +}): void { + const payload = buildWritablePolicyObject({ + base: params.base, + defaultAction: params.defaultAction, + rules: params.rules, + presets: normalizePresetNames(params.presets), + }); + fs.mkdirSync(path.dirname(params.policyPath), { recursive: true }); + fs.writeFileSync(params.policyPath, YAML.stringify(payload), 'utf-8'); +} + +function updatePolicyState( + workspacePath: string, + update: (draft: { + defaultAction: NetworkPolicyAction; + rules: ManagedNetworkRule[]; + presets: string[]; + }) => void, +): PolicyNetworkState { + const policyPath = resolveWorkspacePolicyPath(workspacePath); + const base = readRawPolicyObject(policyPath); + const current = toPolicyState(policyPath); + const draft = { + defaultAction: current.defaultAction, + rules: current.rules.map((rule) => stripRuleIndex(rule)), + presets: [...current.presets], + }; + update(draft); + const normalizedRules = draft.rules + .map((rule) => { + const normalized = normalizeNetworkRule(rule); + if (!normalized) return null; + return { + ...normalized, + ...(rule.managedByPreset + ? { managedByPreset: normalizeManagedByPreset(rule.managedByPreset) } + : {}), + } satisfies ManagedNetworkRule; + }) + .filter((rule): rule is ManagedNetworkRule => Boolean(rule)); + writePolicyState({ + policyPath, + base, + defaultAction: draft.defaultAction === 'allow' ? 'allow' : 'deny', + rules: normalizedRules, + presets: draft.presets, + }); + return toPolicyState(policyPath); +} + +export function resolveWorkspacePolicyPath(workspacePath: string): string { + return path.join(path.resolve(workspacePath), '.hybridclaw', 'policy.yaml'); +} + +export function readPolicyState(workspacePath: string): PolicyNetworkState { + return toPolicyState(resolveWorkspacePolicyPath(workspacePath)); +} + +export function setPolicyDefault( + workspacePath: string, + defaultAction: NetworkPolicyAction, +): PolicyNetworkState { + return updatePolicyState(workspacePath, (draft) => { + draft.defaultAction = defaultAction; + }); +} + +export function addPolicyRule( + workspacePath: string, + rule: NetworkRule, +): PolicyNetworkState { + const normalized = normalizeNetworkRule(rule); + if (!normalized) { + throw new Error('Policy rule is missing a host.'); + } + return updatePolicyState(workspacePath, (draft) => { + draft.rules.push(normalized); + }); +} + +export function deletePolicyRule( + workspacePath: string, + target: string, +): { state: PolicyNetworkState; deleted: IndexedNetworkRule[] } { + const current = readPolicyState(workspacePath); + const rawTarget = target.trim(); + if (!rawTarget) { + throw new Error('Rule index or host is required.'); + } + const numericTarget = Number.parseInt(rawTarget, 10); + const deleted = + Number.isFinite(numericTarget) && `${numericTarget}` === rawTarget + ? current.rules.filter((rule) => rule.index === numericTarget) + : current.rules.filter( + (rule) => rule.host === rawTarget.toLowerCase().replace(/\.$/, ''), + ); + if (deleted.length === 0) { + throw new Error(`No policy rule matched "${target}".`); + } + const next = updatePolicyState(workspacePath, (draft) => { + draft.rules = current.rules + .filter((rule) => !deleted.some((entry) => entry.index === rule.index)) + .map((rule) => stripRuleIndex(rule)); + }); + return { + state: next, + deleted, + }; +} + +export function resetPolicyNetwork(workspacePath: string): PolicyNetworkState { + return updatePolicyState(workspacePath, (draft) => { + draft.defaultAction = DEFAULT_NETWORK_DEFAULT; + draft.rules = DEFAULT_NETWORK_RULES.map((rule) => ({ + ...rule, + methods: [...rule.methods], + paths: [...rule.paths], + })); + draft.presets = []; + }); +} + +export function setPolicyPresets( + workspacePath: string, + params: { + presets: string[]; + rules: ManagedNetworkRule[]; + }, +): PolicyNetworkState { + return updatePolicyState(workspacePath, (draft) => { + draft.presets = [...params.presets]; + draft.rules = params.rules.map((rule) => ({ ...rule })); + }); +} diff --git a/src/tui-banner.ts b/src/tui-banner.ts index 9a2d6bf0..4bc856bb 100644 --- a/src/tui-banner.ts +++ b/src/tui-banner.ts @@ -92,6 +92,7 @@ const SLASH_COMMANDS = [ '/info', '/mcp', '/model', + '/policy', '/rag', '/ralph', '/reset', diff --git a/src/workspace.ts b/src/workspace.ts index 3e6ef542..04d9e1db 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -36,12 +36,21 @@ const DEFAULT_POLICY_TEMPLATE = `approval: - pattern: "rm -rf /" - paths: ["~/.ssh/**", "/etc/**", ".env*"] - tools: ["force_push"] - trusted_network_hosts: ["hybridclaw.io"] - workspace_fence: true max_pending_approvals: 3 approval_timeout_secs: 120 +network: + default: deny + rules: + - action: allow + host: "hybridclaw.io" + port: 443 + methods: ["*"] + paths: ["/**"] + agent: "*" + presets: [] + audit: log_all_red: true log_denials: true diff --git a/tests/approval-policy.test.ts b/tests/approval-policy.test.ts index d243619b..71ad7492 100644 --- a/tests/approval-policy.test.ts +++ b/tests/approval-policy.test.ts @@ -15,6 +15,13 @@ function tempTrustStorePath(name: string): string { return path.join(dir, `${name}.json`); } +function writeTempPolicy(raw: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hybridclaw-policy-')); + const policyPath = path.join(dir, 'policy.yaml'); + fs.writeFileSync(policyPath, `${raw.trim()}\n`, 'utf-8'); + return policyPath; +} + afterEach(() => { vi.unstubAllEnvs(); }); @@ -353,14 +360,18 @@ describe('TrustedCoworkerApprovalRuntime', () => { const runtime = new TrustedCoworkerApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', ); - const originalPrompt = 'Fetch from example.com'; + const originalPrompt = 'Write the report to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const first = runtime.evaluateToolCall({ - toolName: 'web_fetch', - argsJson: JSON.stringify({ url: 'https://example.com' }), + toolName: 'bash', + argsJson, latestUserPrompt: originalPrompt, }); expect(first.decision).toBe('required'); + expect(first.actionKey).toBe('bash:workspace-fence'); const prelude = runtime.handleApprovalResponse([ userMessage('yes for session'), @@ -368,8 +379,8 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(prelude?.approvalMode).toBe('session'); const second = runtime.evaluateToolCall({ - toolName: 'web_fetch', - argsJson: JSON.stringify({ url: 'https://example.com' }), + toolName: 'bash', + argsJson, latestUserPrompt: originalPrompt, }); expect(second.decision).toBe('approved_session'); @@ -379,11 +390,14 @@ describe('TrustedCoworkerApprovalRuntime', () => { const runtime = new TrustedCoworkerApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', ); - const originalPrompt = 'Fetch from example.com'; + const originalPrompt = 'Write the report to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const first = runtime.evaluateToolCall({ - toolName: 'web_fetch', - argsJson: JSON.stringify({ url: 'https://example.com' }), + toolName: 'bash', + argsJson, latestUserPrompt: originalPrompt, }); expect(first.decision).toBe('required'); @@ -394,29 +408,44 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(prelude?.approvalMode).toBe('session'); const second = runtime.evaluateToolCall({ - toolName: 'web_fetch', - argsJson: JSON.stringify({ url: 'https://example.com' }), + toolName: 'bash', + argsJson, latestUserPrompt: originalPrompt, }); expect(second.decision).toBe('approved_session'); }); - test('network approvals reuse site scope across subdomains', () => { + test('unlisted network access is implicit yellow by default', () => { const runtime = new TrustedCoworkerApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', ); - const originalPrompt = 'Open Google Images'; + const evaluation = runtime.evaluateToolCall({ + toolName: 'browser_navigate', + argsJson: JSON.stringify({ url: 'https://images.google.de' }), + latestUserPrompt: 'Open Google Images', + }); + + expect(evaluation.actionKey).toBe('network:google.de'); + expect(evaluation.tier).toBe('yellow'); + expect(evaluation.decision).toBe('implicit'); + expect(evaluation.reason).toBe( + 'network default policy denies unlisted hosts', + ); + }); + test('network approvals reuse site scope across subdomains after a successful run', () => { + const runtime = new TrustedCoworkerApprovalRuntime( + '/tmp/hybridclaw-missing-policy.yaml', + ); + const originalPrompt = 'Open Google Images'; const first = runtime.evaluateToolCall({ toolName: 'browser_navigate', argsJson: JSON.stringify({ url: 'https://images.google.de' }), latestUserPrompt: originalPrompt, }); - expect(first.decision).toBe('required'); + expect(first.decision).toBe('implicit'); expect(first.actionKey).toBe('network:google.de'); - - const prelude = runtime.handleApprovalResponse([userMessage('yes')]); - expect(prelude?.approvalMode).toBe('once'); + runtime.afterToolExecution(first, true); const second = runtime.evaluateToolCall({ toolName: 'browser_navigate', @@ -424,7 +453,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { latestUserPrompt: originalPrompt, }); expect(second.actionKey).toBe('network:google.de'); - expect(second.decision).toBe('promoted'); + expect(second.decision).toBe('implicit'); expect(second.tier).toBe('yellow'); }); @@ -442,7 +471,8 @@ describe('TrustedCoworkerApprovalRuntime', () => { latestUserPrompt: 'Call the completions API', }); - expect(evaluation.decision).toBe('required'); + expect(evaluation.tier).toBe('yellow'); + expect(evaluation.decision).toBe('implicit'); expect(evaluation.actionKey).toBe('network:hybridai.one'); }); @@ -452,9 +482,9 @@ describe('TrustedCoworkerApprovalRuntime', () => { ); const evaluation = runtime.evaluateToolCall({ - toolName: 'web_fetch', - argsJson: JSON.stringify({ url: 'https://example.com' }), - latestUserPrompt: 'Fetch page', + toolName: 'bash', + argsJson: JSON.stringify({ command: 'touch /Users/example/out.txt' }), + latestUserPrompt: 'Write the output to a host file', }); expect(evaluation.decision).toBe('required'); expect(evaluation.pinned).toBe(false); @@ -508,11 +538,13 @@ describe('TrustedCoworkerApprovalRuntime', () => { const runtime = new TrustedCoworkerApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', ); - const originalPrompt = 'Open images.google.de'; - const argsJson = JSON.stringify({ url: 'https://images.google.de' }); + const originalPrompt = 'Write the output to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const first = runtime.evaluateToolCall({ - toolName: 'browser_navigate', + toolName: 'bash', argsJson, latestUserPrompt: originalPrompt, }); @@ -530,7 +562,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(prelude?.replayPrompt).toContain(originalPrompt); const second = runtime.evaluateToolCall({ - toolName: 'browser_navigate', + toolName: 'bash', argsJson, latestUserPrompt: originalPrompt, }); @@ -650,15 +682,17 @@ describe('TrustedCoworkerApprovalRuntime', () => { test('yes for agent persists trust across runtime restarts', () => { const trustStorePath = tempTrustStorePath('agent-trust'); const policyPath = '/tmp/hybridclaw-missing-policy.yaml'; - const prompt = 'Fetch from example.com'; - const argsJson = JSON.stringify({ url: 'https://example.com' }); + const prompt = 'Write the report to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const runtime = new TrustedCoworkerApprovalRuntime( policyPath, trustStorePath, ); const first = runtime.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -670,7 +704,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(prelude?.approvalMode).toBe('agent'); const second = runtime.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -681,7 +715,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { trustStorePath, ); const third = restarted.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -692,8 +726,10 @@ describe('TrustedCoworkerApprovalRuntime', () => { const agentTrustStorePath = tempTrustStorePath('agent-trust'); const allTrustStorePath = tempTrustStorePath('all-trust'); const policyPath = '/tmp/hybridclaw-missing-policy.yaml'; - const prompt = 'Fetch from example.com'; - const argsJson = JSON.stringify({ url: 'https://example.com' }); + const prompt = 'Write the report to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const runtime = new TrustedCoworkerApprovalRuntime( policyPath, @@ -701,7 +737,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { allTrustStorePath, ); const first = runtime.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -713,7 +749,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(prelude?.approvalMode).toBe('all'); const second = runtime.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -725,7 +761,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { allTrustStorePath, ); const third = restarted.evaluateToolCall({ - toolName: 'web_fetch', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -828,8 +864,10 @@ describe('TrustedCoworkerApprovalRuntime', () => { vi.stubEnv('HYBRIDCLAW_AGENT_WORKSPACE_ROOT', workspaceRoot); vi.resetModules(); - const prompt = 'Open example.com'; - const argsJson = JSON.stringify({ url: 'https://example.com' }); + const prompt = 'Write the report to a host file'; + const argsJson = JSON.stringify({ + command: 'touch /Users/example/report.txt', + }); const trustStorePath = path.join(workspaceRoot, 'approval-trust.json'); const { TrustedCoworkerApprovalRuntime: HostModeApprovalRuntime } = @@ -837,7 +875,7 @@ describe('TrustedCoworkerApprovalRuntime', () => { const runtime = new HostModeApprovalRuntime(); const first = runtime.evaluateToolCall({ - toolName: 'browser_navigate', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -852,11 +890,11 @@ describe('TrustedCoworkerApprovalRuntime', () => { const persisted = JSON.parse(fs.readFileSync(trustStorePath, 'utf-8')) as { allowlistedActions?: string[]; }; - expect(persisted.allowlistedActions).toContain('network:example.com'); + expect(persisted.allowlistedActions).toContain('bash:workspace-fence'); const restarted = new HostModeApprovalRuntime(); const second = restarted.evaluateToolCall({ - toolName: 'browser_navigate', + toolName: 'bash', argsJson, latestUserPrompt: prompt, }); @@ -885,6 +923,107 @@ describe('TrustedCoworkerApprovalRuntime', () => { expect(evaluation.tier).toBe('yellow'); }); + test('deny network rules hard-block matching hosts', () => { + const policyPath = writeTempPolicy(` +approval: + workspace_fence: true + +network: + default: deny + rules: + - action: deny + host: "api.github.com" + methods: ["GET"] + paths: ["/repos/**"] + agent: "*" +`); + const runtime = new TrustedCoworkerApprovalRuntime(policyPath); + + const evaluation = runtime.evaluateToolCall({ + toolName: 'http_request', + argsJson: JSON.stringify({ + url: 'https://api.github.com/repos/openai/openai', + method: 'GET', + }), + latestUserPrompt: 'Fetch the repo metadata', + }); + + expect(evaluation.tier).toBe('red'); + expect(evaluation.decision).toBe('denied'); + expect(evaluation.reason).toBe('this host is blocked by approval policy'); + }); + + test('network rules honor method, path, and agent scoping', () => { + const policyPath = writeTempPolicy(` +network: + default: deny + rules: + - action: allow + host: "api.github.com" + methods: ["GET"] + paths: ["/repos/**"] + agent: "research" +`); + vi.stubEnv('HYBRIDCLAW_AGENT_ID', 'research'); + const runtime = new TrustedCoworkerApprovalRuntime(policyPath); + + const allowed = runtime.evaluateToolCall({ + toolName: 'http_request', + argsJson: JSON.stringify({ + url: 'https://api.github.com/repos/openai/openai', + method: 'GET', + }), + latestUserPrompt: 'Fetch the repo metadata', + }); + const wrongMethod = runtime.evaluateToolCall({ + toolName: 'http_request', + argsJson: JSON.stringify({ + url: 'https://api.github.com/repos/openai/openai', + method: 'POST', + }), + latestUserPrompt: 'Create the repo metadata', + }); + + vi.stubEnv('HYBRIDCLAW_AGENT_ID', 'main'); + const wrongAgent = runtime.evaluateToolCall({ + toolName: 'http_request', + argsJson: JSON.stringify({ + url: 'https://api.github.com/repos/openai/openai', + method: 'GET', + }), + latestUserPrompt: 'Fetch the repo metadata', + }); + + expect(allowed.tier).toBe('green'); + expect(allowed.decision).toBe('auto'); + expect(wrongMethod.tier).toBe('yellow'); + expect(wrongMethod.decision).toBe('implicit'); + expect(wrongAgent.tier).toBe('yellow'); + expect(wrongAgent.decision).toBe('implicit'); + }); + + test('legacy trusted_network_hosts entries migrate to allow rules on load', () => { + const policyPath = writeTempPolicy(` +approval: + trusted_network_hosts: ["api.github.com"] +`); + const runtime = new TrustedCoworkerApprovalRuntime(policyPath); + + const evaluation = runtime.evaluateToolCall({ + toolName: 'web_fetch', + argsJson: JSON.stringify({ + url: 'https://api.github.com/repos/openai/openai', + }), + latestUserPrompt: 'Open the GitHub API docs', + }); + + expect(evaluation.tier).toBe('green'); + expect(evaluation.decision).toBe('auto'); + expect(evaluation.reason).toBe( + 'this host is allowlisted in approval policy', + ); + }); + test('hybridclaw.io is allowlisted by default and does not require approval', () => { const runtime = new TrustedCoworkerApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', diff --git a/tests/command-registry.test.ts b/tests/command-registry.test.ts index 4c666494..f434a30d 100644 --- a/tests/command-registry.test.ts +++ b/tests/command-registry.test.ts @@ -700,3 +700,76 @@ test('builds local session help entries from the registry with surface filtering [...webHelp.map((entry) => entry.command)].sort(compareCommands), ); }); + +test('registers policy as a local-only slash command and parses slash args', async () => { + const { + buildCanonicalSlashCommandDefinitions, + buildTuiSlashCommandDefinitions, + buildLocalSessionSlashHelpEntries, + isRegisteredTextCommandName, + mapCanonicalCommandToGatewayArgs, + parseCanonicalSlashCommandArgs, + } = await importCommandRegistry(); + + expect(isRegisteredTextCommandName('policy')).toBe(true); + expect( + buildCanonicalSlashCommandDefinitions([]).some( + (definition) => definition.name === 'policy', + ), + ).toBe(false); + expect(buildTuiSlashCommandDefinitions([])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'policy', + options: expect.arrayContaining([ + expect.objectContaining({ kind: 'subcommand', name: 'status' }), + expect.objectContaining({ kind: 'subcommand', name: 'list' }), + expect.objectContaining({ kind: 'subcommand', name: 'allow' }), + expect.objectContaining({ kind: 'subcommand', name: 'preset' }), + ]), + }), + ]), + ); + expect( + buildLocalSessionSlashHelpEntries('web').some( + (entry) => + entry.command === + '/policy [status|list|allow|deny|delete|preset|default|reset]', + ), + ).toBe(true); + expect( + parseCanonicalSlashCommandArgs({ + commandName: 'policy', + getString: (name) => + name === 'host' + ? 'api.github.com' + : name === 'agent' + ? 'research' + : name === 'methods' + ? 'GET,POST' + : name === 'paths' + ? '/repos/**' + : name === 'comment' + ? 'GitHub API' + : null, + getSubcommand: () => 'allow', + }), + ).toEqual([ + 'policy', + 'allow', + 'api.github.com', + '--agent', + 'research', + '--methods', + 'GET,POST', + '--paths', + '/repos/**', + '--comment', + 'GitHub API', + ]); + expect(mapCanonicalCommandToGatewayArgs(['policy'])).toEqual(['policy']); + expect(mapCanonicalCommandToGatewayArgs(['policy', 'list'])).toEqual([ + 'policy', + 'list', + ]); +}); diff --git a/tests/gateway-service.policy-command.test.ts b/tests/gateway-service.policy-command.test.ts new file mode 100644 index 00000000..1605ffab --- /dev/null +++ b/tests/gateway-service.policy-command.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, test, vi } from 'vitest'; + +const ORIGINAL_HOME = process.env.HOME; + +function makeTempHome(): string { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hybridclaw-home-')); + fs.mkdirSync(path.join(homeDir, '.hybridclaw'), { recursive: true }); + return homeDir; +} + +afterEach(() => { + vi.resetModules(); + if (ORIGINAL_HOME) { + process.env.HOME = ORIGINAL_HOME; + } else { + delete process.env.HOME; + } +}); + +test('policy command runs from local TUI sessions', async () => { + process.env.HOME = makeTempHome(); + vi.resetModules(); + + const { initDatabase } = await import('../src/memory/db.ts'); + const { handleGatewayCommand } = await import( + '../src/gateway/gateway-service.ts' + ); + + initDatabase({ quiet: true }); + + const allow = await handleGatewayCommand({ + sessionId: 'session-policy-local', + guildId: null, + channelId: 'tui', + args: ['policy', 'allow', 'api.github.com', '--agent', 'research'], + }); + expect(allow.kind).toBe('plain'); + expect(allow.text).toContain('Rule added: [2] ALLOW api.github.com:443'); + + const list = await handleGatewayCommand({ + sessionId: 'session-policy-local', + guildId: null, + channelId: 'tui', + args: ['policy', 'list', '--agent', 'research'], + }); + expect(list.kind).toBe('info'); + if (list.kind !== 'info') { + throw new Error(`Unexpected result kind: ${list.kind}`); + } + expect(list.title).toBe('Policy Rules'); + expect(list.text).toContain('api.github.com'); + expect(list.text).toContain('research'); +}); + +test('policy command is rejected outside local TUI/web sessions', async () => { + process.env.HOME = makeTempHome(); + vi.resetModules(); + + const { initDatabase } = await import('../src/memory/db.ts'); + const { handleGatewayCommand } = await import( + '../src/gateway/gateway-service.ts' + ); + + initDatabase({ quiet: true }); + + const result = await handleGatewayCommand({ + sessionId: 'session-policy-remote', + guildId: 'guild-1', + channelId: 'discord-channel-1', + args: ['policy', 'status'], + }); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') { + throw new Error(`Unexpected result kind: ${result.kind}`); + } + expect(result.title).toBe('Policy Restricted'); + expect(result.text).toContain('only available from local TUI/web sessions'); +}); diff --git a/tests/policy-cli.test.ts b/tests/policy-cli.test.ts new file mode 100644 index 00000000..ac0eda30 --- /dev/null +++ b/tests/policy-cli.test.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, test, vi } from 'vitest'; + +import { runPolicyCommand } from '../src/commands/policy-command.js'; +import { handlePolicyCommand } from '../src/policy/policy-cli.js'; +import { + readPolicyState, + resolveWorkspacePolicyPath, +} from '../src/policy/policy-store.js'; + +const originalCwd = process.cwd(); + +function makeWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hybridclaw-policy-cli-')); +} + +afterEach(() => { + process.chdir(originalCwd); + vi.restoreAllMocks(); +}); + +test('policy command supports status, allow, list, default, and delete flows', () => { + const workspacePath = makeWorkspace(); + + const status = runPolicyCommand([], { workspacePath }); + expect(status.kind).toBe('info'); + expect(status.title).toBe('Policy Status'); + expect(status.text).toContain('Default: deny'); + + const allow = runPolicyCommand( + [ + 'allow', + 'api.github.com', + '--methods', + 'GET,POST', + '--agent', + 'main', + '--comment', + 'GitHub API', + ], + { workspacePath }, + ); + expect(allow.kind).toBe('plain'); + expect(allow.text).toContain('Rule added: [2] ALLOW api.github.com:443'); + + const list = runPolicyCommand(['list'], { workspacePath }); + expect(list.kind).toBe('info'); + expect(list.text).toContain('Default: deny'); + expect(list.text).toContain('api.github.com'); + expect(list.text).toContain('GitHub API'); + + const listJson = runPolicyCommand(['list', '--agent', 'main', '--json'], { + workspacePath, + }); + expect(listJson.kind).toBe('info'); + expect(JSON.parse(listJson.text)).toMatchObject({ + default: 'deny', + rules: expect.arrayContaining([ + expect.objectContaining({ host: 'hybridclaw.io', agent: '*' }), + expect.objectContaining({ host: 'api.github.com', agent: 'main' }), + ]), + }); + + const setDefault = runPolicyCommand(['default', 'allow'], { workspacePath }); + expect(setDefault.kind).toBe('plain'); + expect(setDefault.text).toBe('Default policy: allow'); + + const deleted = runPolicyCommand(['delete', 'api.github.com'], { + workspacePath, + }); + expect(deleted.kind).toBe('plain'); + expect(deleted.text).toContain('Deleted rule #2: api.github.com'); +}); + +test('policy preset commands support list, dry-run, apply, and remove', () => { + const workspacePath = makeWorkspace(); + + const presetList = runPolicyCommand(['preset', 'list'], { workspacePath }); + expect(presetList.kind).toBe('info'); + expect(presetList.text).toContain('github'); + expect(presetList.text).toContain('npm'); + + const dryRun = runPolicyCommand(['preset', 'add', 'github', '--dry-run'], { + workspacePath, + }); + expect(dryRun.kind).toBe('info'); + expect(dryRun.title).toBe('Policy Preset Dry Run'); + expect(dryRun.text).toContain("Preset 'github' would add:"); + expect(dryRun.text).toContain('api.github.com:443'); + + const applied = runPolicyCommand(['preset', 'add', 'github'], { + workspacePath, + }); + expect(applied.kind).toBe('plain'); + expect(applied.text).toContain("Applied preset 'github'"); + + let state = readPolicyState(workspacePath); + expect(state.presets).toEqual(['github']); + expect(state.rules.some((rule) => rule.host === 'api.github.com')).toBe(true); + + const removed = runPolicyCommand(['preset', 'remove', 'github'], { + workspacePath, + }); + expect(removed.kind).toBe('plain'); + expect(removed.text).toContain("Removed preset 'github'"); + + state = readPolicyState(workspacePath); + expect(state.presets).toEqual([]); + expect(state.rules).toEqual([ + expect.objectContaining({ host: 'hybridclaw.io' }), + ]); +}); + +test('removing a preset preserves identical manual rules', () => { + const workspacePath = makeWorkspace(); + + const manual = runPolicyCommand( + [ + 'allow', + 'api.github.com', + '--methods', + 'GET,POST', + '--comment', + 'Manual duplicate', + ], + { workspacePath }, + ); + expect(manual.kind).toBe('plain'); + + const applied = runPolicyCommand(['preset', 'add', 'github'], { + workspacePath, + }); + expect(applied.kind).toBe('plain'); + + let state = readPolicyState(workspacePath); + expect(state.rules.filter((rule) => rule.host === 'api.github.com')).toHaveLength( + 2, + ); + + const removed = runPolicyCommand(['preset', 'remove', 'github'], { + workspacePath, + }); + expect(removed.kind).toBe('plain'); + + state = readPolicyState(workspacePath); + expect(state.presets).toEqual([]); + expect(state.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: 'api.github.com', + comment: 'Manual duplicate', + }), + ]), + ); + expect(state.rules.filter((rule) => rule.host === 'api.github.com')).toHaveLength( + 1, + ); + expect(state.rules.some((rule) => rule.host === 'github.com')).toBe(false); + expect( + state.rules.some((rule) => rule.host === 'raw.githubusercontent.com'), + ).toBe(false); +}); + +test('policy CLI handler writes to the workspace under the current working directory', async () => { + const workspacePath = makeWorkspace(); + process.chdir(workspacePath); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handlePolicyCommand(['allow', 'example.com']); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Rule added: [2] ALLOW example.com:443'), + ); + expect(fs.existsSync(resolveWorkspacePolicyPath(workspacePath))).toBe(true); +}); + +test('policy CLI handler throws usage errors from the shared command runner', async () => { + const workspacePath = makeWorkspace(); + process.chdir(workspacePath); + + await expect(handlePolicyCommand(['default', 'maybe'])).rejects.toThrow( + 'Usage: `policy default `', + ); +}); diff --git a/tests/policy-store.test.ts b/tests/policy-store.test.ts new file mode 100644 index 00000000..f1630e77 --- /dev/null +++ b/tests/policy-store.test.ts @@ -0,0 +1,197 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import YAML from 'yaml'; + +import type { NetworkRule } from '../src/policy/network-policy.js'; +import { + addPolicyRule, + deletePolicyRule, + readPolicyState, + resetPolicyNetwork, + resolveWorkspacePolicyPath, + setPolicyDefault, + setPolicyPresets, +} from '../src/policy/policy-store.js'; + +function makeWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hybridclaw-policy-store-')); +} + +function writePolicy(workspacePath: string, raw: string): void { + const policyPath = resolveWorkspacePolicyPath(workspacePath); + fs.mkdirSync(path.dirname(policyPath), { recursive: true }); + fs.writeFileSync(policyPath, `${raw.trim()}\n`, 'utf-8'); +} + +function readPolicyDocument(workspacePath: string): Record { + return YAML.parse( + fs.readFileSync(resolveWorkspacePolicyPath(workspacePath), 'utf-8'), + ) as Record; +} + +test('reads the default network policy when policy.yaml is missing', () => { + const workspacePath = makeWorkspace(); + + const state = readPolicyState(workspacePath); + + expect(state.exists).toBe(false); + expect(state.defaultAction).toBe('deny'); + expect(state.presets).toEqual([]); + expect(state.rules).toEqual([ + expect.objectContaining({ + index: 1, + action: 'allow', + host: 'hybridclaw.io', + port: 443, + methods: ['*'], + paths: ['/**'], + agent: '*', + }), + ]); +}); + +test('migrates legacy trusted_network_hosts into structured rules and removes the legacy field on write', () => { + const workspacePath = makeWorkspace(); + writePolicy( + workspacePath, + ` +approval: + trusted_network_hosts: + - api.github.com + - docs.python.org +`, + ); + + const initial = readPolicyState(workspacePath); + expect(initial.rules.map((rule) => rule.host)).toEqual([ + 'api.github.com', + 'docs.python.org', + ]); + expect(initial.rules.every((rule) => rule.action === 'allow')).toBe(true); + + addPolicyRule(workspacePath, { + action: 'deny', + host: 'evil.example', + port: 443, + methods: ['*'], + paths: ['/**'], + agent: '*', + }); + + const document = readPolicyDocument(workspacePath); + expect( + (document.approval as { trusted_network_hosts?: unknown }) + ?.trusted_network_hosts, + ).toBeUndefined(); + expect( + ( + (document.network as { rules?: Array<{ host?: string }> }).rules || [] + ).map((rule) => rule.host), + ).toEqual(['api.github.com', 'docs.python.org', 'evil.example']); +}); + +test('updates default action, deletes rules by host, and resets to the packaged default', () => { + const workspacePath = makeWorkspace(); + const customRule: NetworkRule = { + action: 'allow', + host: 'api.openai.com', + port: 443, + methods: ['GET', 'POST'], + paths: ['/v1/**'], + agent: 'research', + comment: 'Research agent', + }; + + addPolicyRule(workspacePath, customRule); + let state = setPolicyDefault(workspacePath, 'allow'); + expect(state.defaultAction).toBe('allow'); + expect(state.rules).toHaveLength(2); + + const deleted = deletePolicyRule(workspacePath, 'api.openai.com'); + expect(deleted.deleted).toHaveLength(1); + expect(deleted.deleted[0]?.host).toBe('api.openai.com'); + state = deleted.state; + expect(state.rules).toEqual([ + expect.objectContaining({ host: 'hybridclaw.io' }), + ]); + + state = resetPolicyNetwork(workspacePath); + expect(state.defaultAction).toBe('deny'); + expect(state.presets).toEqual([]); + expect(state.rules).toEqual([ + expect.objectContaining({ + host: 'hybridclaw.io', + action: 'allow', + methods: ['*'], + paths: ['/**'], + agent: '*', + }), + ]); +}); + +test('network writes stay confined to the network section', () => { + const workspacePath = makeWorkspace(); + + addPolicyRule(workspacePath, { + action: 'allow', + host: 'example.com', + port: 443, + methods: ['GET'], + paths: ['/docs/**'], + agent: 'main', + }); + + const document = readPolicyDocument(workspacePath); + expect(document.approval).toBeUndefined(); + expect(document.audit).toBeUndefined(); + expect(document.network).toMatchObject({ + default: 'deny', + presets: [], + }); +}); + +test('stores normalized preset bookkeeping alongside explicit rules', () => { + const workspacePath = makeWorkspace(); + + const state = setPolicyPresets(workspacePath, { + presets: ['GitHub', 'github', 'NPM'], + rules: [ + { + action: 'allow', + host: 'api.github.com', + port: 443, + methods: ['GET'], + paths: ['/repos/**'], + agent: '*', + managedByPreset: 'GitHub', + }, + ], + }); + + expect(state.presets).toEqual(['github', 'npm']); + expect(state.rules).toEqual([ + expect.objectContaining({ + host: 'api.github.com', + methods: ['GET'], + paths: ['/repos/**'], + managedByPreset: 'github', + }), + ]); + expect(readPolicyDocument(workspacePath).network).toMatchObject({ + presets: ['github', 'npm'], + rules: [ + expect.objectContaining({ + host: 'api.github.com', + managed_by_preset: 'github', + }), + ], + }); + expect(readPolicyState(workspacePath).rules).toEqual([ + expect.objectContaining({ + host: 'api.github.com', + managedByPreset: 'github', + }), + ]); +}); diff --git a/tests/tui-banner.test.ts b/tests/tui-banner.test.ts index 1325b559..40a5915b 100644 --- a/tests/tui-banner.test.ts +++ b/tests/tui-banner.test.ts @@ -272,6 +272,7 @@ test('wraps panel rows for very narrow terminals and defaults provider to Hybrid '│ /info │', '│ /mcp │', '│ /model │', + '│ /policy │', '│ /rag │', '│ /ralph │', '│ /reset │',