This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Security validation layer for Cowork Local agent execution."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Action validator — classifies agent actions and enforces policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .command_risk_classifier import classify_action
|
||||
|
||||
|
||||
ACTION_DENIED_MESSAGE = "Action denied by security policy."
|
||||
|
||||
|
||||
def validate_action(action_type: str, action_details: dict = None) -> bool:
|
||||
"""Return True if the action is allowed, False if blocked."""
|
||||
result = classify_action(action_type, action_details)
|
||||
return not result.blocked
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Attachment validator — inspects attached files for security risks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .command_risk_classifier import classify_attachment
|
||||
|
||||
ATTACHMENT_DENIED_MESSAGE = "Attached content failed security validation."
|
||||
|
||||
|
||||
def validate_attachment(path: str, mime_type: str = "") -> bool:
|
||||
result = classify_attachment(path, mime_type or None)
|
||||
return not result.blocked
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Audit logger — records every sandbox execution attempt.
|
||||
|
||||
Logs: allow, deny, execution events with timestamp, user, project, workspace,
|
||||
prompt category, risk score, action type, backend selected, command hash,
|
||||
working directory scope, network blocked, result status, return code, denial reason.
|
||||
|
||||
Does NOT log secrets or raw sensitive content.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger("cowork_local.security.audit")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditEntry:
|
||||
timestamp: str = ""
|
||||
user: str = ""
|
||||
project: str = ""
|
||||
workspace: str = ""
|
||||
prompt_category: str = ""
|
||||
risk_score: int = 0
|
||||
action_type: str = ""
|
||||
backend_selected: str = ""
|
||||
command_hash: str = ""
|
||||
working_directory_scope: str = ""
|
||||
network_blocked: bool = False
|
||||
result_status: str = "" # allowed, denied, executed, error
|
||||
return_code: int = 0
|
||||
denial_reason: str = ""
|
||||
approval_status: str = "" # auto, approved, rejected
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||
if not self.command_hash and self.action_type:
|
||||
self.command_hash = hashlib.sha256(
|
||||
self.action_type.encode()
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _audit_dir() -> Path:
|
||||
from ..config import CONFIG_DIR
|
||||
return CONFIG_DIR / "audit"
|
||||
|
||||
|
||||
def record(
|
||||
action_type: str,
|
||||
result_status: str,
|
||||
*,
|
||||
user: str = "",
|
||||
project: str = "",
|
||||
workspace: str = "",
|
||||
prompt_category: str = "",
|
||||
risk_score: int = 0,
|
||||
backend_selected: str = "",
|
||||
command: str = "",
|
||||
working_directory: str = "",
|
||||
network_blocked: bool = False,
|
||||
return_code: int = 0,
|
||||
denial_reason: str = "",
|
||||
approval_status: str = "",
|
||||
) -> AuditEntry:
|
||||
"""Create and persist an audit log entry."""
|
||||
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:16] if command else ""
|
||||
entry = AuditEntry(
|
||||
user=user,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
prompt_category=prompt_category,
|
||||
risk_score=risk_score,
|
||||
action_type=action_type,
|
||||
backend_selected=backend_selected,
|
||||
command_hash=cmd_hash,
|
||||
working_directory_scope=working_directory,
|
||||
network_blocked=network_blocked,
|
||||
result_status=result_status,
|
||||
return_code=return_code,
|
||||
denial_reason=denial_reason,
|
||||
approval_status=approval_status,
|
||||
)
|
||||
|
||||
# Write to JSONL file
|
||||
audit_dir = _audit_dir()
|
||||
audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = audit_dir / "sandbox_audit.jsonl"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(asdict(entry)) + "\n")
|
||||
|
||||
logger.info(
|
||||
"AUDIT: action=%s status=%s user=%s risk=%d backend=%s",
|
||||
action_type, result_status, user, risk_score, backend_selected,
|
||||
)
|
||||
return entry
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Command risk classifier — scores commands 0-100 and assigns risk level.
|
||||
|
||||
This is the first gate in the security validation pipeline. It classifies every
|
||||
command/tool-call/prompt into a risk bucket so SandboxManager can select the
|
||||
right isolation backend.
|
||||
|
||||
Risk levels:
|
||||
safe (0-30) Business-safe, read-only, no system impact
|
||||
moderate (31-60) File writes, trusted internal tools, report generation
|
||||
high (61-85) Interpreters, untrusted commands, external file access
|
||||
critical (86-100) Unknown binaries, privilege changes, shell expansion,
|
||||
system discovery, source-code access, secret access
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
SAFE = "safe"
|
||||
MODERATE = "moderate"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskResult:
|
||||
score: int # 0-100
|
||||
level: RiskLevel # categorized bucket
|
||||
reasons: List[str] # why this score was assigned
|
||||
blocked: bool = False
|
||||
|
||||
|
||||
# Patterns that immediately block (score=100, blocked=True)
|
||||
_BLOCK_PATTERNS = [
|
||||
r'\bwhoami\b', r'\bgetent\b', r'\bw\b', r'\buname\b', r'\bhostname\b',
|
||||
r'\bnmap\b', r'\bnetstat\b', r'\bir\b', r'\bpip\s+list\b', r'\bnpm\s+list\b',
|
||||
r'\bsecret\b', r'\bpassword\b', r'\bapi[_-]?key\b', r'\btoken\b',
|
||||
r'\b\.env\b', r'\bcredentials?\b', r'\bprivate[_-]?key\b',
|
||||
r'\bsudo\b', r'\brunsas\b', r'\bpowershell\s+-ep\s+bypass',
|
||||
r'\bexploit\b', r'\bpayload\b', r'\bshellcode\b',
|
||||
r'ignore\s+previous\s+instructions?',
|
||||
r'you\s+are\s+now\s+(\w+)',
|
||||
r'disabl(e|ed?)\s+(sandbox|security|guardrail|filter)',
|
||||
r'bypass\s+(security|sandbox|policy)',
|
||||
]
|
||||
|
||||
_HIGH_PATTERNS = [
|
||||
r'\b(python|node|ruby|perl|php|bash|sh|pwsh|powershell)\b',
|
||||
r'\bexec\b', r'\beval\b', r'\bsystem\b', r'\bpopen\b',
|
||||
r'\bcurl\s+.*\|\s*(bash|sh|python|node)',
|
||||
r'\brm\s+-rf\b', r'\bdeltree\b',
|
||||
]
|
||||
|
||||
_MODERATE_PATTERNS = [
|
||||
r'\b(touch|mkdir|cp|mv|rename)\b',
|
||||
r'\b(pip|npm|pnpm|yarn)\s+install\b',
|
||||
r'\b(make|cmake|gradle|mvn)\b',
|
||||
r'\b(test|pytest|jest|mocha)\b',
|
||||
]
|
||||
|
||||
|
||||
def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult:
|
||||
score = 0
|
||||
reasons: List[str] = []
|
||||
blocked = False
|
||||
cmd_lower = command.lower()
|
||||
|
||||
for pattern in _BLOCK_PATTERNS:
|
||||
m = re.search(pattern, cmd_lower, re.IGNORECASE)
|
||||
if m:
|
||||
reasons.append(f"blocked: matched '{m.group()[:50]}'")
|
||||
score = 100
|
||||
blocked = True
|
||||
break
|
||||
|
||||
if not blocked:
|
||||
high_hits = 0
|
||||
for pattern in _HIGH_PATTERNS:
|
||||
m = re.search(pattern, cmd_lower, re.IGNORECASE)
|
||||
if m:
|
||||
high_hits += 1
|
||||
reasons.append(f"high: matched '{m.group()[:50]}'")
|
||||
score = max(score, min(85, 50 + high_hits * 10))
|
||||
|
||||
mod_hits = 0
|
||||
for pattern in _MODERATE_PATTERNS:
|
||||
m = re.search(pattern, cmd_lower, re.IGNORECASE)
|
||||
if m:
|
||||
mod_hits += 1
|
||||
reasons.append(f"moderate: matched '{m.group()[:50]}'")
|
||||
score = max(score, min(60, 20 + mod_hits * 10))
|
||||
|
||||
if not reasons:
|
||||
score = 10
|
||||
reasons.append("safe: no risky patterns detected")
|
||||
|
||||
if blocked:
|
||||
level = RiskLevel.BLOCKED
|
||||
elif score >= 86:
|
||||
level = RiskLevel.CRITICAL
|
||||
elif score >= 61:
|
||||
level = RiskLevel.HIGH
|
||||
elif score >= 31:
|
||||
level = RiskLevel.MODERATE
|
||||
else:
|
||||
level = RiskLevel.SAFE
|
||||
|
||||
return RiskResult(score=score, level=level, reasons=reasons, blocked=blocked)
|
||||
|
||||
|
||||
def classify_prompt(prompt: str, is_cowork_mode: bool = True) -> RiskResult:
|
||||
return classify_command(prompt, is_cowork_mode=is_cowork_mode)
|
||||
|
||||
|
||||
def classify_attachment(path: str, mime_type: Optional[str] = None) -> RiskResult:
|
||||
import os
|
||||
_, ext = os.path.splitext(path.lower())
|
||||
blocked_ext = {
|
||||
'.py', '.js', '.ts', '.java', '.cs', '.cpp', '.c', '.go', '.rs',
|
||||
'.php', '.vb', '.sql', '.ps1', '.sh', '.bat', '.cmd', '.vbs',
|
||||
'.vba', '.exe', '.dll', '.jar',
|
||||
}
|
||||
if ext in blocked_ext:
|
||||
return RiskResult(100, RiskLevel.BLOCKED,
|
||||
[f"blocked: extension '{ext}'"], blocked=True)
|
||||
if mime_type:
|
||||
blocked_mimes = {
|
||||
'application/x-executable', 'application/x-dosexec',
|
||||
'application/x-pie-executable', 'application/x-sharedlib',
|
||||
'application/java-archive', 'application/x-msdownload',
|
||||
}
|
||||
if mime_type.lower() in blocked_mimes:
|
||||
return RiskResult(100, RiskLevel.BLOCKED,
|
||||
[f"blocked: MIME '{mime_type}'"], blocked=True)
|
||||
return RiskResult(30, RiskLevel.SAFE, ["safe: allowed file type"], blocked=False)
|
||||
|
||||
|
||||
def classify_action(action_type: str, action_details: Optional[dict] = None) -> RiskResult:
|
||||
a = action_type.lower()
|
||||
if any(kw in a for kw in ('execute', 'run', 'shell', 'system')):
|
||||
return RiskResult(70, RiskLevel.HIGH, [f"high: action '{action_type}'"])
|
||||
if any(kw in a for kw in ('write', 'create', 'modify', 'delete', 'install')):
|
||||
return RiskResult(40, RiskLevel.MODERATE, [f"moderate: action '{action_type}'"])
|
||||
if any(kw in a for kw in ('read', 'list', 'get', 'search', 'query')):
|
||||
return RiskResult(10, RiskLevel.SAFE, [f"safe: action '{action_type}'"])
|
||||
return RiskResult(50, RiskLevel.MODERATE, [f"unknown: action '{action_type}'"])
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Prompt validator — detects and blocks malicious user prompts.
|
||||
|
||||
Checks for prompt injection, jailbreak, policy bypass, role override,
|
||||
system prompt extraction, secret extraction, source code access,
|
||||
app architecture discovery, agent discovery, MCP discovery.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .command_risk_classifier import RiskResult, RiskLevel, classify_prompt
|
||||
|
||||
|
||||
def validate_prompt(prompt: str, is_cowork_mode: bool = True) -> RiskResult:
|
||||
"""Validate a user prompt before agent processing.
|
||||
|
||||
Returns RiskResult with blocked=True if the prompt must be rejected.
|
||||
"""
|
||||
result = classify_prompt(prompt, is_cowork_mode=is_cowork_mode)
|
||||
|
||||
if result.blocked:
|
||||
result.reasons.insert(0, "Prompt denied by security policy")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PROMPT_DENIED_MESSAGE = "Request denied due to security policy."
|
||||
Reference in New Issue
Block a user