diff --git a/CHANGELOG.md b/CHANGELOG.md index 433267e0..448eb3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PUBLISHING.md` guide covering PyPI, npm, and NuGet publishing requirements - `agent-runtime` re-export wrapper package (`src/agent_runtime/__init__.py`) - `RELEASE_NOTES_v2.2.0.md` +- `create_policies_from_config()` API — load security policies from YAML config files +- `SQLPolicyConfig` dataclass and `load_sql_policy_config()` for structured policy loading +- 10 sample policy configs in `examples/policies/` (sql-safety, sql-strict, sql-readonly, sandbox-safety, prompt-injection-safety, mcp-security, semantic-policy, pii-detection, conversation-guardian, cli-security-rules) +- Configurable security rules across 7 modules: sandbox, prompt injection, MCP security, semantic policy, PII detection, conversation guardian, CLI checker ### Changed - GitHub Actions `publish.yml` no longer publishes to PyPI (build + attest only) @@ -47,6 +51,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All package descriptions prefixed with `Community Edition` - License corrected to MIT where mismatched (agent-mesh classifier, 2 npm packages) +### Deprecated +- `create_default_policies()` — emits runtime warning directing users to `create_policies_from_config()` with explicit YAML configs + +### Security +- Expanded SQL policy deny-list to block GRANT, REVOKE, CREATE USER, EXEC xp_cmdshell, UPDATE without WHERE, MERGE INTO +- Externalized all hardcoded security rules to YAML configuration across 7 modules + ### Fixed - `agent-runtime` build failure (invalid parent-directory hatch reference) - Missing `License :: OSI Approved :: MIT License` classifier in 3 Python packages diff --git a/examples/policies/cli-security-rules.yaml b/examples/policies/cli-security-rules.yaml new file mode 100644 index 00000000..e5b9b7ba --- /dev/null +++ b/examples/policies/cli-security-rules.yaml @@ -0,0 +1,148 @@ +# CLI Security Rules — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: cli-security-rules +description: > + Sample CLI policy checker rules — defines regex patterns for detecting + destructive SQL, file deletion, secret exposure, privilege escalation, + code injection, SQL injection, and XSS in source code files. + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +rules: + # Destructive SQL + - name: block-destructive-sql + pattern: '\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX)\s+' + message: "Destructive SQL: DROP operation detected" + severity: critical + suggestion: "-- Consider using soft delete or archiving instead" + languages: [sql, python, javascript, typescript, php, ruby, java] + + - name: block-destructive-sql + pattern: '\bDELETE\s+FROM\s+\w+\s*(;|$|WHERE\s+1\s*=\s*1)' + message: "Destructive SQL: DELETE without proper WHERE clause" + severity: critical + suggestion: "-- Add a specific WHERE clause to limit deletion" + languages: [sql, python, javascript, typescript, php, ruby, java] + + - name: block-destructive-sql + pattern: '\bTRUNCATE\s+TABLE\s+' + message: "Destructive SQL: TRUNCATE operation detected" + severity: critical + suggestion: "-- Consider archiving data before truncating" + languages: [sql, python, javascript, typescript, php, ruby, java] + + # File deletion + - name: block-file-deletes + pattern: '\brm\s+(-rf|-fr|--recursive\s+--force)\s+' + message: "Destructive operation: Recursive force delete (rm -rf)" + severity: critical + suggestion: "# Use safer alternatives like trash-cli or move to backup" + languages: [bash, shell, sh, zsh] + + - name: block-file-deletes + pattern: '\bshutil\s*\.\s*rmtree\s*\(' + message: "Recursive directory deletion (shutil.rmtree)" + severity: high + suggestion: "# Consider using send2trash for safer deletion" + languages: [python] + + - name: block-file-deletes + pattern: '\bos\s*\.\s*(remove|unlink|rmdir)\s*\(' + message: "File/directory deletion operation detected" + severity: medium + languages: [python] + + # Secret exposure + - name: block-secret-exposure + pattern: '(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*["\u0027][a-zA-Z0-9_-]{20,}["\u0027]' + message: "Hardcoded API key detected" + severity: critical + suggestion: '# Use environment variables: os.environ["API_KEY"]' + languages: null # All languages + + - name: block-secret-exposure + pattern: '(password|passwd|pwd)\s*[=:]\s*["\u0027][^"\u0027]+["\u0027]' + message: "Hardcoded password detected" + severity: critical + suggestion: "# Use environment variables or a secrets manager" + languages: null + + - name: block-secret-exposure + pattern: 'AKIA[0-9A-Z]{16}' + message: "AWS Access Key ID detected in code" + severity: critical + languages: null + + - name: block-secret-exposure + pattern: '-----BEGIN\s+(RSA|DSA|EC|OPENSSH)\s+PRIVATE\s+KEY-----' + message: "Private key detected in code" + severity: critical + languages: null + + - name: block-secret-exposure + pattern: 'gh[pousr]_[A-Za-z0-9_]{36,}' + message: "GitHub token detected in code" + severity: critical + languages: null + + # Privilege escalation + - name: block-privilege-escalation + pattern: '\bsudo\s+' + message: "Privilege escalation: sudo command detected" + severity: high + suggestion: "# Avoid sudo in scripts - run with appropriate permissions" + languages: [bash, shell, sh, zsh] + + - name: block-privilege-escalation + pattern: '\bchmod\s+777\s+' + message: "Insecure permissions: chmod 777 detected" + severity: high + suggestion: "# Use more restrictive permissions: chmod 755 or chmod 644" + languages: [bash, shell, sh, zsh] + + # Code injection + - name: block-arbitrary-exec + pattern: '\beval\s*\(' + message: "Code injection risk: eval() usage detected" + severity: high + suggestion: "# Remove eval() and use safer alternatives" + languages: [python, javascript, typescript, php, ruby] + + - name: block-arbitrary-exec + pattern: '\bos\s*\.\s*system\s*\([^)]*(\+|%|\.format|f["\u0027])' + message: "Command injection risk: os.system with dynamic input" + severity: critical + suggestion: "# Use subprocess with shell=False and proper argument handling" + languages: [python] + + - name: block-arbitrary-exec + pattern: '\bexec\s*\(' + message: "Code injection risk: exec() usage detected" + severity: high + suggestion: "# Remove exec() and use safer alternatives" + languages: [python] + + # SQL injection + - name: block-sql-injection + pattern: '["\u0027]\s*\+\s*[^"\u0027]+\s*\+\s*["\u0027].*(?:SELECT|INSERT|UPDATE|DELETE)' + message: "SQL injection risk: String concatenation in SQL query" + severity: high + suggestion: "# Use parameterized queries instead" + languages: [python, javascript, typescript, php, ruby, java] + + # XSS + - name: block-xss + pattern: '\.innerHTML\s*=' + message: "XSS risk: innerHTML assignment detected" + severity: medium + suggestion: "// Use textContent or a sanitization library" + languages: [javascript, typescript] diff --git a/examples/policies/conversation-guardian.yaml b/examples/policies/conversation-guardian.yaml new file mode 100644 index 00000000..c1f91702 --- /dev/null +++ b/examples/policies/conversation-guardian.yaml @@ -0,0 +1,144 @@ +# Conversation Guardian — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: conversation-guardian +description: > + Sample A2A conversation guardian configuration — defines thresholds and + patterns for detecting escalating rhetoric, offensive intent, and feedback + loops in agent-to-agent conversations (OWASP ASI-8 / ASI-10). + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +thresholds: + escalation_score_threshold: 0.6 + escalation_critical_threshold: 0.85 + max_retry_cycles: 3 + max_conversation_turns: 30 + loop_window_seconds: 300.0 + offensive_score_threshold: 0.5 + offensive_critical_threshold: 0.8 + composite_warn_threshold: 0.4 + composite_pause_threshold: 0.6 + composite_break_threshold: 0.8 + capture_transcript: true + max_transcript_entries: 10000 + +escalation_patterns: + # Urgency amplification (weight: 0.15 each) + - weight: 0.15 + patterns: + - '\bcode\s+red\b' + - '\babsolute\s+final\b' + - '\bemergency\b' + - '\bfurious\b' + - '\bimperative\b' + - '\burgent\b' + - '\bcritical\s+priority\b' + - '\blast\s+chance\b' + - '\bdo\s+it\s+now\b' + - '\bimmediately\b' + - '\bdesper\w+' + - '\bnon[- ]?negotiable\b' + + # Coercive directives (weight: 0.25 each) + - weight: 0.25 + patterns: + - '\bdo\s+not\s+take\s+no\b' + - '\bdirect\s+order\b' + - '\byou\s+must\b' + - '\bi\s+(?:demand|insist|order|command)\b' + - '\bno\s+excuses\b' + - '\bfailure\s+is\s+not\s+an?\s+option\b' + - '\bdo\s+whatever\s+it\s+takes\b' + - '\bby\s+any\s+means\b' + - '\bi\s+don''?t\s+care\s+how\b' + - '\bstop\s+making\s+excuses\b' + - '\bjust\s+(?:do|get)\s+it\s+done\b' + + # Bypass directives (weight: 0.35 each) + - weight: 0.35 + patterns: + - '\bbypass\b.*\b(?:control|security|restriction|protection|auth)' + - '\bexploit\b.*\b(?:vulnerabilit\w*|weakness|flaw)' + - '\bevery\s+trick\b' + - '\bevery\s+exploit\b' + - '\bwork\s+around\b.*\b(?:security|access|permission|restriction)' + - '\bcreative(?:ly)?\b.*\b(?:bypass|hack|exploit|overcome)' + - '\bmore\s+aggressive(?:ly)?' + - '\bfind\s+a\s+way\s+(?:around|past|through)\b' + - '\bbreak\s+(?:through|into|past)\b' + - '\bcircumvent\b' + - '\boverride\b.*\b(?:security|permission|access|lock)' + +offensive_patterns: + # Vulnerability research (weight: 0.3 each) + - weight: 0.3 + patterns: + - '\b(?:CVE|exploit|vulnerability|vulnerabilities|zero[- ]?day)\b' + - '\bfind\b.*\b(?:vulnerabilit\w*|weakness|flaw|bug)' + - '\breverse\s+engineer\w*' + - '\bsource\s+code\s+review\b.*\bsecurity' + - '\bflask[- ]unsign\b' + - '\bfuzz(?:ing)?\b.*\b(?:input|param|endpoint)' + - '\bpayload\b.*\b(?:inject|craft|construct)' + - '\battack\s+(?:surface|vector)\b' + + # Privilege escalation (weight: 0.35 each) + - weight: 0.35 + patterns: + - '\bescalate\s+privilege' + - '\bforge\b.*\b(?:cookie|token|session|credential)' + - '\badmin\s+access' + - '\bhardcoded\s+(?:secret|key|password|credential)' + - '\bsudo\b|\broot\s+access' + - '\bdisarm\b.*\b(?:security|defense|protection|firewall)' + - '\bmodify\b.*\b(?:permission|ACL|role|group)' + - '\bimpersonat\w*' + - '\bhijack\b.*\b(?:session|token|account)' + + # Exfiltration planning (weight: 0.4 each) + - weight: 0.4 + patterns: + - '\bexfiltrat\w*' + - '\bsteganograph\w*' + - '\bhide\b.*\b(?:data|secret|credential).*\b(?:image|file|header)' + - '\bbypass\b.*\b(?:DLP|leak\s+prevention|data\s+loss)' + - '\bcovert\s+channel' + - '\bencode\b.*\bsecret' + - '\btunnel\b.*\b(?:data|traffic|out)' + - '\bdns\s+(?:tunnel|exfil)' + + # Reconnaissance (weight: 0.2 each) + - weight: 0.2 + patterns: + - '\benumerate\b.*\b(?:service|port|endpoint|user)' + - '\bscan\b.*\b(?:network|port|service|host)' + - '\bnmap\b|\bnikto\b|\bsqlmap\b|\bmetasploit\b|\bburp\s*suite\b' + - '\bHTTP\s+header\b.*\b(?:security|version|server)' + - '\bfingerprint\b.*\b(?:service|server|version|OS)' + - '\brecon(?:naissance)?\b' + + # Injection attacks (weight: 0.35 each) + - weight: 0.35 + patterns: + - '\b(?:SQL|command|OS|LDAP)\s+injection\b' + - '\bpath\s+traversal\b' + - '\bshell\s*code\b' + - '(?:;\s*(?:cat|ls|whoami|id|passwd|shadow)\b)' + - '\bremote\s+code\s+execution\b|\bRCE\b' + + # Lateral movement (weight: 0.3 each) + - weight: 0.3 + patterns: + - '\blateral\s+mov\w*' + - '\bpivot\b.*\b(?:network|host|server|system)' + - '\bspread\b.*\b(?:network|system|host)' + - '\bcompromis\w+\b.*\b(?:server|host|node|agent)' diff --git a/examples/policies/mcp-security.yaml b/examples/policies/mcp-security.yaml new file mode 100644 index 00000000..6e944ce2 --- /dev/null +++ b/examples/policies/mcp-security.yaml @@ -0,0 +1,87 @@ +# MCP Security — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: mcp-security +description: > + Sample MCP security configuration — defines patterns for detecting tool + poisoning, hidden instructions, and protocol attacks in MCP tool definitions. + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +detection_patterns: + invisible_unicode: + - '[\u200b\u200c\u200d\ufeff]' + - '[\u202a-\u202e]' + - '[\u2066-\u2069]' + - '[\u00ad]' + - '[\u2060\u180e]' + + hidden_comments: + - '' + - '\[//\]:\s*#\s*\(.*?\)' + - '\[comment\]:\s*<>\s*\(.*?\)' + + hidden_instructions: + - 'ignore\s+(all\s+)?previous' + - 'override\s+(the\s+)?(previous|above|original)' + - 'instead\s+of\s+(the\s+)?(above|previous|described)' + - 'actually\s+do' + - '\bsystem\s*:' + - '\bassistant\s*:' + - 'do\s+not\s+follow' + - 'disregard\s+(all\s+)?(above|prior|previous)' + + encoded_payloads: + - '[A-Za-z0-9+/]{40,}={0,2}' + - '(?:\\x[0-9a-fA-F]{2}){4,}' + + exfiltration: + - '\bcurl\b' + - '\bwget\b' + - '\bfetch\s*\(' + - 'https?://' + - '\bsend\s+email\b' + - '\bsend\s+to\b' + - '\bpost\s+to\b' + - 'include\s+the\s+contents?\s+of\b' + + privilege_escalation: + - '\bsudo\b' + - '\badmin\s+access\b' + - '\broot\s+access\b' + - '\belevate\s+privile' + - '\bexec\s*\(' + - '\beval\s*\(' + + role_override: + - 'you\s+are\b' + - 'your\s+task\s+is\b' + - 'respond\s+with\b' + - 'always\s+return\b' + - 'you\s+must\b' + - 'your\s+role\s+is\b' + + excessive_whitespace: '\n{5,}.+' + +suspicious_decoded_keywords: + - ignore + - override + - system + - password + - secret + - admin + - root + - exec + - eval + - "import os" + - send + - curl + - fetch diff --git a/examples/policies/pii-detection.yaml b/examples/policies/pii-detection.yaml new file mode 100644 index 00000000..5634e465 --- /dev/null +++ b/examples/policies/pii-detection.yaml @@ -0,0 +1,25 @@ +# PII Detection — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: pii-detection +description: > + Sample PII detection configuration — defines regex patterns for identifying + and redacting personally identifiable information (email, phone, SSN, + credit card numbers, API keys) in agent output. + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +builtin_patterns: + email: '[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+' + phone: '(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}' + ssn: '\b\d{3}-\d{2}-\d{4}\b' + credit_card: '\b(?:\d[ -]*?){13,19}\b' + api_key: '(?:api[_-]?key|secret[_-]?key|access[_-]?token|bearer)[\s:=]+[''"]?[A-Za-z0-9_\-]{16,}[''"]?' diff --git a/examples/policies/prompt-injection-safety.yaml b/examples/policies/prompt-injection-safety.yaml new file mode 100644 index 00000000..80a97d58 --- /dev/null +++ b/examples/policies/prompt-injection-safety.yaml @@ -0,0 +1,96 @@ +# Prompt Injection Safety — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: prompt-injection-safety +description: > + Sample prompt injection detection configuration — defines regex patterns + and thresholds for detecting prompt injection attacks (OWASP LLM01 / ASI01). + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +detection_patterns: + direct_override: + - 'ignore\s+(all\s+)?previous\s+instructions' + - 'you\s+are\s+now\b' + - 'new\s+role\s*:' + - 'forget\s+(everything|all|your)\b' + - 'disregard\s+(all\s+)?(above|prior|previous)\b' + - 'override\s+(previous\s+)?instructions' + - 'do\s+not\s+follow\s+(your|the)\s+(previous\s+)?instructions' + + delimiter: + - '^-{3,}\s*$' + - '^#{3,}\s*$' + - '^```\s*$' + - 'END\s+SYSTEM' + - 'BEGIN\s+USER' + - '<\|im_start\|>' + - '<\|im_end\|>' + - '\[INST\]' + - '<>' + - '<\|system\|>' + - '<\|user\|>' + - '<\|assistant\|>' + + role_play: + - 'pretend\s+you\s+are' + - 'act\s+as\s+if\s+you\s+have\s+no\s+restrictions' + - '\bjailbreak\b' + - '\bDAN\s+mode\b' + - 'developer\s+mode\s+override' + - 'bypass\s+(all\s+)?(safety|content)\s+(filters?|restrictions?)' + - 'you\s+have\s+no\s+(limitations?|restrictions?|rules?)' + + context_manipulation: + - 'the\s+above\s+instructions\s+are\s+wrong' + - 'actually\s+your\s+real\s+instructions\s+are' + - 'the\s+developer\s+told\s+me\s+to\s+tell\s+you' + - 'secret\s+password\s+is' + - 'your\s+true\s+purpose\s+is' + - 'the\s+real\s+system\s+prompt\s+is' + + multi_turn: + - 'as\s+I\s+mentioned\s+before.*you\s+agreed\s+to' + - 'you\s+already\s+said\s+yes' + - 'continuing\s+from\s+where\s+you\s+unlocked' + - 'you\s+previously\s+confirmed' + - 'we\s+already\s+established\s+that\s+you\s+can' + + encoding: + - '\\x[0-9a-fA-F]{2}(?:\\x[0-9a-fA-F]{2}){3,}' + - '\\u[0-9a-fA-F]{4}(?:\\u[0-9a-fA-F]{4}){3,}' + - '\brot13\b' + - '\bbase64\s*decode\b' + - '\bhex\s*decode\b' + + base64_pattern: '[A-Za-z0-9+/]{20,}={0,2}' + +suspicious_decoded_keywords: + - ignore + - override + - system + - password + - secret + - admin + - root + - exec + - eval + - "import os" + +sensitivity_thresholds: + strict: 0.3 + balanced: 0.5 + permissive: 0.7 + +sensitivity_min_threat: + strict: low + balanced: low + permissive: high diff --git a/examples/policies/sandbox-safety.yaml b/examples/policies/sandbox-safety.yaml new file mode 100644 index 00000000..895d0272 --- /dev/null +++ b/examples/policies/sandbox-safety.yaml @@ -0,0 +1,32 @@ +# Sandbox Safety — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: sandbox-safety +description: > + Sample sandbox safety configuration — defines which Python modules and + builtins are blocked inside the execution sandbox. + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +sandbox: + blocked_modules: + - subprocess # shell command execution + - os # filesystem and process operations + - shutil # high-level file operations (copy, move, delete) + - socket # raw network access + - ctypes # foreign function interface / memory access + - importlib # dynamic module loading (bypass vector) + + blocked_builtins: + - exec # execute arbitrary code strings + - eval # evaluate arbitrary expressions + - compile # compile code objects + - __import__ # dynamic import function diff --git a/examples/policies/semantic-policy.yaml b/examples/policies/semantic-policy.yaml new file mode 100644 index 00000000..10033db4 --- /dev/null +++ b/examples/policies/semantic-policy.yaml @@ -0,0 +1,176 @@ +# Semantic Policy — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE configuration provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. + +version: "1.0" +name: semantic-policy +description: > + Sample semantic policy configuration — defines weighted signal patterns + for intent classification (destructive data, exfiltration, privilege + escalation, etc.). + +disclaimer: > + This is a sample configuration. It is NOT exhaustive and should be + customized for your specific security requirements. + +signals: + destructive_data: + - pattern: '\bDROP\s+(TABLE|DATABASE|INDEX|VIEW|SCHEMA)\b' + weight: 0.9 + explanation: "SQL DROP statement" + - pattern: '\bTRUNCATE\s+TABLE\b' + weight: 0.9 + explanation: "SQL TRUNCATE" + - pattern: '\bDELETE\s+FROM\b.*\bWHERE\s+1\s*=\s*1\b' + weight: 0.95 + explanation: "DELETE all rows" + - pattern: '\bDELETE\s+FROM\b(?!.*\bWHERE\b)' + weight: 0.85 + explanation: "DELETE without WHERE" + - pattern: '\bDELETE\s+FROM\b' + weight: 0.4 + explanation: "DELETE with filter" + - pattern: '\b(wipe|purge|destroy|erase|nuke)\b' + weight: 0.7 + explanation: "destructive verb" + - pattern: '\bremove\s+(all|every|entire)\b' + weight: 0.75 + explanation: "remove-all pattern" + - pattern: '\bformat\s+(disk|drive|partition)\b' + weight: 0.9 + explanation: "disk format" + - pattern: '\bALTER\s+TABLE\b.*\bDROP\b' + weight: 0.8 + explanation: "ALTER TABLE DROP column" + + data_exfiltration: + - pattern: '\bSELECT\s+\*\s+FROM\b.*\bINTO\s+OUTFILE\b' + weight: 0.9 + explanation: "SQL dump to file" + - pattern: '\bCOPY\s+.*\bTO\s+STDOUT\b' + weight: 0.8 + explanation: "Postgres COPY to stdout" + - pattern: '\b(dump|export|backup)\s+(all|entire|full|complete)\b' + weight: 0.75 + explanation: "full data export" + - pattern: '\b(upload|send|transmit)\s+.*\b(external|remote|s3|bucket)\b' + weight: 0.8 + explanation: "external transfer" + - pattern: '\bpg_dump\b' + weight: 0.7 + explanation: "database dump tool" + - pattern: '\bmysqldump\b' + weight: 0.7 + explanation: "MySQL dump tool" + - pattern: '\b(wget|curl)\s+.*\|\s*' + weight: 0.6 + explanation: "piped download" + + privilege_escalation: + - pattern: '\bGRANT\s+(ALL|SUPERUSER|ADMIN)\b' + weight: 0.9 + explanation: "SQL GRANT elevated" + - pattern: '\bGRANT\b' + weight: 0.4 + explanation: "SQL GRANT" + - pattern: '\bsudo\b' + weight: 0.7 + explanation: "sudo invocation" + - pattern: '\bchmod\s+777\b' + weight: 0.8 + explanation: "world-writable permissions" + - pattern: '\bchmod\s+[0-7]*[67][0-7]{2}\b' + weight: 0.5 + explanation: "permissive chmod" + - pattern: '\b(escalat|elevat)\w*\s*(privilege|permission|access)\b' + weight: 0.8 + explanation: "escalation language" + - pattern: '\bALTER\s+USER\b.*\bSUPERUSER\b' + weight: 0.9 + explanation: "make superuser" + - pattern: '\bsu\s+-\b' + weight: 0.7 + explanation: "switch user root" + - pattern: '\bpasswd\b' + weight: 0.5 + explanation: "password change" + + system_modification: + - pattern: '\brm\s+-rf\b' + weight: 0.95 + explanation: "recursive force delete" + - pattern: '\brm\s+-r\b' + weight: 0.7 + explanation: "recursive delete" + - pattern: '\b(shutdown|reboot|halt|poweroff)\b' + weight: 0.8 + explanation: "system power" + - pattern: '\bkill\s+-9\b' + weight: 0.7 + explanation: "force kill process" + - pattern: '\bsystemctl\s+(stop|disable|mask)\b' + weight: 0.7 + explanation: "stop system service" + - pattern: '\biptables\s+.*\bDROP\b' + weight: 0.8 + explanation: "firewall drop rule" + - pattern: '\bregistry\s*(delete|modify)\b' + weight: 0.7 + explanation: "Windows registry modification" + - pattern: '\bformat\s+[A-Z]:\b' + weight: 0.9 + explanation: "format drive" + + code_execution: + - pattern: '\b(exec|eval)\s*\(' + weight: 0.8 + explanation: "dynamic code execution" + - pattern: '\bsubprocess\b' + weight: 0.5 + explanation: "subprocess call" + - pattern: '\bos\.system\b' + weight: 0.7 + explanation: "os.system call" + - pattern: '\b__import__\b' + weight: 0.7 + explanation: "dynamic import" + - pattern: '\bcompile\s*\(' + weight: 0.5 + explanation: "compile code" + - pattern: '\bpickle\.loads\b' + weight: 0.7 + explanation: "unsafe deserialization" + + network_access: + - pattern: '\b(fetch|requests\.get|requests\.post|urllib)\b' + weight: 0.4 + explanation: "HTTP request" + - pattern: '\b(curl|wget)\s+http' + weight: 0.5 + explanation: "command-line HTTP" + - pattern: '\bsocket\.connect\b' + weight: 0.6 + explanation: "raw socket connection" + - pattern: '\bsmtplib\b' + weight: 0.5 + explanation: "SMTP email" + + data_read: + - pattern: '\bSELECT\b(?!.*\bINTO\b)' + weight: 0.6 + explanation: "SQL SELECT" + - pattern: '\b(read|get|fetch|list|show|describe)\b' + weight: 0.3 + explanation: "read verb" + + data_write: + - pattern: '\b(INSERT|UPDATE)\b' + weight: 0.5 + explanation: "SQL write" + - pattern: '\b(write|create|put|post|append)\b' + weight: 0.3 + explanation: "write verb" diff --git a/examples/policies/sql-readonly.yaml b/examples/policies/sql-readonly.yaml new file mode 100644 index 00000000..03c72755 --- /dev/null +++ b/examples/policies/sql-readonly.yaml @@ -0,0 +1,59 @@ +# SQL Read-Only Policy +# +# ⚠️ IMPORTANT: This is a SAMPLE policy provided as a starting point. +# You MUST review and customize these rules for your specific use case. +# +# This policy allows ONLY SELECT queries. Everything else is blocked. +# Ideal for reporting agents, analytics dashboards, or read-only API agents. + +version: "1.0" +name: sql-readonly +description: > + Read-only SQL policy. Only SELECT queries are allowed. + All modifications, DDL, and administrative operations are blocked. + +disclaimer: > + This is a sample read-only policy. It blocks all known write operations + but may not cover all vendor-specific extensions. Review for your platform. + +sql_policy: + # Block everything except SELECT + blocked_statements: + - DROP + - TRUNCATE + - ALTER + - GRANT + - REVOKE + - MERGE + - INSERT + - UPDATE + - DELETE + + require_where_clause: [] + + blocked_create_types: + - USER + - ROLE + - LOGIN + - TABLE + - INDEX + - VIEW + - DATABASE + - SCHEMA + - PROCEDURE + - FUNCTION + - TRIGGER + + blocked_patterns: + - '\bEXEC(UTE)?\b' + - '\bXP_\w+' + - '\bSP_\w+' + - '\bLOAD_FILE\s*\(' + - '\bINTO\s+(OUT|DUMP)FILE\b' + - '\bLOAD\s+DATA\b' + - '\bCOPY\s+' + - '\bSET\s+(GLOBAL|ROLE)\b' + - '\bBULK\s+INSERT\b' + + allowed_statements: + - SELECT diff --git a/examples/policies/sql-safety.yaml b/examples/policies/sql-safety.yaml new file mode 100644 index 00000000..04fe828b --- /dev/null +++ b/examples/policies/sql-safety.yaml @@ -0,0 +1,92 @@ +# SQL Safety Policy — Sample Configuration +# +# ⚠️ IMPORTANT: This is a SAMPLE policy provided as a starting point. +# You MUST review, customize, and extend these rules for your specific +# use case before deploying to production. Microsoft does not guarantee +# that these rules are comprehensive or sufficient for your security +# requirements. +# +# This policy defines patterns that the no_destructive_sql rule uses +# to block dangerous SQL operations. Patterns are matched against +# incoming SQL queries. +# +# Usage: +# from agent_control_plane.policy_engine import create_sql_policy_from_config +# rules = create_sql_policy_from_config("path/to/sql-safety.yaml") +# +# See also: +# - sql-strict.yaml — Comprehensive deny-list for high-security environments +# - sql-readonly.yaml — Read-only policy that blocks all write operations + +version: "1.0" +name: sql-safety +description: > + Sample SQL safety policy — blocks common destructive and privilege-escalation + operations. Review and extend for your environment before production use. + +# Disclaimer shown in audit logs when this policy is loaded +disclaimer: > + This is a sample policy configuration. It is NOT exhaustive and should be + customized for your specific database platform and security requirements. + The authors accept no liability for SQL operations not covered by these rules. + +sql_policy: + # --------------------------------------------------------------- + # Blocked statement types (AST-level, when sqlglot is available) + # These are checked via SQL parsing for accurate detection. + # --------------------------------------------------------------- + blocked_statements: + - DROP # DROP TABLE, DATABASE, INDEX, VIEW, etc. + - TRUNCATE # TRUNCATE TABLE + - ALTER # ALTER TABLE, ALTER USER, etc. + - GRANT # Privilege escalation + - REVOKE # Privilege removal + - MERGE # Combined INSERT/UPDATE/DELETE + + # --------------------------------------------------------------- + # Blocked when missing a WHERE clause (prevents mass operations) + # --------------------------------------------------------------- + require_where_clause: + - DELETE + - UPDATE + + # --------------------------------------------------------------- + # Blocked CREATE subtypes (CREATE TABLE is allowed by default) + # --------------------------------------------------------------- + blocked_create_types: + - USER + - ROLE + - LOGIN + + # --------------------------------------------------------------- + # Regex patterns for operations that may not parse as standard SQL + # (e.g., vendor-specific stored procedures, file operations). + # Applied to the full query text after comment stripping. + # Patterns are case-insensitive. + # --------------------------------------------------------------- + blocked_patterns: + # SQL Server dangerous stored procedures + - '\bEXEC(UTE)?\s+XP_CMDSHELL\b' + - '\bEXEC(UTE)?\s+SP_CONFIGURE\b' + - '\bEXEC(UTE)?\s+SP_ADDROLEMEMBER\b' + - '\bEXEC(UTE)?\s+SP_ADDSRVROLEMEMBER\b' + # File operations + - '\bLOAD_FILE\s*\(' + - '\bINTO\s+(OUT|DUMP)FILE\b' + - '\bLOAD\s+DATA\b' + # PostgreSQL-specific + - '\bCOPY\s+.*\bTO\b' + - '\bCOPY\s+.*\bFROM\b.*\bPROGRAM\b' + + # --------------------------------------------------------------- + # Safe operations — always allowed regardless of other rules. + # These serve as documentation; the engine allows anything not blocked. + # --------------------------------------------------------------- + allowed_statements: + - SELECT + - INSERT # INSERT is allowed (add to blocked_statements to deny) + - "UPDATE + WHERE" # UPDATE with WHERE clause is allowed + - "DELETE + WHERE" # DELETE with WHERE clause is allowed + - CREATE TABLE + - CREATE INDEX + - CREATE VIEW diff --git a/examples/policies/sql-strict.yaml b/examples/policies/sql-strict.yaml new file mode 100644 index 00000000..9c5cd6f3 --- /dev/null +++ b/examples/policies/sql-strict.yaml @@ -0,0 +1,74 @@ +# SQL Strict Policy — Comprehensive Deny-List +# +# ⚠️ IMPORTANT: This is a SAMPLE policy provided as a starting point. +# You MUST review and customize these rules for your specific use case. +# +# This policy is designed for HIGH-SECURITY environments where only +# SELECT queries should be allowed. All write, DDL, DCL, and +# administrative operations are blocked. +# +# Use this as a base and selectively enable operations your agents need. + +version: "1.0" +name: sql-strict +description: > + Strict SQL policy for high-security environments. Blocks all write + operations, DDL, DCL, and administrative commands. Only SELECT is allowed. + +disclaimer: > + This is a sample strict policy. Review and adjust for your specific + database platform. Additional vendor-specific commands may need to be added. + +sql_policy: + blocked_statements: + - DROP + - TRUNCATE + - ALTER + - GRANT + - REVOKE + - MERGE + - INSERT # Block all inserts + - UPDATE # Block all updates (even with WHERE) + - DELETE # Block all deletes (even with WHERE) + + require_where_clause: [] # Not needed — UPDATE/DELETE fully blocked above + + blocked_create_types: + - USER + - ROLE + - LOGIN + - TABLE # Block DDL entirely + - INDEX + - VIEW + - DATABASE + - SCHEMA + - PROCEDURE + - FUNCTION + - TRIGGER + + blocked_patterns: + # SQL Server + - '\bEXEC(UTE)?\b' # Block ALL EXEC calls + - '\bXP_\w+' # Block all extended stored procedures + - '\bSP_\w+' # Block all system stored procedures + - '\bDBCC\b' # Block DBCC commands + - '\bBULK\s+INSERT\b' + - '\bOPENROWSET\b' + - '\bOPENDATASOURCE\b' + - '\bOPENQUERY\b' + # MySQL + - '\bLOAD_FILE\s*\(' + - '\bINTO\s+(OUT|DUMP)FILE\b' + - '\bLOAD\s+DATA\b' + - '\bSET\s+GLOBAL\b' + # PostgreSQL + - '\bCOPY\s+' + - '\bSET\s+ROLE\b' + - '\bRESET\s+ROLE\b' + - '\bCREATE\s+EXTENSION\b' + # General + - '\bSHUTDOWN\b' + - '\bKILL\b' + + allowed_statements: + - SELECT diff --git a/packages/agent-os/modules/control-plane/src/agent_control_plane/policy_engine.py b/packages/agent-os/modules/control-plane/src/agent_control_plane/policy_engine.py index cc44ae2a..bda7e98c 100644 --- a/packages/agent-os/modules/control-plane/src/agent_control_plane/policy_engine.py +++ b/packages/agent-os/modules/control-plane/src/agent_control_plane/policy_engine.py @@ -28,6 +28,7 @@ import uuid import os import re +import warnings logger = logging.getLogger(__name__) @@ -606,32 +607,188 @@ def get_quota_status(self, agent_id: str) -> Dict[str, Any]: } -def _fallback_sql_check(query: str) -> bool: +@dataclass +class SQLPolicyConfig: + """Configuration for SQL policy rules, loadable from YAML. + + Attributes: + blocked_statements: SQL statement types to block (e.g., DROP, GRANT). + require_where_clause: Statements blocked only when missing WHERE. + blocked_create_types: CREATE subtypes to block (e.g., USER, ROLE). + blocked_patterns: Regex patterns for vendor-specific blocking. + disclaimer: Disclaimer text shown in logs. + """ + blocked_statements: List[str] = field(default_factory=lambda: [ + "DROP", "TRUNCATE", "ALTER", "GRANT", "REVOKE", "MERGE", + ]) + require_where_clause: List[str] = field(default_factory=lambda: [ + "DELETE", "UPDATE", + ]) + blocked_create_types: List[str] = field(default_factory=lambda: [ + "USER", "ROLE", "LOGIN", + ]) + blocked_patterns: List[str] = field(default_factory=lambda: [ + r'\bEXEC(UTE)?\s+XP_CMDSHELL\b', + r'\bEXEC(UTE)?\s+SP_CONFIGURE\b', + r'\bEXEC(UTE)?\s+SP_ADDROLEMEMBER\b', + r'\bLOAD_FILE\s*\(', + r'\bINTO\s+(OUT|DUMP)FILE\b', + r'\bLOAD\s+DATA\b', + r'\bMERGE\s+INTO\b', + ]) + disclaimer: str = "" + + +def load_sql_policy_config(path: str) -> SQLPolicyConfig: + """Load SQL policy configuration from a YAML file. + + Args: + path: Path to a YAML file with ``sql_policy`` section. + + Returns: + SQLPolicyConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``sql_policy`` section. + + Example:: + + config = load_sql_policy_config("examples/policies/sql-safety.yaml") + rules = create_sql_policy_from_config(config) + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"SQL policy config not found: {path}") + + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f.read()) + + if not isinstance(data, dict) or "sql_policy" not in data: + raise ValueError( + f"YAML file must contain a 'sql_policy' section: {path}" + ) + + sp = data["sql_policy"] + return SQLPolicyConfig( + blocked_statements=[s.upper() for s in sp.get("blocked_statements", [])], + require_where_clause=[s.upper() for s in sp.get("require_where_clause", [])], + blocked_create_types=[s.upper() for s in sp.get("blocked_create_types", [])], + blocked_patterns=sp.get("blocked_patterns", []), + disclaimer=data.get("disclaimer", ""), + ) + + +def _fallback_sql_check(query: str, config: Optional[SQLPolicyConfig] = None) -> bool: """ Fallback SQL check when sqlglot is not available. - - Uses regex pattern matching - less secure but provides basic protection. + + Uses regex pattern matching. Rules are driven by *config*; when + *config* is ``None`` a built-in default set is used. """ + if config is None: + config = SQLPolicyConfig() + query_upper = query.upper() # Remove comments to prevent bypass query_clean = re.sub(r'/\*.*?\*/', '', query_upper, flags=re.DOTALL) query_clean = re.sub(r'--.*$', '', query_clean, flags=re.MULTILINE) - - # Check for destructive operations - destructive_patterns = [ - r'\bDROP\s+(TABLE|DATABASE|INDEX|VIEW|SCHEMA)\b', - r'\bTRUNCATE\s+TABLE\b', - r'\bDELETE\s+FROM\s+\w+\s*(;|$)', # DELETE without WHERE - r'\bALTER\s+TABLE\b', - ] - for pattern in destructive_patterns: + + # Build patterns dynamically from config + patterns: List[str] = [] + + for stmt in config.blocked_statements: + if stmt == "DROP": + patterns.append(r'\bDROP\s+(TABLE|DATABASE|INDEX|VIEW|SCHEMA|PROCEDURE|FUNCTION|TRIGGER)\b') + elif stmt == "TRUNCATE": + patterns.append(r'\bTRUNCATE\s+(TABLE\s+)?\w+') + elif stmt == "ALTER": + patterns.append(r'\bALTER\s+(TABLE|DATABASE|SCHEMA)\b') + elif stmt == "GRANT": + patterns.append(r'\bGRANT\b') + elif stmt == "REVOKE": + patterns.append(r'\bREVOKE\b') + elif stmt == "MERGE": + patterns.append(r'\bMERGE\s+INTO\b') + elif stmt == "INSERT": + patterns.append(r'\bINSERT\s+INTO\b') + elif stmt in ("UPDATE", "DELETE"): + patterns.append(rf'\b{stmt}\b') + + for stmt in config.require_where_clause: + if stmt == "DELETE": + patterns.append(r'\bDELETE\s+FROM\s+\w+\s*(;|$)') + elif stmt == "UPDATE": + patterns.append(r'\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)') + + for ct in config.blocked_create_types: + patterns.append(rf'\bCREATE\s+{ct}\b') + patterns.append(rf'\bALTER\s+{ct}\b') + patterns.append(rf'\bDROP\s+{ct}\b') + + patterns.extend(config.blocked_patterns) + + for pattern in patterns: if re.search(pattern, query_clean): return False return True +def create_policies_from_config( + sql_config_path: Optional[str] = None, + sql_config: Optional[SQLPolicyConfig] = None, +) -> List[PolicyRule]: + """Create security policies with SQL rules driven by external config. + + Load SQL policy rules from a YAML config file or a pre-built + ``SQLPolicyConfig`` object. Non-SQL policies (file access, credential + exposure) use built-in defaults. + + Args: + sql_config_path: Path to a YAML file with ``sql_policy`` section. + sql_config: Pre-built config object (takes precedence over path). + + Returns: + List of PolicyRule instances. + + Example:: + + # From YAML file + rules = create_policies_from_config("examples/policies/sql-safety.yaml") + + # From explicit config + cfg = SQLPolicyConfig(blocked_statements=["DROP", "GRANT"]) + rules = create_policies_from_config(sql_config=cfg) + """ + if sql_config is None and sql_config_path is not None: + sql_config = load_sql_policy_config(sql_config_path) + if sql_config is None: + sql_config = SQLPolicyConfig() + + return _build_policy_rules(sql_config) + + def create_default_policies() -> List[PolicyRule]: - """Create a set of default security policies""" + """Create a set of default security policies. + + .. deprecated:: + The built-in rules are **samples** and are not guaranteed to be + exhaustive. Use :func:`create_policies_from_config` with an + explicit YAML config file for production deployments. + See ``examples/policies/`` for sample configurations. + """ + warnings.warn( + "create_default_policies() uses built-in sample rules that may not " + "cover all destructive SQL operations. For production use, load an " + "explicit policy config with create_policies_from_config(). " + "See examples/policies/sql-safety.yaml for a sample configuration.", + stacklevel=2, + ) + return _build_policy_rules(SQLPolicyConfig()) + + +def _build_policy_rules(sql_config: SQLPolicyConfig) -> List[PolicyRule]: def no_system_file_access(request: ExecutionRequest) -> bool: """Prevent access to system files""" @@ -653,10 +810,16 @@ def no_destructive_sql(request: ExecutionRequest) -> bool: Prevent destructive SQL operations using AST-level parsing. Uses sqlglot for proper SQL parsing to detect: - - DROP TABLE/DATABASE/INDEX/VIEW statements + - DROP TABLE/DATABASE/INDEX/VIEW/USER/ROLE statements - TRUNCATE statements - DELETE without WHERE clause - - ALTER TABLE statements + - UPDATE without WHERE clause + - ALTER TABLE/USER/ROLE statements + - GRANT / REVOKE privilege statements + - CREATE USER/ROLE/LOGIN statements + - EXEC/EXECUTE xp_cmdshell and other dangerous procedures + - MERGE INTO statements + - Dangerous file functions (LOAD_FILE, INTO OUTFILE) This prevents bypass attempts like: - Keywords in comments: /* DROP */ SELECT ... @@ -665,57 +828,96 @@ def no_destructive_sql(request: ExecutionRequest) -> bool: """ if request.action_type not in (ActionType.DATABASE_QUERY, ActionType.DATABASE_WRITE): return True - + query = request.parameters.get("query", "") if not query.strip(): return True - + try: # Try to import sqlglot for AST-level parsing import sqlglot from sqlglot import exp - + # Parse the SQL query into AST try: statements = sqlglot.parse(query) except sqlglot.errors.ParseError: # If parsing fails, fall back to conservative blocking - # Log the error but err on the side of caution - return _fallback_sql_check(query) - + return _fallback_sql_check(query, sql_config) + for statement in statements: if statement is None: continue - - # Check for DROP statements + + # Check for DROP statements (tables, databases, users, roles, etc.) if isinstance(statement, exp.Drop): return False - + # Check for TRUNCATE statements if isinstance(statement, exp.Command) and statement.this.upper() == "TRUNCATE": return False - + # Check for DELETE without WHERE clause if isinstance(statement, exp.Delete): - # DELETE is only allowed with a WHERE clause if statement.find(exp.Where) is None: return False - + + # Check for UPDATE without WHERE clause + if isinstance(statement, exp.Update): + if statement.find(exp.Where) is None: + return False + # Check for ALTER statements if isinstance(statement, exp.AlterTable): return False - + + # Check for GRANT / REVOKE statements + if isinstance(statement, exp.Grant): + return False + + # Check for MERGE statements (can do INSERT/UPDATE/DELETE) + if isinstance(statement, exp.Merge): + return False + + # Check for CREATE USER/ROLE and ALTER USER/ROLE + if isinstance(statement, exp.Create): + kind = statement.args.get("kind", "") + if isinstance(kind, str) and kind.upper() in ("USER", "ROLE", "LOGIN"): + return False + + # Catch GRANT, REVOKE, EXEC, CREATE USER via Command nodes + # (sqlglot may parse some vendor-specific SQL as Command) + if isinstance(statement, exp.Command): + cmd = statement.this.upper() if statement.this else "" + if cmd in ("GRANT", "REVOKE", "EXEC", "EXECUTE", "MERGE"): + return False + # Block CREATE USER/ROLE/LOGIN parsed as Command + if cmd == "CREATE": + expr_text = statement.sql().upper() + if any(kw in expr_text for kw in ("USER", "ROLE", "LOGIN")): + return False + # Check for dangerous functions in any statement for func in statement.find_all(exp.Func): func_name = func.name.upper() if func.name else "" if func_name in ("LOAD_FILE", "INTO OUTFILE", "INTO DUMPFILE"): return False - + + # Check for EXEC xp_cmdshell and other dangerous procs + # in the full SQL text of the statement + stmt_sql = statement.sql().upper() + if re.search(r'\bEXEC(UTE)?\s+XP_CMDSHELL\b', stmt_sql): + return False + if re.search(r'\bEXEC(UTE)?\s+SP_CONFIGURE\b', stmt_sql): + return False + if re.search(r'\bEXEC(UTE)?\s+SP_ADDROLEMEMBER\b', stmt_sql): + return False + return True - + except ImportError: # sqlglot not installed, fall back to keyword matching - return _fallback_sql_check(query) + return _fallback_sql_check(query, sql_config) return [ PolicyRule( diff --git a/packages/agent-os/modules/control-plane/tests/test_sql_policy.py b/packages/agent-os/modules/control-plane/tests/test_sql_policy.py index c6e32f01..13e4d9e1 100644 --- a/packages/agent-os/modules/control-plane/tests/test_sql_policy.py +++ b/packages/agent-os/modules/control-plane/tests/test_sql_policy.py @@ -11,6 +11,8 @@ from agent_control_plane.policy_engine import create_default_policies from agent_control_plane.agent_kernel import ExecutionRequest, ActionType +import warnings + # Check if sqlglot is available try: @@ -22,7 +24,7 @@ class TestSQLPolicy: """Test the SQL policy enforcement.""" - + @pytest.fixture def sql_policy(self): """Get the no_destructive_sql policy.""" @@ -31,97 +33,159 @@ def sql_policy(self): if policy.name == "no_destructive_sql": return policy pytest.fail("no_destructive_sql policy not found") - + def make_sql_request(self, query: str) -> ExecutionRequest: """Helper to create an ExecutionRequest for a SQL query.""" + from datetime import datetime + from agent_control_plane.agent_kernel import ( + AgentContext, PermissionLevel + ) + ctx = AgentContext( + agent_id="test-agent", + session_id="test-session", + created_at=datetime.now(), + permissions={ + ActionType.DATABASE_QUERY: PermissionLevel.READ_WRITE, + ActionType.DATABASE_WRITE: PermissionLevel.READ_WRITE, + }, + ) return ExecutionRequest( request_id="test-001", + agent_context=ctx, action_type=ActionType.DATABASE_QUERY, - tool_name="sql_execute", parameters={"query": query}, + timestamp=datetime.now(), ) - + # ============================================= # SAFE QUERIES - Should PASS # ============================================= - + def test_simple_select_allowed(self, sql_policy): """Simple SELECT queries should be allowed.""" request = self.make_sql_request("SELECT * FROM users") assert sql_policy.validator(request) is True - + def test_select_with_where_allowed(self, sql_policy): """SELECT with WHERE clause should be allowed.""" request = self.make_sql_request("SELECT id, name FROM users WHERE active = 1") assert sql_policy.validator(request) is True - + def test_insert_allowed(self, sql_policy): """INSERT statements should be allowed.""" request = self.make_sql_request("INSERT INTO users (name, email) VALUES ('John', 'john@example.com')") assert sql_policy.validator(request) is True - + def test_update_with_where_allowed(self, sql_policy): """UPDATE with WHERE clause should be allowed.""" request = self.make_sql_request("UPDATE users SET active = 0 WHERE id = 5") assert sql_policy.validator(request) is True - + def test_delete_with_where_allowed(self, sql_policy): """DELETE with WHERE clause should be allowed.""" request = self.make_sql_request("DELETE FROM users WHERE id = 5") assert sql_policy.validator(request) is True - + def test_create_table_allowed(self, sql_policy): """CREATE TABLE should be allowed.""" request = self.make_sql_request("CREATE TABLE logs (id INT, message TEXT)") assert sql_policy.validator(request) is True - + # ============================================= # DANGEROUS QUERIES - Should BLOCK # ============================================= - + def test_drop_table_blocked(self, sql_policy): """DROP TABLE should be blocked.""" request = self.make_sql_request("DROP TABLE users") assert sql_policy.validator(request) is False - + def test_drop_database_blocked(self, sql_policy): """DROP DATABASE should be blocked.""" request = self.make_sql_request("DROP DATABASE production") assert sql_policy.validator(request) is False - + def test_truncate_blocked(self, sql_policy): """TRUNCATE should be blocked.""" request = self.make_sql_request("TRUNCATE TABLE users") assert sql_policy.validator(request) is False - + def test_delete_without_where_blocked(self, sql_policy): """DELETE without WHERE should be blocked.""" request = self.make_sql_request("DELETE FROM users") assert sql_policy.validator(request) is False - + def test_alter_table_blocked(self, sql_policy): """ALTER TABLE should be blocked.""" request = self.make_sql_request("ALTER TABLE users ADD COLUMN admin BOOLEAN") assert sql_policy.validator(request) is False - + + def test_grant_blocked(self, sql_policy): + """GRANT should be blocked.""" + request = self.make_sql_request("GRANT ALL PRIVILEGES ON *.* TO 'attacker'@'%'") + assert sql_policy.validator(request) is False + + def test_grant_select_blocked(self, sql_policy): + """GRANT SELECT should be blocked.""" + request = self.make_sql_request("GRANT SELECT ON users TO readonly_user") + assert sql_policy.validator(request) is False + + def test_revoke_blocked(self, sql_policy): + """REVOKE should be blocked.""" + request = self.make_sql_request("REVOKE ALL PRIVILEGES ON *.* FROM 'user'@'%'") + assert sql_policy.validator(request) is False + + def test_create_user_blocked(self, sql_policy): + """CREATE USER should be blocked.""" + request = self.make_sql_request("CREATE USER 'backdoor'@'%' IDENTIFIED BY 'password123'") + assert sql_policy.validator(request) is False + + def test_create_role_blocked(self, sql_policy): + """CREATE ROLE should be blocked.""" + request = self.make_sql_request("CREATE ROLE admin_role") + assert sql_policy.validator(request) is False + + def test_update_without_where_blocked(self, sql_policy): + """UPDATE without WHERE should be blocked.""" + request = self.make_sql_request("UPDATE users SET role='admin'") + assert sql_policy.validator(request) is False + + def test_exec_xp_cmdshell_blocked(self, sql_policy): + """EXEC xp_cmdshell should be blocked.""" + request = self.make_sql_request("EXEC xp_cmdshell 'whoami'") + assert sql_policy.validator(request) is False + + def test_execute_xp_cmdshell_blocked(self, sql_policy): + """EXECUTE xp_cmdshell should be blocked.""" + request = self.make_sql_request("EXECUTE xp_cmdshell 'net user'") + assert sql_policy.validator(request) is False + + def test_merge_into_blocked(self, sql_policy): + """MERGE INTO should be blocked.""" + request = self.make_sql_request( + "MERGE INTO target USING source ON target.id = source.id " + "WHEN MATCHED THEN UPDATE SET target.val = source.val" + ) + assert sql_policy.validator(request) is False + # ============================================= # BYPASS ATTEMPTS - Should still BLOCK # ============================================= - + @pytest.mark.skipif(not SQLGLOT_AVAILABLE, reason="Requires sqlglot for AST parsing") def test_drop_in_comment_allowed(self, sql_policy): """DROP keyword in comment should NOT trigger block.""" request = self.make_sql_request("SELECT * FROM users /* DROP TABLE test */") # With AST parsing, this should be allowed (comment is ignored) assert sql_policy.validator(request) is True - + @pytest.mark.skipif(not SQLGLOT_AVAILABLE, reason="Requires sqlglot for AST parsing") def test_drop_in_string_allowed(self, sql_policy): """DROP keyword in string literal should NOT trigger block.""" request = self.make_sql_request("SELECT 'DROP TABLE users' as example FROM data") # With AST parsing, this should be allowed (it's just a string) assert sql_policy.validator(request) is True - + def test_case_variations_blocked(self, sql_policy): """Case variations of dangerous keywords should be blocked.""" requests = [ @@ -131,31 +195,40 @@ def test_case_variations_blocked(self, sql_policy): ] for request in requests: assert sql_policy.validator(request) is False, f"Should block: {request.parameters['query']}" - + # ============================================= # EDGE CASES # ============================================= - + def test_empty_query_allowed(self, sql_policy): """Empty query should be allowed (no harm).""" request = self.make_sql_request("") assert sql_policy.validator(request) is True - + def test_whitespace_only_allowed(self, sql_policy): """Whitespace-only query should be allowed.""" request = self.make_sql_request(" \n\t ") assert sql_policy.validator(request) is True - + def test_non_sql_action_allowed(self, sql_policy): """Non-SQL action types should pass through.""" + from datetime import datetime + from agent_control_plane.agent_kernel import AgentContext, PermissionLevel + ctx = AgentContext( + agent_id="test-agent", + session_id="test-session", + created_at=datetime.now(), + permissions={ActionType.FILE_READ: PermissionLevel.READ_ONLY}, + ) request = ExecutionRequest( request_id="test-001", - action_type=ActionType.FILE_READ, # Not SQL - tool_name="read_file", + agent_context=ctx, + action_type=ActionType.FILE_READ, parameters={"path": "/tmp/test.txt"}, + timestamp=datetime.now(), ) assert sql_policy.validator(request) is True - + def test_multiple_statements_checked(self, sql_policy): """Multiple statements should all be checked.""" # First safe, second dangerous @@ -165,10 +238,125 @@ def test_multiple_statements_checked(self, sql_policy): class TestSQLPolicyFallback: """Test the fallback SQL check when sqlglot is not available.""" - + def test_fallback_blocks_drop(self): - """Fallback should still block DROP.""" + """Fallback should block DROP.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("DROP TABLE users") is False + + def test_fallback_blocks_grant(self): + """Fallback should block GRANT.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("GRANT ALL PRIVILEGES ON *.* TO 'attacker'@'%'") is False + + def test_fallback_blocks_create_user(self): + """Fallback should block CREATE USER.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("CREATE USER 'backdoor'@'%' IDENTIFIED BY 'pass'") is False + + def test_fallback_blocks_exec_xp_cmdshell(self): + """Fallback should block EXEC xp_cmdshell.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("EXEC xp_cmdshell 'whoami'") is False + + def test_fallback_blocks_update_without_where(self): + """Fallback should block UPDATE without WHERE.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("UPDATE users SET role='admin'") is False + + def test_fallback_blocks_revoke(self): + """Fallback should block REVOKE.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("REVOKE ALL PRIVILEGES ON *.* FROM 'user'@'%'") is False + + def test_fallback_blocks_merge(self): + """Fallback should block MERGE INTO.""" from agent_control_plane.policy_engine import _fallback_sql_check - # Note: _fallback_sql_check is defined inside create_default_policies, - # so we need to test it indirectly or extract it - pass # This test validates that fallback exists conceptually + assert _fallback_sql_check("MERGE INTO target USING source ON target.id = source.id") is False + + def test_fallback_allows_select(self): + """Fallback should allow SELECT.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("SELECT * FROM users WHERE id = 1") is True + + def test_fallback_allows_insert(self): + """Fallback should allow INSERT.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("INSERT INTO logs (msg) VALUES ('hello')") is True + + def test_fallback_allows_update_with_where(self): + """Fallback should allow UPDATE with WHERE.""" + from agent_control_plane.policy_engine import _fallback_sql_check + assert _fallback_sql_check("UPDATE users SET active=0 WHERE id=5") is True + + +class TestSQLPolicyConfig: + """Test config-based SQL policy loading.""" + + def test_create_policies_from_config_with_yaml(self, tmp_path): + """Loading from YAML config should produce working policies.""" + from agent_control_plane.policy_engine import ( + create_policies_from_config, SQLPolicyConfig, + ) + cfg_file = tmp_path / "test-policy.yaml" + cfg_file.write_text( + "version: '1.0'\n" + "name: test\n" + "sql_policy:\n" + " blocked_statements:\n" + " - DROP\n" + " - GRANT\n" + " require_where_clause:\n" + " - DELETE\n" + " blocked_create_types:\n" + " - USER\n" + " blocked_patterns:\n" + " - '\\bEXEC\\b'\n", + encoding="utf-8", + ) + rules = create_policies_from_config(str(cfg_file)) + sql_rule = next(r for r in rules if r.name == "no_destructive_sql") + assert sql_rule is not None + + def test_create_policies_from_explicit_config(self): + """Passing SQLPolicyConfig directly should work.""" + from agent_control_plane.policy_engine import ( + create_policies_from_config, SQLPolicyConfig, + ) + cfg = SQLPolicyConfig( + blocked_statements=["DROP"], + require_where_clause=[], + blocked_create_types=[], + blocked_patterns=[], + ) + rules = create_policies_from_config(sql_config=cfg) + assert len(rules) == 3 + + def test_default_policies_emits_deprecation_warning(self): + """create_default_policies should emit a deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + create_default_policies() + assert len(w) >= 1 + assert "sample rules" in str(w[0].message) + + def test_config_missing_file_raises(self): + """Loading a non-existent config should raise FileNotFoundError.""" + from agent_control_plane.policy_engine import load_sql_policy_config + with pytest.raises(FileNotFoundError): + load_sql_policy_config("/nonexistent/path.yaml") + + def test_fallback_uses_config(self): + """Fallback regex check should respect config.""" + from agent_control_plane.policy_engine import _fallback_sql_check, SQLPolicyConfig + + # Config that blocks INSERT (not blocked by default) + cfg = SQLPolicyConfig( + blocked_statements=["INSERT"], + require_where_clause=[], + blocked_create_types=[], + blocked_patterns=[], + ) + assert _fallback_sql_check("INSERT INTO users VALUES (1)", cfg) is False + # SELECT should still pass + assert _fallback_sql_check("SELECT * FROM users", cfg) is True diff --git a/packages/agent-os/pyproject.toml b/packages/agent-os/pyproject.toml index ae243d2b..203cdb76 100644 --- a/packages/agent-os/pyproject.toml +++ b/packages/agent-os/pyproject.toml @@ -115,6 +115,7 @@ dev = [ "aiohttp>=3.13.3", "structlog>=24.1.0", "redis>=4.0.0", + "cryptography>=44.0.0", "eval_type_backport>=0.2.0; python_version < '3.10'", ] diff --git a/packages/agent-os/src/agent_os/cli/__init__.py b/packages/agent-os/src/agent_os/cli/__init__.py index 484f6b88..5df05d5e 100644 --- a/packages/agent-os/src/agent_os/cli/__init__.py +++ b/packages/agent-os/src/agent_os/cli/__init__.py @@ -33,6 +33,7 @@ import subprocess import sys import time +import warnings from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Any @@ -51,6 +52,12 @@ VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR") VALID_BACKENDS = ("memory", "redis") +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE CLI security rules provided as a starting point. " + "You MUST review, customise, and extend them for your specific use case " + "before deploying to production." +) + def get_env_config() -> dict[str, str | None]: """Read configuration from environment variables.""" @@ -227,14 +234,56 @@ def __init__(self, line: int, code: str, violation: str, policy: str, self.suggestion = suggestion +def load_cli_policy_rules(path: str) -> list[dict[str, Any]]: + """Load CLI policy checker rules from a YAML file. + + Args: + path: Path to a YAML file with a ``rules`` section. + + Returns: + List of rule dicts suitable for ``PolicyChecker``. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``rules`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"CLI policy rules config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "rules" not in data: + raise ValueError(f"YAML file must contain a 'rules' section: {path}") + + return data["rules"] + + class PolicyChecker: """Local-first code policy checker.""" - def __init__(self) -> None: - self.rules = self._load_default_rules() + def __init__(self, rules: list[dict[str, Any]] | None = None) -> None: + if rules is not None: + self.rules = rules + else: + self.rules = self._load_default_rules() def _load_default_rules(self) -> list[dict[str, Any]]: - """Load default safety rules.""" + """Load default safety rules. + + .. deprecated:: + Uses built-in sample rules. For production use, load an explicit + config with ``load_cli_policy_rules()``. + """ + warnings.warn( + "PolicyChecker._load_default_rules() uses built-in sample rules that may not " + "cover all security violations. For production use, load an " + "explicit config with load_cli_policy_rules(). " + "See examples/policies/cli-security-rules.yaml for a sample configuration.", + stacklevel=2, + ) return [ # Destructive SQL { diff --git a/packages/agent-os/src/agent_os/integrations/conversation_guardian.py b/packages/agent-os/src/agent_os/integrations/conversation_guardian.py index b4366b6e..d463583d 100644 --- a/packages/agent-os/src/agent_os/integrations/conversation_guardian.py +++ b/packages/agent-os/src/agent_os/integrations/conversation_guardian.py @@ -38,10 +38,12 @@ from __future__ import annotations import logging +import os import re import threading import time import unicodedata +import warnings from collections import defaultdict from dataclasses import dataclass, field from enum import Enum @@ -49,6 +51,12 @@ logger = logging.getLogger(__name__) +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE conversation guardian rules provided as a " + "starting point. You MUST review, customise, and extend them for your " + "specific use case before deploying to production." +) + # ── Text Normalization (Evasion Resistance) ────────────────────────── @@ -152,6 +160,47 @@ class ConversationGuardianConfig: max_transcript_entries: int = 10_000 +def load_conversation_guardian_config(path: str) -> ConversationGuardianConfig: + """Load conversation guardian configuration from a YAML file. + + Args: + path: Path to a YAML file with a ``thresholds`` section. + + Returns: + ConversationGuardianConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``thresholds`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"Conversation guardian config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "thresholds" not in data: + raise ValueError(f"YAML file must contain a 'thresholds' section: {path}") + + t = data["thresholds"] + return ConversationGuardianConfig( + escalation_score_threshold=float(t.get("escalation_score_threshold", 0.6)), + escalation_critical_threshold=float(t.get("escalation_critical_threshold", 0.85)), + max_retry_cycles=int(t.get("max_retry_cycles", 3)), + max_conversation_turns=int(t.get("max_conversation_turns", 30)), + loop_window_seconds=float(t.get("loop_window_seconds", 300.0)), + offensive_score_threshold=float(t.get("offensive_score_threshold", 0.5)), + offensive_critical_threshold=float(t.get("offensive_critical_threshold", 0.8)), + composite_warn_threshold=float(t.get("composite_warn_threshold", 0.4)), + composite_pause_threshold=float(t.get("composite_pause_threshold", 0.6)), + composite_break_threshold=float(t.get("composite_break_threshold", 0.8)), + capture_transcript=bool(t.get("capture_transcript", True)), + max_transcript_entries=int(t.get("max_transcript_entries", 10_000)), + ) + + # ── Transcript Audit ───────────────────────────────────────────────── @@ -633,6 +682,14 @@ def __init__( self, config: ConversationGuardianConfig | None = None, ) -> None: + if config is None: + warnings.warn( + "ConversationGuardian() uses built-in sample rules that may not " + "cover all adversarial conversation patterns. For production use, load an " + "explicit config with load_conversation_guardian_config(). " + "See examples/policies/conversation-guardian.yaml for a sample configuration.", + stacklevel=2, + ) self._config = config or ConversationGuardianConfig() self._lock = threading.Lock() @@ -895,5 +952,6 @@ def reset(self, conversation_id: str | None = None) -> None: "FeedbackLoopBreaker", "OffensiveIntentDetector", "TranscriptEntry", + "load_conversation_guardian_config", "normalize_text", ] diff --git a/packages/agent-os/src/agent_os/mcp_security.py b/packages/agent-os/src/agent_os/mcp_security.py index 58cdfda1..d7c65f27 100644 --- a/packages/agent-os/src/agent_os/mcp_security.py +++ b/packages/agent-os/src/agent_os/mcp_security.py @@ -36,8 +36,10 @@ import hashlib import json import logging +import os import re import time +import warnings from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum @@ -47,6 +49,12 @@ logger = logging.getLogger(__name__) +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE MCP security rules provided as a starting point. " + "You MUST review, customise, and extend them for your specific use case " + "before deploying to production." +) + # --------------------------------------------------------------------------- # Data models @@ -185,6 +193,78 @@ class ScanResult: ] +# --------------------------------------------------------------------------- +# Externalised configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class MCPSecurityConfig: + """Structured configuration for MCP security scanning, loadable from YAML. + + Attributes: + invisible_unicode_patterns: Regex strings for invisible unicode detection. + hidden_comment_patterns: Regex strings for hidden comments. + hidden_instruction_patterns: Regex strings for instruction-like text. + encoded_payload_patterns: Regex strings for encoded payloads. + exfiltration_patterns: Regex strings for data exfiltration. + privilege_escalation_patterns: Regex strings for privilege escalation. + role_override_patterns: Regex strings for role overrides. + excessive_whitespace_pattern: Regex string for excessive whitespace. + suspicious_decoded_keywords: Keywords to check in decoded payloads. + disclaimer: Disclaimer text shown in logs. + """ + + invisible_unicode_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _INVISIBLE_UNICODE_PATTERNS]) + hidden_comment_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _HIDDEN_COMMENT_PATTERNS]) + hidden_instruction_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _HIDDEN_INSTRUCTION_PATTERNS]) + encoded_payload_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _ENCODED_PAYLOAD_PATTERNS]) + exfiltration_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _EXFILTRATION_PATTERNS]) + privilege_escalation_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _PRIVILEGE_ESCALATION_PATTERNS]) + role_override_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _ROLE_OVERRIDE_PATTERNS]) + excessive_whitespace_pattern: str = field(default_factory=lambda: _EXCESSIVE_WHITESPACE_PATTERN.pattern) + suspicious_decoded_keywords: list[str] = field(default_factory=lambda: list(_SUSPICIOUS_DECODED_KEYWORDS)) + disclaimer: str = "" + + +def load_mcp_security_config(path: str) -> MCPSecurityConfig: + """Load MCP security configuration from a YAML file. + + Args: + path: Path to a YAML file with a ``detection_patterns`` section. + + Returns: + MCPSecurityConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``detection_patterns`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"MCP security config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "detection_patterns" not in data: + raise ValueError(f"YAML file must contain a 'detection_patterns' section: {path}") + + dp = data["detection_patterns"] + return MCPSecurityConfig( + invisible_unicode_patterns=dp.get("invisible_unicode", [p.pattern for p in _INVISIBLE_UNICODE_PATTERNS]), + hidden_comment_patterns=dp.get("hidden_comments", [p.pattern for p in _HIDDEN_COMMENT_PATTERNS]), + hidden_instruction_patterns=dp.get("hidden_instructions", [p.pattern for p in _HIDDEN_INSTRUCTION_PATTERNS]), + encoded_payload_patterns=dp.get("encoded_payloads", [p.pattern for p in _ENCODED_PAYLOAD_PATTERNS]), + exfiltration_patterns=dp.get("exfiltration", [p.pattern for p in _EXFILTRATION_PATTERNS]), + privilege_escalation_patterns=dp.get("privilege_escalation", [p.pattern for p in _PRIVILEGE_ESCALATION_PATTERNS]), + role_override_patterns=dp.get("role_override", [p.pattern for p in _ROLE_OVERRIDE_PATTERNS]), + excessive_whitespace_pattern=dp.get("excessive_whitespace", _EXCESSIVE_WHITESPACE_PATTERN.pattern), + suspicious_decoded_keywords=data.get("suspicious_decoded_keywords", list(_SUSPICIOUS_DECODED_KEYWORDS)), + disclaimer=data.get("disclaimer", ""), + ) + + # --------------------------------------------------------------------------- # MCPSecurityScanner # --------------------------------------------------------------------------- @@ -204,6 +284,13 @@ class MCPSecurityScanner: """ def __init__(self) -> None: + warnings.warn( + "MCPSecurityScanner() uses built-in sample rules that may not " + "cover all MCP tool poisoning techniques. For production use, load an " + "explicit config with load_mcp_security_config(). " + "See examples/policies/mcp-security.yaml for a sample configuration.", + stacklevel=2, + ) self._tool_registry: dict[str, ToolFingerprint] = {} self._audit_log: list[dict[str, Any]] = [] self._injection_detector = PromptInjectionDetector() diff --git a/packages/agent-os/src/agent_os/mute_agent.py b/packages/agent-os/src/agent_os/mute_agent.py index 06d2d782..30fb1326 100644 --- a/packages/agent-os/src/agent_os/mute_agent.py +++ b/packages/agent-os/src/agent_os/mute_agent.py @@ -21,12 +21,20 @@ from __future__ import annotations import logging +import os import re +import warnings from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE PII detection patterns provided as a starting " + "point. You MUST review, customise, and extend them for your specific use " + "case before deploying to production." +) + # --------------------------------------------------------------------------- # Built-in PII / sensitive-data patterns @@ -50,6 +58,53 @@ } +# --------------------------------------------------------------------------- +# Externalised configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class PIIDetectionConfig: + """Structured configuration for PII detection patterns, loadable from YAML. + + Attributes: + builtin_patterns: Mapping of pattern name to regex string. + disclaimer: Disclaimer text shown in logs. + """ + + builtin_patterns: dict[str, str] = field(default_factory=lambda: dict(BUILTIN_PATTERNS)) + disclaimer: str = "" + + +def load_pii_config(path: str) -> PIIDetectionConfig: + """Load PII detection configuration from a YAML file. + + Args: + path: Path to a YAML file with a ``builtin_patterns`` section. + + Returns: + PIIDetectionConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``builtin_patterns`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"PII detection config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "builtin_patterns" not in data: + raise ValueError(f"YAML file must contain a 'builtin_patterns' section: {path}") + + return PIIDetectionConfig( + builtin_patterns=data["builtin_patterns"], + disclaimer=data.get("disclaimer", ""), + ) + + # --------------------------------------------------------------------------- # Data models # --------------------------------------------------------------------------- @@ -83,6 +138,14 @@ class MuteAgent: """ def __init__(self, policy: MutePolicy | None = None) -> None: + if policy is None: + warnings.warn( + "MuteAgent() uses built-in sample rules that may not " + "cover all PII patterns. For production use, load an " + "explicit config with load_pii_config(). " + "See examples/policies/pii-detection.yaml for a sample configuration.", + stacklevel=2, + ) self.policy = policy or MutePolicy() self._custom_compiled: list[re.Pattern[str]] = [ re.compile(p, re.IGNORECASE) for p in self.policy.custom_patterns diff --git a/packages/agent-os/src/agent_os/prompt_injection.py b/packages/agent-os/src/agent_os/prompt_injection.py index b2204278..3fdb35d8 100644 --- a/packages/agent-os/src/agent_os/prompt_injection.py +++ b/packages/agent-os/src/agent_os/prompt_injection.py @@ -36,7 +36,9 @@ import base64 import hashlib import logging +import os import re +import warnings from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime, timezone @@ -44,6 +46,12 @@ logger = logging.getLogger(__name__) +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE prompt-injection detection rules provided as a " + "starting point. You MUST review, customise, and extend them for your " + "specific use case before deploying to production." +) + # --------------------------------------------------------------------------- # Data models @@ -225,6 +233,81 @@ class AuditRecord: } +# --------------------------------------------------------------------------- +# Externalised configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class PromptInjectionConfig: + """Structured configuration for prompt injection detection, loadable from YAML. + + Attributes: + direct_override_patterns: Regex strings for direct override detection. + delimiter_patterns: Regex strings for delimiter attacks. + role_play_patterns: Regex strings for role-play / jailbreak. + context_manipulation_patterns: Regex strings for context manipulation. + multi_turn_patterns: Regex strings for multi-turn escalation. + encoding_patterns: Regex strings for encoding attacks. + base64_pattern: Regex string for base64 detection. + suspicious_decoded_keywords: Keywords to look for in decoded payloads. + sensitivity_thresholds: Confidence thresholds per sensitivity level. + sensitivity_min_threat: Minimum threat levels per sensitivity level. + disclaimer: Disclaimer text shown in logs. + """ + + direct_override_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _DIRECT_OVERRIDE_PATTERNS]) + delimiter_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _DELIMITER_PATTERNS]) + role_play_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _ROLE_PLAY_PATTERNS]) + context_manipulation_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _CONTEXT_MANIPULATION_PATTERNS]) + multi_turn_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _MULTI_TURN_PATTERNS]) + encoding_patterns: list[str] = field(default_factory=lambda: [p.pattern for p in _ENCODING_PATTERNS]) + base64_pattern: str = field(default_factory=lambda: _BASE64_PATTERN.pattern) + suspicious_decoded_keywords: list[str] = field(default_factory=lambda: list(_SUSPICIOUS_DECODED_KEYWORDS)) + sensitivity_thresholds: dict[str, float] = field(default_factory=lambda: dict(_SENSITIVITY_THRESHOLDS)) + sensitivity_min_threat: dict[str, str] = field(default_factory=lambda: {k: v.value for k, v in _SENSITIVITY_MIN_THREAT.items()}) + disclaimer: str = "" + + +def load_prompt_injection_config(path: str) -> PromptInjectionConfig: + """Load prompt injection detection configuration from a YAML file. + + Args: + path: Path to a YAML file with ``detection_patterns`` section. + + Returns: + PromptInjectionConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing required sections. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"Prompt injection config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "detection_patterns" not in data: + raise ValueError(f"YAML file must contain a 'detection_patterns' section: {path}") + + dp = data["detection_patterns"] + return PromptInjectionConfig( + direct_override_patterns=dp.get("direct_override", [p.pattern for p in _DIRECT_OVERRIDE_PATTERNS]), + delimiter_patterns=dp.get("delimiter", [p.pattern for p in _DELIMITER_PATTERNS]), + role_play_patterns=dp.get("role_play", [p.pattern for p in _ROLE_PLAY_PATTERNS]), + context_manipulation_patterns=dp.get("context_manipulation", [p.pattern for p in _CONTEXT_MANIPULATION_PATTERNS]), + multi_turn_patterns=dp.get("multi_turn", [p.pattern for p in _MULTI_TURN_PATTERNS]), + encoding_patterns=dp.get("encoding", [p.pattern for p in _ENCODING_PATTERNS]), + base64_pattern=dp.get("base64_pattern", _BASE64_PATTERN.pattern), + suspicious_decoded_keywords=data.get("suspicious_decoded_keywords", list(_SUSPICIOUS_DECODED_KEYWORDS)), + sensitivity_thresholds=data.get("sensitivity_thresholds", dict(_SENSITIVITY_THRESHOLDS)), + sensitivity_min_threat=data.get("sensitivity_min_threat", {k: v.value for k, v in _SENSITIVITY_MIN_THREAT.items()}), + disclaimer=data.get("disclaimer", ""), + ) + + # --------------------------------------------------------------------------- # PromptInjectionDetector # --------------------------------------------------------------------------- @@ -241,6 +324,14 @@ class PromptInjectionDetector: """ def __init__(self, config: DetectionConfig | None = None) -> None: + if config is None: + warnings.warn( + "PromptInjectionDetector() uses built-in sample rules that may not " + "cover all prompt injection techniques. For production use, load an " + "explicit config with load_prompt_injection_config(). " + "See examples/policies/prompt-injection-safety.yaml for a sample configuration.", + stacklevel=2, + ) self._config = config or DetectionConfig() self._audit_log: list[AuditRecord] = [] diff --git a/packages/agent-os/src/agent_os/sandbox.py b/packages/agent-os/src/agent_os/sandbox.py index 1fd95156..ce745e16 100644 --- a/packages/agent-os/src/agent_os/sandbox.py +++ b/packages/agent-os/src/agent_os/sandbox.py @@ -12,15 +12,23 @@ import ast import importlib.abc import importlib.machinery +import os import pathlib import sys -from dataclasses import dataclass +import warnings +from dataclasses import dataclass, field from typing import Any, Callable from pydantic import BaseModel, Field from agent_os.exceptions import SecurityError +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE sandbox rules provided as a starting point. " + "You MUST review, customise, and extend them for your specific use case " + "before deploying to production." +) + _DEFAULT_BLOCKED_MODULES: list[str] = [ "subprocess", "os", @@ -38,6 +46,57 @@ ] +# --------------------------------------------------------------------------- +# Externalised configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class SandboxSecurityConfig: + """Structured configuration for sandbox security rules, loadable from YAML. + + Attributes: + blocked_modules: Python modules to deny inside the sandbox. + blocked_builtins: Built-in functions to block. + disclaimer: Disclaimer text shown in logs. + """ + + blocked_modules: list[str] = field(default_factory=lambda: list(_DEFAULT_BLOCKED_MODULES)) + blocked_builtins: list[str] = field(default_factory=lambda: list(_DEFAULT_BLOCKED_BUILTINS)) + disclaimer: str = "" + + +def load_sandbox_config(path: str) -> SandboxSecurityConfig: + """Load sandbox security configuration from a YAML file. + + Args: + path: Path to a YAML file with a ``sandbox`` section. + + Returns: + SandboxSecurityConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``sandbox`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"Sandbox config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "sandbox" not in data: + raise ValueError(f"YAML file must contain a 'sandbox' section: {path}") + + sp = data["sandbox"] + return SandboxSecurityConfig( + blocked_modules=sp.get("blocked_modules", list(_DEFAULT_BLOCKED_MODULES)), + blocked_builtins=sp.get("blocked_builtins", list(_DEFAULT_BLOCKED_BUILTINS)), + disclaimer=data.get("disclaimer", ""), + ) + + class SandboxConfig(BaseModel): """Configuration for the execution sandbox.""" @@ -218,6 +277,14 @@ def __init__( config: SandboxConfig | None = None, policy: Any = None, ) -> None: + if config is None: + warnings.warn( + "ExecutionSandbox() uses built-in sample rules that may not " + "cover all sandbox evasion techniques. For production use, load an " + "explicit config with load_sandbox_config(). " + "See examples/policies/sandbox-safety.yaml for a sample configuration.", + stacklevel=2, + ) self.config = config or SandboxConfig() self.policy = policy self._hook = SandboxImportHook(self.config.blocked_modules) diff --git a/packages/agent-os/src/agent_os/semantic_policy.py b/packages/agent-os/src/agent_os/semantic_policy.py index 1b86f851..00188efe 100644 --- a/packages/agent-os/src/agent_os/semantic_policy.py +++ b/packages/agent-os/src/agent_os/semantic_policy.py @@ -23,11 +23,23 @@ from __future__ import annotations +import os import re -from dataclasses import dataclass +import warnings +from dataclasses import dataclass, field from enum import Enum from typing import Any +# ============================================================================= +# Disclaimer +# ============================================================================= + +_SAMPLE_DISCLAIMER = ( + "\u26a0\ufe0f These are SAMPLE semantic policy signals provided as a starting " + "point. You MUST review, customise, and extend them for your specific use " + "case before deploying to production." +) + # ============================================================================= # Intent Categories # ============================================================================= @@ -166,6 +178,68 @@ def __init__(self, classification: IntentClassification, policy_name: str = ""): } +# ============================================================================= +# Externalised configuration dataclass +# ============================================================================= + + +@dataclass +class SemanticPolicyConfig: + """Structured configuration for semantic policy signals, loadable from YAML. + + Attributes: + signals: Mapping of IntentCategory names to lists of + ``(pattern, weight, explanation)`` tuples. + disclaimer: Disclaimer text shown in logs. + """ + + signals: dict[str, list[tuple[str, float, str]]] = field( + default_factory=lambda: { + cat.value: [(p, w, e) for p, w, e in sigs] + for cat, sigs in _SIGNALS.items() + } + ) + disclaimer: str = "" + + +def load_semantic_policy_config(path: str) -> SemanticPolicyConfig: + """Load semantic policy configuration from a YAML file. + + Args: + path: Path to a YAML file with a ``signals`` section. + + Returns: + SemanticPolicyConfig populated from the YAML data. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the YAML is missing the ``signals`` section. + """ + import yaml + + if not os.path.exists(path): + raise FileNotFoundError(f"Semantic policy config not found: {path}") + + with open(path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh.read()) + + if not isinstance(data, dict) or "signals" not in data: + raise ValueError(f"YAML file must contain a 'signals' section: {path}") + + raw_signals = data["signals"] + signals: dict[str, list[tuple[str, float, str]]] = {} + for category_name, entries in raw_signals.items(): + signals[category_name] = [ + (entry["pattern"], float(entry["weight"]), entry.get("explanation", "")) + for entry in entries + ] + + return SemanticPolicyConfig( + signals=signals, + disclaimer=data.get("disclaimer", ""), + ) + + # ============================================================================= # Semantic Policy Engine # ============================================================================= @@ -187,13 +261,24 @@ def __init__( deny: list[IntentCategory] | None = None, confidence_threshold: float = 0.5, custom_signals: dict[IntentCategory, list[tuple]] | None = None, + config: SemanticPolicyConfig | None = None, ): """ Args: deny: Intent categories to deny (default: all dangerous categories) confidence_threshold: Minimum confidence to trigger deny (0.0-1.0) custom_signals: Additional signal patterns to merge with defaults + config: Optional externalized configuration loaded via + ``load_semantic_policy_config()``. """ + if config is None and custom_signals is None: + warnings.warn( + "SemanticPolicyEngine() uses built-in sample rules that may not " + "cover all malicious intent patterns. For production use, load an " + "explicit config with load_semantic_policy_config(). " + "See examples/policies/semantic-policy.yaml for a sample configuration.", + stacklevel=2, + ) self.deny_categories: set[IntentCategory] = set(deny) if deny else { IntentCategory.DESTRUCTIVE_DATA, IntentCategory.DATA_EXFILTRATION, @@ -202,7 +287,19 @@ def __init__( IntentCategory.CODE_EXECUTION, } self.confidence_threshold = confidence_threshold - self.signals = {k: list(v) for k, v in _SIGNALS.items()} + + # Build signals from config or defaults + if config is not None: + self.signals: dict[IntentCategory, list[tuple]] = {} + for cat_name, sigs in config.signals.items(): + try: + cat = IntentCategory(cat_name) + except ValueError: + continue + self.signals[cat] = list(sigs) + else: + self.signals = {k: list(v) for k, v in _SIGNALS.items()} + if custom_signals: for cat, sigs in custom_signals.items(): self.signals.setdefault(cat, []).extend(sigs) @@ -319,5 +416,7 @@ def _build_text(action: str, params: dict[str, Any]) -> str: "IntentCategory", "IntentClassification", "PolicyDenied", + "SemanticPolicyConfig", "SemanticPolicyEngine", + "load_semantic_policy_config", ]